mirror of
https://github.com/SapphireServer/Sapphire.git
synced 2025-04-25 14:07:46 +00:00
Removed battlenpc for cleanup work
This commit is contained in:
parent
8e38ffdb56
commit
b8a366fb29
13 changed files with 7 additions and 930 deletions
|
@ -2,7 +2,6 @@
|
|||
|
||||
#include "Player.h"
|
||||
#include "Chara.h"
|
||||
#include "BattleNpc.h"
|
||||
|
||||
Core::Entity::Actor::Actor( ObjKind type ) :
|
||||
m_objKind( type )
|
||||
|
@ -71,10 +70,3 @@ Core::Entity::PlayerPtr Core::Entity::Actor::getAsPlayer()
|
|||
return boost::dynamic_pointer_cast< Entity::Player, Entity::Actor >( shared_from_this() );
|
||||
}
|
||||
|
||||
/*! \return pointer to this instance as BattleNpcPtr */
|
||||
Core::Entity::BattleNpcPtr Core::Entity::Actor::getAsBattleNpc()
|
||||
{
|
||||
if( !isBattleNpc() )
|
||||
return nullptr;
|
||||
return boost::dynamic_pointer_cast< Entity::BattleNpc, Entity::Actor >( shared_from_this() );
|
||||
}
|
|
@ -72,7 +72,6 @@ namespace Entity {
|
|||
|
||||
CharaPtr getAsChara();
|
||||
PlayerPtr getAsPlayer();
|
||||
BattleNpcPtr getAsBattleNpc();
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -1,569 +0,0 @@
|
|||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdint.h>
|
||||
#include <cmath>
|
||||
|
||||
#include <common/Logging/Logger.h>
|
||||
#include <common/Exd/ExdDataGenerated.h>
|
||||
#include <common/Util/Util.h>
|
||||
#include <common/Util/UtilMath.h>
|
||||
|
||||
#include "Player.h"
|
||||
#include "BattleNpc.h"
|
||||
|
||||
#include "Network/PacketWrappers/MoveActorPacket.h"
|
||||
#include "Network/PacketWrappers/ActorControlPacket142.h"
|
||||
#include "Network/PacketWrappers/ActorControlPacket143.h"
|
||||
|
||||
using namespace Core::Common;
|
||||
using namespace Core::Network::Packets;
|
||||
using namespace Core::Network::Packets::Server;
|
||||
|
||||
extern Core::Logger g_log;
|
||||
extern Core::Data::ExdDataGenerated g_exdDataGen;
|
||||
|
||||
uint32_t Core::Entity::BattleNpc::m_nextID = 1149241694;
|
||||
|
||||
Core::Entity::BattleNpc::BattleNpc() :
|
||||
Chara( ObjKind::BattleNpc )
|
||||
{
|
||||
m_id = 0;
|
||||
m_status = ActorStatus::Idle;
|
||||
}
|
||||
|
||||
Core::Entity::BattleNpc::~BattleNpc()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
Core::Entity::BattleNpc::BattleNpc( uint16_t modelId, uint16_t nameid, const Common::FFXIVARR_POSITION3& spawnPos,
|
||||
uint16_t bnpcBaseId, uint32_t type, uint8_t level, uint8_t behaviour,
|
||||
uint32_t mobType ) :
|
||||
Chara( ObjKind::BattleNpc )
|
||||
{
|
||||
BattleNpc::m_nextID++;
|
||||
m_id = BattleNpc::m_nextID;
|
||||
//strcpy( m_name, pBNpc->m_name.c_str() );
|
||||
|
||||
m_pos = spawnPos;
|
||||
m_posOrigin = spawnPos;
|
||||
|
||||
m_mode = MODE_IDLE;
|
||||
m_targetId = static_cast< uint64_t >( INVALID_GAME_OBJECT_ID );
|
||||
|
||||
m_maxHp = 150;
|
||||
m_maxMp = 100;
|
||||
|
||||
m_baseStats.max_hp = m_maxHp;
|
||||
m_baseStats.max_mp = m_maxMp;
|
||||
|
||||
m_hp = m_maxHp;
|
||||
m_mp = m_maxMp;
|
||||
|
||||
m_currentStance = Stance::Passive;
|
||||
|
||||
m_class = ClassJob::Gladiator;
|
||||
m_level = level > uint8_t{0} ? level : uint8_t{70};
|
||||
|
||||
m_modelId = modelId;
|
||||
m_nameId = nameid;
|
||||
|
||||
m_behavior = behaviour;
|
||||
|
||||
m_bnpcBaseId = bnpcBaseId;
|
||||
|
||||
m_status = ActorStatus::Idle;
|
||||
|
||||
m_pOwner = nullptr;
|
||||
|
||||
m_mobType = mobType;
|
||||
|
||||
m_invincibilityType = InvincibilityType::InvincibilityNone;
|
||||
|
||||
//m_type = static_cast< Common::ObjKind >( type );
|
||||
|
||||
}
|
||||
|
||||
// spawn this player for pTarget
|
||||
void Core::Entity::BattleNpc::spawn( PlayerPtr pTarget )
|
||||
{
|
||||
//GamePacketNew< FFXIVIpcActorSpawn > spawnPacket( getId(), pTarget->getId() );
|
||||
|
||||
//spawnPacket.data().unknown_0 = 0;
|
||||
//spawnPacket.data().ownerId = m_pOwner == nullptr ? INVALID_GAME_OBJECT_ID : m_pOwner->getId();
|
||||
//spawnPacket.data().targetId = INVALID_GAME_OBJECT_ID & 0xFFFFFFFF;
|
||||
//spawnPacket.data().hPCurr = m_hp;
|
||||
//spawnPacket.data().hPMax = m_baseStats.max_hp;
|
||||
//spawnPacket.data().level = m_level;
|
||||
////spawnPacket.data().tPCurr = 1000;
|
||||
//spawnPacket.data().model = m_modelId;
|
||||
//spawnPacket.data().bnpcBaseId = m_bnpcBaseId;
|
||||
//spawnPacket.data().nameId = m_nameId;
|
||||
//spawnPacket.data().spawnIndex = pTarget->getSpawnIdForActorId( getId() );
|
||||
//g_log.info(std::to_string(spawnPacket.data().spawnIndex) + " " + std::to_string(getId()));
|
||||
//spawnPacket.data().status = static_cast< uint8_t >( m_status );
|
||||
//spawnPacket.data().mobAgressive = m_behavior;
|
||||
//spawnPacket.data().type = static_cast< uint8_t >( m_type );
|
||||
//spawnPacket.data().mobTypeIcon = m_mobType;
|
||||
//spawnPacket.data().unknown_33 = 5;
|
||||
//spawnPacket.data().typeFlags = 4;
|
||||
//spawnPacket.data().pos.x = m_pos.x;
|
||||
//spawnPacket.data().pos.y = m_pos.y;
|
||||
//spawnPacket.data().pos.z = m_pos.z;
|
||||
//spawnPacket.data().rotation = Math::Util::floatToUInt16Rot( getRotation() );
|
||||
////spawnPacket.data().unknown_B0[11] = 1;
|
||||
////spawnPacket.data().unknown_B0[12] = 4;
|
||||
////spawnPacket.data().unknown_B0[14] = 20;
|
||||
|
||||
//pTarget->queuePacket( spawnPacket );
|
||||
|
||||
ZoneChannelPacket< FFXIVIpcNpcSpawn > spawnPacket( getId(), pTarget->getId() );
|
||||
|
||||
|
||||
spawnPacket.data().pos.x = m_pos.x;
|
||||
spawnPacket.data().pos.y = m_pos.y;
|
||||
spawnPacket.data().pos.z = m_pos.z;
|
||||
|
||||
spawnPacket.data().targetId = INVALID_GAME_OBJECT_ID & 0xFFFFFFFF;
|
||||
spawnPacket.data().hPCurr = m_hp;
|
||||
spawnPacket.data().hPMax = m_baseStats.max_hp;
|
||||
spawnPacket.data().level = m_level;
|
||||
|
||||
spawnPacket.data().subtype = 5;
|
||||
spawnPacket.data().enemyType = 4;
|
||||
|
||||
spawnPacket.data().modelChara = m_modelId;
|
||||
spawnPacket.data().bNPCBase = m_bnpcBaseId;
|
||||
spawnPacket.data().bNPCName = m_nameId;
|
||||
spawnPacket.data().spawnIndex = pTarget->getSpawnIdForActorId( getId() );
|
||||
|
||||
spawnPacket.data().rotation = Math::Util::floatToUInt16Rot( getRotation() );
|
||||
|
||||
spawnPacket.data().type = static_cast< uint8_t >( m_objKind );
|
||||
|
||||
spawnPacket.data().state = static_cast< uint8_t >( m_status );
|
||||
|
||||
pTarget->queuePacket( spawnPacket );
|
||||
}
|
||||
|
||||
// despawn
|
||||
void Core::Entity::BattleNpc::despawn( PlayerPtr pPlayer )
|
||||
{
|
||||
pPlayer->freePlayerSpawnId( getId() );
|
||||
|
||||
ActorControlPacket143 controlPacket( m_id, DespawnZoneScreenMsg, 0x04, getId(), 0x01 );
|
||||
pPlayer->queuePacket( controlPacket );
|
||||
|
||||
}
|
||||
|
||||
uint8_t Core::Entity::BattleNpc::getLevel() const
|
||||
{
|
||||
return m_level;
|
||||
}
|
||||
|
||||
Core::Entity::StateMode Core::Entity::BattleNpc::getMode() const
|
||||
{
|
||||
return m_mode;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::setMode( StateMode mode )
|
||||
{
|
||||
m_mode = mode;
|
||||
}
|
||||
|
||||
uint8_t Core::Entity::BattleNpc::getbehavior() const
|
||||
{
|
||||
return m_behavior;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::hateListAdd( Chara& actor, int32_t hateAmount )
|
||||
{
|
||||
auto hateEntry = new HateListEntry();
|
||||
hateEntry->m_hateAmount = hateAmount;
|
||||
hateEntry->m_pChara = actor.getAsChara();
|
||||
|
||||
m_hateList.insert( hateEntry );
|
||||
}
|
||||
|
||||
Core::Entity::CharaPtr Core::Entity::BattleNpc::hateListGetHighest()
|
||||
{
|
||||
|
||||
auto it = m_hateList.begin();
|
||||
uint32_t maxHate = 0;
|
||||
HateListEntry* entry = nullptr;
|
||||
for( ; it != m_hateList.end(); ++it )
|
||||
{
|
||||
if( ( *it )->m_hateAmount > maxHate )
|
||||
{
|
||||
maxHate = ( *it )->m_hateAmount;
|
||||
entry = *it;
|
||||
}
|
||||
}
|
||||
|
||||
if( entry && maxHate != 0 )
|
||||
return entry->m_pChara;
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::setOwner( PlayerPtr pPlayer )
|
||||
{
|
||||
m_pOwner = pPlayer;
|
||||
|
||||
if( pPlayer != nullptr )
|
||||
{
|
||||
ZoneChannelPacket< FFXIVIpcActorOwner > setOwnerPacket( getId(), pPlayer->getId() );
|
||||
setOwnerPacket.data().type = 0x01;
|
||||
setOwnerPacket.data().actorId = pPlayer->getId();
|
||||
sendToInRangeSet( setOwnerPacket );
|
||||
}
|
||||
else
|
||||
{
|
||||
ZoneChannelPacket< FFXIVIpcActorOwner > setOwnerPacket(getId(), INVALID_GAME_OBJECT_ID );
|
||||
setOwnerPacket.data().type = 0x01;
|
||||
setOwnerPacket.data().actorId = INVALID_GAME_OBJECT_ID;
|
||||
sendToInRangeSet( setOwnerPacket );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::sendPositionUpdate()
|
||||
{
|
||||
MoveActorPacket movePacket( *this, 0x3A, 0x00, 0, 0x5A );
|
||||
sendToInRangeSet( movePacket );
|
||||
}
|
||||
|
||||
bool Core::Entity::BattleNpc::moveTo( Common::FFXIVARR_POSITION3& pos )
|
||||
{
|
||||
|
||||
if( Math::Util::distance( getPos().x, getPos().y, getPos().z,
|
||||
pos.x, pos.y, pos.z ) <= 4 )
|
||||
// reached destination
|
||||
return true;
|
||||
|
||||
float rot = Math::Util::calcAngFrom( getPos().x, getPos().z, pos.x, pos.z );
|
||||
float newRot = PI - rot + ( PI / 2 );
|
||||
|
||||
face( pos );
|
||||
float angle = Math::Util::calcAngFrom( getPos().x, getPos().z, pos.x, pos.z ) + PI;
|
||||
|
||||
float x = static_cast< float >( cosf( angle ) * 1.1f );
|
||||
float y = ( getPos().y + pos.y ) * 0.5f; // fake value while there is no collision
|
||||
float z = static_cast< float >( sinf( angle ) * 1.1f );
|
||||
|
||||
Common::FFXIVARR_POSITION3 newPos{};
|
||||
|
||||
newPos.x = getPos().x + x;
|
||||
newPos.y = y;
|
||||
newPos.z = getPos().z + z;
|
||||
|
||||
setPosition( newPos );
|
||||
|
||||
Common::FFXIVARR_POSITION3 tmpPos{};
|
||||
tmpPos.x = getPos().x + x;
|
||||
tmpPos.y = y;
|
||||
tmpPos.z = getPos().z + z;
|
||||
|
||||
setPosition( tmpPos );
|
||||
setRotation(newRot);
|
||||
|
||||
sendPositionUpdate();
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::aggro( Chara& actor )
|
||||
{
|
||||
|
||||
m_lastAttack = Util::getTimeMs();
|
||||
hateListUpdate( actor, 1 );
|
||||
|
||||
changeTarget( actor.getId() );
|
||||
setStance( Stance::Active );
|
||||
m_mode = MODE_COMBAT;
|
||||
|
||||
if( actor.isPlayer() )
|
||||
{
|
||||
PlayerPtr tmpPlayer = actor.getAsPlayer();
|
||||
tmpPlayer->queuePacket( ActorControlPacket142( getId(), 0, 1, 1 ) );
|
||||
tmpPlayer->onMobAggro( getAsBattleNpc() );
|
||||
}
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::deaggro( Chara& actor )
|
||||
{
|
||||
if( !hateListHasActor( actor ) )
|
||||
hateListRemove( actor );
|
||||
|
||||
if( actor.isPlayer() )
|
||||
{
|
||||
PlayerPtr tmpPlayer = actor.getAsPlayer();
|
||||
tmpPlayer->onMobDeaggro( getAsBattleNpc() );
|
||||
}
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::hateListClear()
|
||||
{
|
||||
auto it = m_hateList.begin();
|
||||
for( ; it != m_hateList.end(); ++it )
|
||||
{
|
||||
if( isInRangeSet( ( *it )->m_pChara ) )
|
||||
deaggro( *( *it )->m_pChara );
|
||||
HateListEntry* tmpListEntry = ( *it );
|
||||
delete tmpListEntry;
|
||||
}
|
||||
m_hateList.clear();
|
||||
}
|
||||
|
||||
|
||||
void Core::Entity::BattleNpc::hateListRemove( Chara& actor )
|
||||
{
|
||||
auto it = m_hateList.begin();
|
||||
for( ; it != m_hateList.end(); ++it )
|
||||
{
|
||||
if( ( *it )->m_pChara->getId() == actor.getId() )
|
||||
{
|
||||
HateListEntry* pEntry = *it;
|
||||
m_hateList.erase( it );
|
||||
delete pEntry;
|
||||
if( actor.isPlayer() )
|
||||
{
|
||||
PlayerPtr tmpPlayer = actor.getAsPlayer();
|
||||
tmpPlayer->onMobDeaggro( getAsBattleNpc() );
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Core::Entity::BattleNpc::hateListHasActor( Chara& actor )
|
||||
{
|
||||
auto it = m_hateList.begin();
|
||||
for( ; it != m_hateList.end(); ++it )
|
||||
{
|
||||
if( ( *it )->m_pChara->getId() == actor.getId() )
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::resetPos()
|
||||
{
|
||||
m_pos = m_posOrigin;
|
||||
}
|
||||
|
||||
uint32_t Core::Entity::BattleNpc::getNameId() const
|
||||
{
|
||||
return m_nameId;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::hateListUpdate( Chara& actor, int32_t hateAmount )
|
||||
{
|
||||
|
||||
auto it = m_hateList.begin();
|
||||
for( ; it != m_hateList.end(); ++it )
|
||||
{
|
||||
if( ( *it )->m_pChara->getId() == actor.getId() )
|
||||
{
|
||||
( *it )->m_hateAmount += hateAmount;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
auto hateEntry = new HateListEntry();
|
||||
hateEntry->m_hateAmount = hateAmount;
|
||||
hateEntry->m_pChara = actor.getAsChara();
|
||||
m_hateList.insert( hateEntry );
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::onDeath()
|
||||
{
|
||||
//LuaManager->onMobDeath( this );
|
||||
|
||||
setTimeOfDeath( static_cast< uint32_t >( time( nullptr ) ) );
|
||||
setTargetId( INVALID_GAME_OBJECT_ID );
|
||||
m_currentStance = Stance::Passive;
|
||||
m_mode = MODE_IDLE;
|
||||
m_hp = 0;
|
||||
setOwner( nullptr );
|
||||
|
||||
// todo: fully ghetto retarded exp reward pls fix
|
||||
{
|
||||
uint32_t minHate = -1;
|
||||
uint32_t maxHate = 0;
|
||||
uint32_t totalHate = 0;
|
||||
for( auto& pHateEntry : m_hateList )
|
||||
{
|
||||
if( pHateEntry->m_pChara->isPlayer() )
|
||||
{
|
||||
if( pHateEntry->m_hateAmount < minHate )
|
||||
minHate = pHateEntry->m_hateAmount;
|
||||
else if( pHateEntry->m_hateAmount > maxHate )
|
||||
maxHate = pHateEntry->m_hateAmount;
|
||||
}
|
||||
totalHate += pHateEntry->m_hateAmount;
|
||||
}
|
||||
|
||||
//uint32_t plsBeHatedThisMuchAtLeast = totalHate / ( maxHate + 2 ) / clamp( m_hateList.size(), 1.0f, 1.5f );
|
||||
|
||||
for( auto& pHateEntry : m_hateList )
|
||||
{
|
||||
// todo: this is pure retarded
|
||||
// todo: check for companion
|
||||
if( pHateEntry->m_pChara->isPlayer() ) // && pHateEntry->m_hateAmount >= plsBeHatedThisMuchAtLeast )
|
||||
{
|
||||
uint8_t level = pHateEntry->m_pChara->getLevel();
|
||||
auto levelDiff = static_cast< int32_t >( this->m_level ) - level;
|
||||
auto cappedLevelDiff = Math::Util::clamp( levelDiff, 1, 6 );
|
||||
|
||||
auto expNeeded = g_exdDataGen.get< Core::Data::ParamGrow >( m_level + cappedLevelDiff - 1 )->expToNext;
|
||||
int32_t exp = 0;
|
||||
|
||||
// todo: arbitrary numbers pulled out of my ass
|
||||
if( m_level <= 14 )
|
||||
exp = ( expNeeded / ( 100 - levelDiff) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 9 ) ) + 1 ) );
|
||||
else if( m_level <= 24 )
|
||||
exp = ( expNeeded / ( 150 - levelDiff) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 8 ) ) + 1 ) );
|
||||
else if( m_level <= 34 )
|
||||
exp = ( expNeeded / ( 350 - levelDiff ) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 7 ) ) + 1 ) );
|
||||
else if( m_level <= 44 )
|
||||
exp = ( expNeeded / ( 550 - levelDiff ) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 6 ) ) + 1 ) );
|
||||
else if( m_level <= 50 )
|
||||
exp = ( expNeeded / ( 750 - levelDiff ) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 5 ) ) + 1 ) );
|
||||
else
|
||||
exp = ( expNeeded / ( 1200 - levelDiff ) ) + cappedLevelDiff + ( cappedLevelDiff * ( ( rand() % ( cappedLevelDiff * 4 ) ) + 1 ) );
|
||||
|
||||
|
||||
// todo: this is actually retarded, we need real rand()
|
||||
srand( static_cast< uint32_t > ( time( nullptr ) ) );
|
||||
|
||||
auto pPlayer = pHateEntry->m_pChara->getAsPlayer();
|
||||
pPlayer->gainExp( exp );
|
||||
pPlayer->onMobKill( m_nameId );
|
||||
}
|
||||
}
|
||||
}
|
||||
hateListClear();
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::onActionHostile( Chara& source )
|
||||
{
|
||||
|
||||
if( hateListGetHighest() == nullptr )
|
||||
aggro( source );
|
||||
|
||||
if( getClaimer() == nullptr )
|
||||
setOwner( source.getAsPlayer() );
|
||||
}
|
||||
|
||||
Core::Entity::CharaPtr Core::Entity::BattleNpc::getClaimer() const
|
||||
{
|
||||
return m_pOwner;
|
||||
}
|
||||
|
||||
|
||||
// HACK: this is highly experimental code, will have to be changed eventually
|
||||
// since there are different types of mobs... (stationary, moving...) likely to be
|
||||
// handled by scripts entirely.
|
||||
void Core::Entity::BattleNpc::update( int64_t currTime )
|
||||
{
|
||||
|
||||
if( !isAlive() )
|
||||
{
|
||||
m_status = ActorStatus::Idle;
|
||||
m_mode = MODE_IDLE;
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatusEffects();
|
||||
float distance = Math::Util::distance( m_pos.x, m_pos.y, m_pos.z,
|
||||
m_posOrigin.x, m_posOrigin.y, m_posOrigin.z );
|
||||
|
||||
if( ( distance > 70 ) && m_mode != MODE_RETREAT )
|
||||
{
|
||||
changeTarget( INVALID_GAME_OBJECT_ID );
|
||||
m_mode = MODE_RETREAT;
|
||||
hateListClear();
|
||||
setOwner( nullptr );
|
||||
}
|
||||
|
||||
switch( m_mode )
|
||||
{
|
||||
|
||||
case MODE_RETREAT:
|
||||
{
|
||||
if( moveTo( m_posOrigin ) )
|
||||
m_mode = MODE_IDLE;
|
||||
}
|
||||
break;
|
||||
|
||||
case MODE_IDLE:
|
||||
{
|
||||
CharaPtr pClosestChara = getClosestChara();
|
||||
|
||||
if( pClosestChara && pClosestChara->isAlive() )
|
||||
{
|
||||
distance = Math::Util::distance( getPos().x, getPos().y, getPos().z,
|
||||
pClosestChara->getPos().x,
|
||||
pClosestChara->getPos().y,
|
||||
pClosestChara->getPos().z );
|
||||
|
||||
//if( distance < 8 && getbehavior() == 2 )
|
||||
// aggro( pClosestChara );
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MODE_COMBAT:
|
||||
{
|
||||
CharaPtr pClosestChara = hateListGetHighest();
|
||||
|
||||
if( pClosestChara != nullptr && !pClosestChara->isAlive() )
|
||||
{
|
||||
hateListRemove( *pClosestChara );
|
||||
pClosestChara = hateListGetHighest();
|
||||
}
|
||||
|
||||
if( pClosestChara != nullptr )
|
||||
{
|
||||
distance = Math::Util::distance( getPos().x, getPos().y, getPos().z,
|
||||
pClosestChara->getPos().x,
|
||||
pClosestChara->getPos().y,
|
||||
pClosestChara->getPos().z );
|
||||
|
||||
if( distance > 4 )
|
||||
moveTo( pClosestChara->getPos() );
|
||||
else
|
||||
{
|
||||
if( face( pClosestChara->getPos() ) )
|
||||
sendPositionUpdate();
|
||||
// in combat range. ATTACK!
|
||||
autoAttack( pClosestChara );
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
changeTarget( INVALID_GAME_OBJECT_ID );
|
||||
setStance( Stance::Passive );
|
||||
setOwner( nullptr );
|
||||
m_mode = MODE_RETREAT;
|
||||
}
|
||||
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
uint32_t Core::Entity::BattleNpc::getTimeOfDeath() const
|
||||
{
|
||||
return m_timeOfDeath;
|
||||
}
|
||||
|
||||
void Core::Entity::BattleNpc::setTimeOfDeath( uint32_t tod )
|
||||
{
|
||||
m_timeOfDeath = tod;
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
#ifndef _BATTLENPC_H
|
||||
#define _BATTLENPC_H
|
||||
|
||||
#include "Chara.h"
|
||||
|
||||
namespace Core {
|
||||
namespace Entity {
|
||||
|
||||
enum StateMode
|
||||
{
|
||||
MODE_COMBAT,
|
||||
MODE_RETREAT,
|
||||
MODE_IDLE,
|
||||
};
|
||||
|
||||
typedef struct
|
||||
{
|
||||
uint32_t m_hateAmount;
|
||||
CharaPtr m_pChara;
|
||||
} HateListEntry;
|
||||
|
||||
// class for Mobs inheriting from Chara
|
||||
class BattleNpc : public Chara
|
||||
{
|
||||
public:
|
||||
BattleNpc();
|
||||
virtual ~BattleNpc() override;
|
||||
|
||||
BattleNpc( uint16_t modelId, uint16_t nameid, const Common::FFXIVARR_POSITION3& spawnPos, uint16_t bnpcBaseId = 0,
|
||||
uint32_t type = 2, uint8_t level = 0, uint8_t behaviour = 1, uint32_t mobType = 0 );
|
||||
|
||||
//BattleNpc( uint32_t modelId,
|
||||
// uint32_t nameId,
|
||||
// uint32_t bnpcBaseId,
|
||||
// uint32_t level,
|
||||
// const Common::FFXIVARR_POSITION3& spawnPos,
|
||||
// uint32_t type = 2, uint32_t behaviour = 1, uint32_t mobType = 0 );
|
||||
|
||||
void initStatusEffectContainer();
|
||||
|
||||
// send spawn packets to pTarget
|
||||
void spawn( PlayerPtr pTarget ) override;
|
||||
|
||||
// send despawn packets to pTarget
|
||||
void despawn( PlayerPtr pTarget ) override;
|
||||
|
||||
uint8_t getLevel() const override;
|
||||
|
||||
StateMode getMode() const;
|
||||
|
||||
void setMode( StateMode mode );
|
||||
|
||||
uint8_t getbehavior() const;
|
||||
|
||||
void hateListAdd( Chara& actor, int32_t hateAmount );
|
||||
|
||||
void hateListUpdate( Chara& actor, int32_t hateAmount );
|
||||
void hateListRemove( Chara& actor );
|
||||
|
||||
bool hateListHasActor( Chara& actor );
|
||||
|
||||
void resetPos();
|
||||
|
||||
uint32_t getNameId() const;
|
||||
|
||||
void hateListClear();
|
||||
|
||||
CharaPtr hateListGetHighest();
|
||||
|
||||
void aggro( Chara& actor );
|
||||
|
||||
void deaggro( Chara& actor );
|
||||
|
||||
void setOwner( PlayerPtr pPlayer );
|
||||
|
||||
void onDeath() override;
|
||||
|
||||
void onActionHostile( Chara& source ) override;
|
||||
|
||||
CharaPtr getClaimer() const;
|
||||
|
||||
void sendPositionUpdate();
|
||||
|
||||
// return true if it reached the position
|
||||
bool moveTo( Common::FFXIVARR_POSITION3& pos );
|
||||
|
||||
void update( int64_t currTime ) override;
|
||||
|
||||
uint32_t getTimeOfDeath() const;
|
||||
|
||||
void setTimeOfDeath( uint32_t tod );
|
||||
|
||||
private:
|
||||
|
||||
static uint32_t m_nextID;
|
||||
StateMode m_mode;
|
||||
Common::FFXIVARR_POSITION3 m_posOrigin;
|
||||
uint8_t m_level;
|
||||
uint16_t m_modelId;
|
||||
uint16_t m_nameId;
|
||||
uint16_t m_bnpcBaseId;
|
||||
uint8_t m_behavior;
|
||||
uint32_t m_unk1;
|
||||
uint32_t m_unk2;
|
||||
std::set< HateListEntry* > m_hateList;
|
||||
CharaPtr m_pOwner;
|
||||
uint32_t m_timeOfDeath;
|
||||
uint32_t m_mobType;
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
#include "Session.h"
|
||||
#include "Player.h"
|
||||
#include "BattleNpc.h"
|
||||
|
||||
#include "Zone/TerritoryMgr.h"
|
||||
#include "Zone/Zone.h"
|
||||
|
@ -1266,64 +1265,6 @@ void Core::Entity::Player::updateHowtosSeen( uint32_t howToId )
|
|||
m_howTo[index] |= value;
|
||||
}
|
||||
|
||||
|
||||
void Core::Entity::Player::onMobAggro( BattleNpcPtr pBNpc )
|
||||
{
|
||||
hateListAdd( pBNpc );
|
||||
|
||||
queuePacket( ActorControlPacket142( getId(), ToggleAggro, 1 ) );
|
||||
}
|
||||
|
||||
void Core::Entity::Player::onMobDeaggro( BattleNpcPtr pBNpc )
|
||||
{
|
||||
hateListRemove( pBNpc );
|
||||
|
||||
if( m_actorIdTohateSlotMap.empty() )
|
||||
queuePacket( ActorControlPacket142( getId(), ToggleAggro ) );
|
||||
}
|
||||
|
||||
void Core::Entity::Player::hateListAdd( BattleNpcPtr pBNpc )
|
||||
|
||||
{
|
||||
if( m_freeHateSlotQueue.empty() )
|
||||
return;
|
||||
uint8_t hateId = m_freeHateSlotQueue.front();
|
||||
m_freeHateSlotQueue.pop();
|
||||
m_actorIdTohateSlotMap[pBNpc->getId()] = hateId;
|
||||
sendHateList();
|
||||
|
||||
}
|
||||
|
||||
void Core::Entity::Player::hateListRemove( BattleNpcPtr pBNpc )
|
||||
{
|
||||
|
||||
auto it = m_actorIdTohateSlotMap.begin();
|
||||
for( ; it != m_actorIdTohateSlotMap.end(); ++it )
|
||||
{
|
||||
if( it->first == pBNpc->getId() )
|
||||
{
|
||||
uint8_t hateSlot = it->second;
|
||||
m_freeHateSlotQueue.push( hateSlot );
|
||||
m_actorIdTohateSlotMap.erase( it );
|
||||
sendHateList();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Core::Entity::Player::hateListHasMob( BattleNpcPtr pBNpc )
|
||||
{
|
||||
|
||||
auto it = m_actorIdTohateSlotMap.begin();
|
||||
for( ; it != m_actorIdTohateSlotMap.end(); ++it )
|
||||
{
|
||||
if( it->first == pBNpc->getId() )
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Core::Entity::Player::initHateSlotQueue()
|
||||
{
|
||||
m_freeHateSlotQueue = std::queue< uint8_t >();
|
||||
|
|
|
@ -513,14 +513,7 @@ public:
|
|||
|
||||
// Player Battle Handling
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
void onMobAggro( BattleNpcPtr pBNpc );
|
||||
void onMobDeaggro( BattleNpcPtr pBNpc );
|
||||
|
||||
void initHateSlotQueue();
|
||||
void hateListAdd( BattleNpcPtr pBNpc );
|
||||
void hateListRemove( BattleNpcPtr pBNpc );
|
||||
|
||||
bool hateListHasMob( BattleNpcPtr pBNpc );
|
||||
|
||||
void sendHateList();
|
||||
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
#include "Script/NativeScriptManager.h"
|
||||
|
||||
#include "Actor/Player.h"
|
||||
#include "Actor/BattleNpc.h"
|
||||
|
||||
#include "Zone/Zone.h"
|
||||
#include "Zone/InstanceContent.h"
|
||||
|
@ -357,19 +356,6 @@ void Core::DebugCommandHandler::add( char * data, Entity::Player& player, boost:
|
|||
player.addTitle( titleId );
|
||||
player.sendNotice( "Added title (ID: " + std::to_string( titleId ) + ")" );
|
||||
}
|
||||
else if( subCommand == "spawn" )
|
||||
{
|
||||
int32_t model, name;
|
||||
|
||||
sscanf( params.c_str(), "%d %d", &model, &name );
|
||||
|
||||
auto pBNpc = Entity::make_BattleNpc( model, name, player.getPos() );
|
||||
|
||||
auto pZone = player.getCurrentZone();
|
||||
pBNpc->setCurrentZone( pZone );
|
||||
pZone->pushActor( pBNpc );
|
||||
|
||||
}
|
||||
else if( subCommand == "op" )
|
||||
{
|
||||
// temporary research packet
|
||||
|
|
|
@ -37,7 +37,6 @@ namespace Core
|
|||
TYPE_FORWARD( Actor );
|
||||
TYPE_FORWARD( Chara );
|
||||
TYPE_FORWARD( Player );
|
||||
TYPE_FORWARD( BattleNpc );
|
||||
TYPE_FORWARD( EventObject );
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
#include "Zone/Zone.h"
|
||||
#include "Actor/Player.h"
|
||||
#include "Actor/BattleNpc.h"
|
||||
#include "ServerZone.h"
|
||||
#include "Event/EventHandler.h"
|
||||
#include "Event/EventHelper.h"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
#include "Cell.h"
|
||||
|
||||
#include "Actor/Chara.h"
|
||||
#include "Actor/BattleNpc.h"
|
||||
#include "Forwards.h"
|
||||
#include "Zone.h"
|
||||
|
||||
|
@ -24,7 +23,6 @@ Core::Cell::~Cell()
|
|||
|
||||
void Core::Cell::init( uint32_t x, uint32_t y, ZonePtr pZone )
|
||||
{
|
||||
//Console->outDebOnly("[Region:%X] Initializing a new cell[%i/%i]", pRegion->getId(), x, y );
|
||||
m_pZone = pZone;
|
||||
m_posX = x;
|
||||
m_posY = y;
|
||||
|
@ -32,24 +30,12 @@ void Core::Cell::init( uint32_t x, uint32_t y, ZonePtr pZone )
|
|||
m_charas.clear();
|
||||
}
|
||||
|
||||
void Core::Cell::loadCharas( CellCache* pCC )
|
||||
{
|
||||
m_bLoaded = true;
|
||||
assert( pCC );
|
||||
|
||||
for( auto entry : pCC->battleNpcCache )
|
||||
{
|
||||
entry->setCurrentZone( m_pZone );
|
||||
m_pZone->pushActor( entry );
|
||||
}
|
||||
}
|
||||
|
||||
void Core::Cell::addChara( Entity::CharaPtr pAct )
|
||||
{
|
||||
if( pAct->isPlayer() )
|
||||
++m_playerCount;
|
||||
|
||||
m_charas.insert(pAct);
|
||||
m_charas.insert( pAct );
|
||||
}
|
||||
|
||||
void Core::Cell::removeChara( Entity::CharaPtr pAct )
|
||||
|
|
|
@ -9,12 +9,6 @@
|
|||
|
||||
namespace Core {
|
||||
|
||||
|
||||
struct CellCache
|
||||
{
|
||||
std::vector< Entity::BattleNpcPtr > battleNpcCache;
|
||||
};
|
||||
|
||||
typedef std::set< Entity::CharaPtr > CharaSet;
|
||||
|
||||
class Cell
|
||||
|
@ -43,8 +37,6 @@ namespace Core {
|
|||
|
||||
void removeChara( Entity::CharaPtr pAct );
|
||||
|
||||
void loadCharas( CellCache *pCC );
|
||||
|
||||
bool hasChara( Entity::CharaPtr pAct )
|
||||
{
|
||||
return (m_charas.find(pAct) != m_charas.end());
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
#include "Session.h"
|
||||
#include "Actor/Chara.h"
|
||||
#include "Actor/Player.h"
|
||||
#include "Actor/BattleNpc.h"
|
||||
|
||||
#include "Forwards.h"
|
||||
|
||||
|
@ -100,14 +99,12 @@ Core::Zone::~Zone()
|
|||
|
||||
bool Core::Zone::init()
|
||||
{
|
||||
memset( m_pCellCache, 0, sizeof( CellCache* ) * _sizeX );
|
||||
|
||||
if( g_scriptMgr.onZoneInit( shared_from_this() ) )
|
||||
{
|
||||
// all good
|
||||
}
|
||||
|
||||
loadCellCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -132,108 +129,9 @@ void Core::Zone::setCurrentFestival( uint16_t festivalId )
|
|||
m_currentFestivalId = festivalId;
|
||||
}
|
||||
|
||||
Core::CellCache* Core::Zone::getCellCacheList( uint32_t cellx, uint32_t celly )
|
||||
{
|
||||
assert( cellx < _sizeX );
|
||||
assert( celly < _sizeY );
|
||||
if( m_pCellCache[cellx] == nullptr )
|
||||
return nullptr;
|
||||
|
||||
return m_pCellCache[cellx][celly];
|
||||
}
|
||||
|
||||
Core::CellCache* Core::Zone::getCellCacheAndCreate( uint32_t cellx, uint32_t celly )
|
||||
{
|
||||
assert( cellx < _sizeX );
|
||||
assert( celly < _sizeY );
|
||||
if( m_pCellCache[cellx] == nullptr )
|
||||
{
|
||||
m_pCellCache[cellx] = new CellCache*[_sizeY];
|
||||
memset(m_pCellCache[cellx], 0, sizeof( CellCache* ) * _sizeY);
|
||||
}
|
||||
|
||||
if( m_pCellCache[cellx][celly] == nullptr )
|
||||
{
|
||||
//m_pCellCache[cellx][celly] = new CellCache;
|
||||
}
|
||||
|
||||
return m_pCellCache[cellx][celly];
|
||||
}
|
||||
|
||||
void Core::Zone::loadCellCache()
|
||||
{
|
||||
auto pQR = g_charaDb.query( "SELECT Id,"
|
||||
"Zoneid,"
|
||||
"NameId,"
|
||||
"SizeId,"
|
||||
"ClassJob,"
|
||||
"DisplayFlags1,"
|
||||
"DisplayFlags2,"
|
||||
"Level,"
|
||||
"Pos_0_0,"
|
||||
"Pos_0_1,"
|
||||
"Pos_0_2,"
|
||||
"Rotation,"
|
||||
"MobType,"
|
||||
"Behaviour,"
|
||||
"ModelMainWeapon,"
|
||||
"ModelSubWeapon,"
|
||||
"ModelId,"
|
||||
"Look,"
|
||||
"Models,"
|
||||
"type "
|
||||
"FROM battlenpc WHERE ZoneId = " + std::to_string( getTerritoryId() ) + ";" );
|
||||
|
||||
std::vector< Entity::BattleNpcPtr > cache;
|
||||
|
||||
while( pQR->next() )
|
||||
{
|
||||
uint32_t id = pQR->getUInt( 1 );
|
||||
uint32_t targetZoneId = pQR->getUInt( 2 );
|
||||
uint32_t nameId = pQR->getUInt( 3 );
|
||||
uint32_t sizeId = pQR->getUInt( 4 );
|
||||
uint32_t classJob = pQR->getUInt( 5 );
|
||||
uint32_t displayFlags1 = pQR->getUInt( 6 );
|
||||
uint32_t displayFlags2 = pQR->getUInt( 7 );
|
||||
uint32_t level = pQR->getUInt( 8 );
|
||||
float posX = pQR->getFloat( 9 );
|
||||
float posY = pQR->getFloat( 10 );
|
||||
float posZ = pQR->getFloat( 11 );
|
||||
uint32_t rotation = pQR->getUInt( 12 );
|
||||
uint32_t mobType = pQR->getUInt( 13 );
|
||||
uint32_t behaviour = pQR->getUInt( 14 );
|
||||
uint64_t modelMainWeapon = pQR->getUInt( 15 );
|
||||
uint64_t modelSubWeapon = pQR->getUInt( 16 );
|
||||
uint32_t modelId = pQR->getUInt( 17 );
|
||||
uint32_t type = pQR->getUInt( 18 );
|
||||
|
||||
Common::FFXIVARR_POSITION3 pos{ posX, posY, posZ };
|
||||
auto pBNpc = Entity::make_BattleNpc( modelId, nameId, pos, sizeId, type, level, behaviour, mobType );
|
||||
pBNpc->setRotation( static_cast< float >( rotation ) );
|
||||
cache.push_back( pBNpc );
|
||||
}
|
||||
|
||||
|
||||
|
||||
for( auto entry : cache )
|
||||
{
|
||||
// get cell position
|
||||
uint32_t cellX = CellHandler< TerritoryMgr >::getPosX( entry->getPos().x );
|
||||
uint32_t cellY = CellHandler< TerritoryMgr >::getPosY( entry->getPos().z );
|
||||
|
||||
// find the right cell, create it if not existing yet
|
||||
if( m_pCellCache[cellX] == nullptr )
|
||||
{
|
||||
m_pCellCache[cellX] = new CellCache*[_sizeY];
|
||||
memset( m_pCellCache[cellX], 0, sizeof( CellCache* ) * _sizeY );
|
||||
}
|
||||
|
||||
if( !m_pCellCache[cellX][cellY] )
|
||||
m_pCellCache[cellX][cellY] = new CellCache;
|
||||
|
||||
// add the populace cache object to the cells list
|
||||
m_pCellCache[cellX][cellY]->battleNpcCache.push_back( entry );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -314,15 +212,7 @@ void Core::Zone::pushActor( Entity::CharaPtr pChara )
|
|||
m_playerMap[pPlayer->getId()] = pPlayer;
|
||||
updateCellActivity( cx, cy, 2 );
|
||||
}
|
||||
else if( pChara->isBattleNpc() )
|
||||
{
|
||||
|
||||
Entity::BattleNpcPtr pBNpc = pChara->getAsBattleNpc();
|
||||
m_BattleNpcMap[pBNpc->getId()] = pBNpc;
|
||||
pBNpc->setPosition( pBNpc->getPos() );
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Core::Zone::removeActor( Entity::CharaPtr pChara )
|
||||
{
|
||||
|
@ -349,8 +239,6 @@ void Core::Zone::removeActor( Entity::CharaPtr pChara )
|
|||
onLeaveTerritory( *pChara->getAsPlayer() );
|
||||
|
||||
}
|
||||
else if( pChara->isBattleNpc() )
|
||||
m_BattleNpcMap.erase( pChara->getId() );
|
||||
|
||||
// remove from lists of other actors
|
||||
pChara->removeFromInRange();
|
||||
|
@ -433,6 +321,7 @@ bool Core::Zone::checkWeather()
|
|||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
void Core::Zone::updateBnpcs( int64_t tickCount )
|
||||
{
|
||||
if( ( tickCount - m_lastMobUpdate ) > 250 )
|
||||
|
@ -480,6 +369,7 @@ void Core::Zone::updateBnpcs( int64_t tickCount )
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
bool Core::Zone::update( uint32_t currTime )
|
||||
{
|
||||
|
@ -489,7 +379,7 @@ bool Core::Zone::update( uint32_t currTime )
|
|||
bool changedWeather = checkWeather();
|
||||
|
||||
updateSessions( changedWeather );
|
||||
updateBnpcs( tickCount );
|
||||
//updateBnpcs( tickCount );
|
||||
onUpdate( currTime );
|
||||
|
||||
return true;
|
||||
|
@ -590,9 +480,6 @@ void Core::Zone::updateCellActivity( uint32_t x, uint32_t y, int32_t radius )
|
|||
|
||||
assert( !pCell->isLoaded() );
|
||||
|
||||
CellCache * pCC = getCellCacheAndCreate( posX, posY );
|
||||
if( pCC )
|
||||
pCell->loadCharas(pCC);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
@ -604,9 +491,7 @@ void Core::Zone::updateCellActivity( uint32_t x, uint32_t y, int32_t radius )
|
|||
|
||||
if( !pCell->isLoaded() )
|
||||
{
|
||||
CellCache * pCC = getCellCacheAndCreate( posX, posY );
|
||||
if( pCC )
|
||||
pCell->loadCharas(pCC);
|
||||
|
||||
}
|
||||
}
|
||||
else if( !isCellActive( posX, posY ) && pCell->isActive() )
|
||||
|
|
|
@ -34,15 +34,10 @@ protected:
|
|||
std::string m_internalName;
|
||||
|
||||
std::unordered_map< int32_t, Entity::PlayerPtr > m_playerMap;
|
||||
std::unordered_map< int32_t, Entity::BattleNpcPtr > m_BattleNpcMap;
|
||||
std::unordered_map< int32_t, Entity::EventObjectPtr > m_eventObjects;
|
||||
|
||||
std::set< Entity::BattleNpcPtr > m_BattleNpcDeadMap;
|
||||
|
||||
SessionSet m_sessionSet;
|
||||
|
||||
CellCache** m_pCellCache[_sizeX];
|
||||
|
||||
Common::Weather m_currentWeather;
|
||||
Common::Weather m_weatherOverride;
|
||||
|
||||
|
@ -53,8 +48,6 @@ protected:
|
|||
|
||||
std::map< uint8_t, int32_t> m_weatherRateMap;
|
||||
|
||||
|
||||
|
||||
public:
|
||||
Zone();
|
||||
|
||||
|
@ -71,10 +64,6 @@ public:
|
|||
uint16_t getCurrentFestival() const;
|
||||
void setCurrentFestival( uint16_t festivalId );
|
||||
|
||||
CellCache* getCellCacheList( uint32_t cellx, uint32_t celly );
|
||||
|
||||
CellCache* getCellCacheAndCreate( uint32_t cellx, uint32_t celly );
|
||||
|
||||
virtual void loadCellCache();
|
||||
virtual uint32_t getTerritoryId() const;
|
||||
virtual void onEnterTerritory( Entity::Player& player );
|
||||
|
@ -107,7 +96,7 @@ public:
|
|||
std::size_t getPopCount() const;
|
||||
void loadWeatherRates();
|
||||
bool checkWeather();
|
||||
void updateBnpcs( int64_t tickCount );
|
||||
//void updateBnpcs( int64_t tickCount );
|
||||
|
||||
bool update( uint32_t currTime );
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue