Kelsidavis-WoWee/src/game/entity_controller.cpp
Kelsi e5b4e86600 fix: misleading indentation on BAG_UPDATE/UNIT_INVENTORY_CHANGED emits
The two emit calls were indented 12 spaces (suggesting a nested block)
instead of 8 (matching the enclosing if). Same class of maintenance
trap as the PLAYER_ALIVE/PLAYER_UNGHOST fix in b3abf04d.
2026-03-29 19:31:29 -07:00

2145 lines
99 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "game/entity_controller.hpp"
#include "game/game_handler.hpp"
#include "game/game_utils.hpp"
#include "game/packet_parsers.hpp"
#include "game/entity.hpp"
#include "game/update_field_table.hpp"
#include "game/opcode_table.hpp"
#include "game/chat_handler.hpp"
#include "game/transport_manager.hpp"
#include "core/logger.hpp"
#include "core/coordinates.hpp"
#include "network/world_socket.hpp"
#include <algorithm>
#include <cstring>
#include <zlib.h>
namespace wowee {
namespace game {
namespace {
const char* worldStateName(WorldState state) {
switch (state) {
case WorldState::DISCONNECTED: return "DISCONNECTED";
case WorldState::CONNECTING: return "CONNECTING";
case WorldState::CONNECTED: return "CONNECTED";
case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED";
case WorldState::AUTH_SENT: return "AUTH_SENT";
case WorldState::AUTHENTICATED: return "AUTHENTICATED";
case WorldState::READY: return "READY";
case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED";
case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED";
case WorldState::ENTERING_WORLD: return "ENTERING_WORLD";
case WorldState::IN_WORLD: return "IN_WORLD";
case WorldState::FAILED: return "FAILED";
}
return "UNKNOWN";
}
bool envFlagEnabled(const char* key, bool defaultValue = false) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' ||
raw[0] == 'n' || raw[0] == 'N');
}
int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
char* end = nullptr;
long parsed = std::strtol(raw, &end, 10);
if (end == raw) return defaultValue;
return static_cast<int>(std::clamp<long>(parsed, minValue, maxValue));
}
int updateObjectBlocksBudgetPerUpdate(WorldState state) {
static const int inWorldBudget =
parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048);
static const int loginBudget =
parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096);
return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget;
}
float slowUpdateObjectBlockLogThresholdMs() {
static const int thresholdMs =
parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000);
return static_cast<float>(thresholdMs);
}
} // anonymous namespace
EntityController::EntityController(GameHandler& owner)
: owner_(owner) { initTypeHandlers(); }
void EntityController::registerOpcodes(DispatchTable& table) {
// World object updates
table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) {
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(owner_.state), " size=", packet.getSize());
if (owner_.state == WorldState::IN_WORLD) handleUpdateObject(packet);
};
table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) {
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(owner_.state), " size=", packet.getSize());
if (owner_.state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet);
};
table[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) {
if (owner_.state == WorldState::IN_WORLD) handleDestroyObject(packet);
};
// Entity queries
table[Opcode::SMSG_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) {
handleNameQueryResponse(packet);
};
table[Opcode::SMSG_CREATURE_QUERY_RESPONSE] = [this](network::Packet& packet) {
handleCreatureQueryResponse(packet);
};
table[Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE] = [this](network::Packet& packet) {
handleGameObjectQueryResponse(packet);
};
table[Opcode::SMSG_GAMEOBJECT_PAGETEXT] = [this](network::Packet& packet) {
handleGameObjectPageText(packet);
};
table[Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE] = [this](network::Packet& packet) {
handlePageTextQueryResponse(packet);
};
}
void EntityController::clearAll() {
pendingUpdateObjectWork_.clear();
playerNameCache.clear();
playerClassRaceCache_.clear();
pendingNameQueries.clear();
creatureInfoCache.clear();
pendingCreatureQueries.clear();
gameObjectInfoCache_.clear();
pendingGameObjectQueries_.clear();
transportGuids_.clear();
serverUpdatedTransportGuids_.clear();
entityManager.clear();
}
// ============================================================
// Update Object Pipeline
// ============================================================
void EntityController::enqueueUpdateObjectWork(UpdateObjectData&& data) {
pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)});
}
void EntityController::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start,
float budgetMs) {
if (pendingUpdateObjectWork_.empty()) {
return;
}
const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(owner_.state);
int processedBlocks = 0;
while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) {
float elapsedMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - start).count();
if (elapsedMs >= budgetMs) {
break;
}
auto& work = pendingUpdateObjectWork_.front();
if (!work.outOfRangeProcessed) {
auto outOfRangeStart = std::chrono::steady_clock::now();
processOutOfRangeObjects(work.data.outOfRangeGuids);
float outOfRangeMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - outOfRangeStart).count();
if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) {
LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs,
"ms guidCount=", work.data.outOfRangeGuids.size());
}
work.outOfRangeProcessed = true;
}
while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) {
elapsedMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - start).count();
if (elapsedMs >= budgetMs) {
break;
}
const UpdateBlock& block = work.data.blocks[work.nextBlockIndex];
auto blockStart = std::chrono::steady_clock::now();
applyUpdateObjectBlock(block, work.newItemCreated);
float blockMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - blockStart).count();
if (blockMs > slowUpdateObjectBlockLogThresholdMs()) {
LOG_WARNING("SLOW update-object block apply: ", blockMs,
"ms index=", work.nextBlockIndex,
" type=", static_cast<int>(block.updateType),
" guid=0x", std::hex, block.guid, std::dec,
" objectType=", static_cast<int>(block.objectType),
" fieldCount=", block.fields.size(),
" hasMovement=", block.hasMovement ? 1 : 0);
}
++work.nextBlockIndex;
++processedBlocks;
}
if (work.nextBlockIndex >= work.data.blocks.size()) {
finalizeUpdateObjectBatch(work.newItemCreated);
pendingUpdateObjectWork_.pop_front();
continue;
}
break;
}
if (!pendingUpdateObjectWork_.empty()) {
const auto& work = pendingUpdateObjectWork_.front();
LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=",
pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex,
"/", work.data.blocks.size(), ", owner_.state=", worldStateName(owner_.state), ")");
}
}
void EntityController::handleUpdateObject(network::Packet& packet) {
UpdateObjectData data;
if (!owner_.packetParsers_->parseUpdateObject(packet, data)) {
static int updateObjErrors = 0;
if (++updateObjErrors <= 5)
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
if (data.blocks.empty()) return;
// Fall through: process any blocks that were successfully parsed before the failure.
}
enqueueUpdateObjectWork(std::move(data));
}
void EntityController::processOutOfRangeObjects(const std::vector<uint64_t>& guids) {
// Process out-of-range objects first
for (uint64_t guid : guids) {
auto entity = entityManager.getEntity(guid);
if (!entity) continue;
const bool isKnownTransport = transportGuids_.count(guid) > 0;
if (isKnownTransport) {
// Keep transports alive across out-of-range flapping.
// Boats/zeppelins are global movers and removing them here can make
// them disappear until a later movement snapshot happens to recreate them.
const bool playerAboardNow = (owner_.playerTransportGuid_ == guid);
const bool stickyAboard = (owner_.playerTransportStickyGuid_ == guid && owner_.playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (owner_.movementInfo.transportGuid == guid);
LOG_INFO("Preserving transport on out-of-range: 0x",
std::hex, guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
continue;
}
LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec);
// Trigger despawn callbacks before removing entity
if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) {
owner_.creatureDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) {
owner_.playerDespawnCallback_(guid);
owner_.otherPlayerVisibleItemEntries_.erase(guid);
owner_.otherPlayerVisibleDirty_.erase(guid);
owner_.otherPlayerMoveTimeMs_.erase(guid);
owner_.inspectedPlayerItemEntries_.erase(guid);
owner_.pendingAutoInspect_.erase(guid);
// Clear pending name query so the query is re-sent when this player
// comes back into range (entity is recreated as a new object).
pendingNameQueries.erase(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) {
owner_.gameObjectDespawnCallback_(guid);
}
transportGuids_.erase(guid);
serverUpdatedTransportGuids_.erase(guid);
owner_.clearTransportAttachment(guid);
if (owner_.playerTransportGuid_ == guid) {
owner_.clearPlayerTransport();
}
entityManager.removeEntity(guid);
}
}
// ============================================================
// Phase 1: Extracted helper methods
// ============================================================
bool EntityController::extractPlayerAppearance(const std::map<uint16_t, uint32_t>& fields,
uint8_t& outRace,
uint8_t& outGender,
uint32_t& outAppearanceBytes,
uint8_t& outFacial) const {
outRace = 0;
outGender = 0;
outAppearanceBytes = 0;
outFacial = 0;
auto readField = [&](uint16_t idx, uint32_t& out) -> bool {
if (idx == 0xFFFF) return false;
auto it = fields.find(idx);
if (it == fields.end()) return false;
out = it->second;
return true;
};
uint32_t bytes0 = 0;
uint32_t pbytes = 0;
uint32_t pbytes2 = 0;
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES);
const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2);
bool haveBytes0 = readField(ufBytes0, bytes0);
bool havePbytes = readField(ufPbytes, pbytes);
bool havePbytes2 = readField(ufPbytes2, pbytes2);
// Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing,
// try to locate plausible packed fields by scanning.
if (!haveBytes0) {
for (const auto& [idx, v] : fields) {
uint8_t race = static_cast<uint8_t>(v & 0xFF);
uint8_t cls = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t gender = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t power = static_cast<uint8_t>((v >> 24) & 0xFF);
if (race >= 1 && race <= 20 &&
cls >= 1 && cls <= 20 &&
gender <= 1 &&
power <= 10) {
bytes0 = v;
haveBytes0 = true;
break;
}
}
}
if (!havePbytes) {
for (const auto& [idx, v] : fields) {
uint8_t skin = static_cast<uint8_t>(v & 0xFF);
uint8_t face = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t hair = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t color = static_cast<uint8_t>((v >> 24) & 0xFF);
if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) {
pbytes = v;
havePbytes = true;
break;
}
}
}
if (!havePbytes2) {
for (const auto& [idx, v] : fields) {
uint8_t facial = static_cast<uint8_t>(v & 0xFF);
if (facial <= 100) {
pbytes2 = v;
havePbytes2 = true;
break;
}
}
}
if (!haveBytes0 || !havePbytes) return false;
outRace = static_cast<uint8_t>(bytes0 & 0xFF);
outGender = static_cast<uint8_t>((bytes0 >> 16) & 0xFF);
outAppearanceBytes = pbytes;
outFacial = havePbytes2 ? static_cast<uint8_t>(pbytes2 & 0xFF) : 0;
return true;
}
void EntityController::maybeDetectCoinageIndex(const std::map<uint16_t, uint32_t>& oldFields,
const std::map<uint16_t, uint32_t>& newFields) {
if (owner_.pendingMoneyDelta_ == 0 || owner_.pendingMoneyDeltaTimer_ <= 0.0f) return;
if (oldFields.empty() || newFields.empty()) return;
constexpr uint32_t kMaxPlausibleCoinage = 2147483647u;
std::vector<uint16_t> candidates;
candidates.reserve(8);
for (const auto& [idx, newVal] : newFields) {
auto itOld = oldFields.find(idx);
if (itOld == oldFields.end()) continue;
uint32_t oldVal = itOld->second;
if (newVal < oldVal) continue;
uint32_t delta = newVal - oldVal;
if (delta != owner_.pendingMoneyDelta_) continue;
if (newVal > kMaxPlausibleCoinage) continue;
candidates.push_back(idx);
}
if (candidates.empty()) return;
uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE);
uint16_t chosen = candidates[0];
if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) {
chosen = current;
} else {
std::sort(candidates.begin(), candidates.end());
chosen = candidates[0];
}
if (chosen != current && current != 0xFFFF) {
owner_.updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen);
LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")");
}
owner_.pendingMoneyDelta_ = 0;
owner_.pendingMoneyDeltaTimer_ = 0.0f;
}
// ============================================================
// Phase 2: Update type dispatch
// ============================================================
void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) {
switch (block.updateType) {
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2:
handleCreateObject(block, newItemCreated);
break;
case UpdateType::VALUES:
handleValuesUpdate(block);
break;
case UpdateType::MOVEMENT:
handleMovementUpdate(block);
break;
default:
break;
}
}
// ============================================================
// Phase 3: Concern-specific helpers
// ============================================================
// 3i: Non-player transport child attachment — identical in CREATE/VALUES/MOVEMENT
void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& block,
const std::shared_ptr<Entity>& entity,
ObjectType entityType) {
if (block.guid == owner_.playerGuid) return;
if (entityType != ObjectType::UNIT && entityType != ObjectType::GAMEOBJECT) return;
if (block.onTransport && block.transportGuid != 0) {
glm::vec3 localOffset = core::coords::serverToCanonical(
glm::vec3(block.transportX, block.transportY, block.transportZ));
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
owner_.setTransportAttachment(block.guid, entityType, block.transportGuid,
localOffset, hasLocalOrientation, localOriCanonical);
if (owner_.transportManager_ && owner_.transportManager_->getTransport(block.transportGuid)) {
glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
}
} else {
owner_.clearTransportAttachment(block.guid);
}
}
// 3f: Rebuild playerAuras from UNIT_FIELD_AURAS (Classic/vanilla only).
// blockFields is used to check if any aura field was updated in this packet.
// entity->getFields() is used for reading the full accumulated state.
// Normalises Classic harmful bit (0x02) to WotLK debuff bit (0x80) so
// downstream code checking for 0x80 works consistently across expansions.
void EntityController::syncClassicAurasFromFields(const std::shared_ptr<Entity>& entity) {
if (!isClassicLikeExpansion() || !owner_.spellHandler_) return;
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS);
if (ufAuras == 0xFFFF) return;
const auto& allFields = entity->getFields();
bool hasAuraField = false;
for (const auto& [fk, fv] : allFields) {
if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; }
}
if (!hasAuraField) return;
owner_.spellHandler_->playerAuras_.clear();
owner_.spellHandler_->playerAuras_.resize(48);
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (int slot = 0; slot < 48; ++slot) {
auto it = allFields.find(static_cast<uint16_t>(ufAuras + slot));
if (it != allFields.end() && it->second != 0) {
AuraSlot& a = owner_.spellHandler_->playerAuras_[slot];
a.spellId = it->second;
// Read aura flag byte: packed 4-per-uint32 at ufAuraFlags
uint8_t aFlag = 0;
if (ufAuraFlags != 0xFFFF) {
auto fit = allFields.find(static_cast<uint16_t>(ufAuraFlags + slot / 4));
if (fit != allFields.end())
aFlag = static_cast<uint8_t>((fit->second >> ((slot % 4) * 8)) & 0xFF);
}
// Normalize Classic harmful bit (0x02) to WotLK debuff bit (0x80)
// so downstream code checking for 0x80 works consistently.
if (aFlag & 0x02)
aFlag = (aFlag & ~0x02) | 0x80;
a.flags = aFlag;
a.durationMs = -1;
a.maxDurationMs = -1;
a.casterGuid = owner_.playerGuid;
a.receivedAtMs = nowMs;
}
}
LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS");
pendingEvents_.emit("UNIT_AURA", {"player"});
}
// 3h: Detect player mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes
void EntityController::detectPlayerMountChange(uint32_t newMountDisplayId,
const std::map<uint16_t, uint32_t>& blockFields) {
uint32_t old = owner_.currentMountDisplayId_;
owner_.currentMountDisplayId_ = newMountDisplayId;
if (newMountDisplayId != old && owner_.mountCallback_) owner_.mountCallback_(newMountDisplayId);
if (newMountDisplayId != old)
pendingEvents_.emit("UNIT_MODEL_CHANGED", {"player"});
if (old == 0 && newMountDisplayId != 0) {
// Just mounted — find the mount aura (indefinite duration, self-cast)
owner_.mountAuraSpellId_ = 0;
if (owner_.spellHandler_) for (const auto& a : owner_.spellHandler_->playerAuras_) {
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.playerGuid) {
owner_.mountAuraSpellId_ = a.spellId;
}
}
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
if (owner_.mountAuraSpellId_ == 0) {
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
if (ufAuras != 0xFFFF) {
for (const auto& [fk, fv] : blockFields) {
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
owner_.mountAuraSpellId_ = fv;
break;
}
}
}
}
LOG_INFO("Mount detected: displayId=", newMountDisplayId, " auraSpellId=", owner_.mountAuraSpellId_);
}
if (old != 0 && newMountDisplayId == 0) {
// Only clear the specific mount aura, not all indefinite auras.
// Previously this cleared every aura with maxDurationMs < 0, which
// would strip racial passives, tracking, and zone buffs on dismount.
uint32_t mountSpell = owner_.mountAuraSpellId_;
owner_.mountAuraSpellId_ = 0;
if (mountSpell != 0 && owner_.spellHandler_) {
for (auto& a : owner_.spellHandler_->playerAuras_) {
if (!a.isEmpty() && a.spellId == mountSpell) {
a = AuraSlot{};
break;
}
}
}
}
}
// Phase 4: Resolve cached field indices once per handler call.
EntityController::UnitFieldIndices EntityController::UnitFieldIndices::resolve() {
return UnitFieldIndices{
fieldIndex(UF::UNIT_FIELD_HEALTH),
fieldIndex(UF::UNIT_FIELD_MAXHEALTH),
fieldIndex(UF::UNIT_FIELD_POWER1),
fieldIndex(UF::UNIT_FIELD_MAXPOWER1),
fieldIndex(UF::UNIT_FIELD_LEVEL),
fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE),
fieldIndex(UF::UNIT_FIELD_FLAGS),
fieldIndex(UF::UNIT_DYNAMIC_FLAGS),
fieldIndex(UF::UNIT_FIELD_DISPLAYID),
fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID),
fieldIndex(UF::UNIT_NPC_FLAGS),
fieldIndex(UF::UNIT_FIELD_BYTES_0),
fieldIndex(UF::UNIT_FIELD_BYTES_1)
};
}
EntityController::PlayerFieldIndices EntityController::PlayerFieldIndices::resolve() {
return PlayerFieldIndices{
fieldIndex(UF::PLAYER_XP),
fieldIndex(UF::PLAYER_NEXT_LEVEL_XP),
fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE),
fieldIndex(UF::UNIT_FIELD_LEVEL),
fieldIndex(UF::PLAYER_FIELD_COINAGE),
fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY),
fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY),
fieldIndex(UF::PLAYER_FLAGS),
fieldIndex(UF::UNIT_FIELD_RESISTANCES),
fieldIndex(UF::PLAYER_BYTES),
fieldIndex(UF::PLAYER_BYTES_2),
fieldIndex(UF::PLAYER_CHOSEN_TITLE),
{fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1),
fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3),
fieldIndex(UF::UNIT_FIELD_STAT4)},
fieldIndex(UF::UNIT_FIELD_ATTACK_POWER),
fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER),
fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS),
fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS),
fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE),
fieldIndex(UF::PLAYER_DODGE_PERCENTAGE),
fieldIndex(UF::PLAYER_PARRY_PERCENTAGE),
fieldIndex(UF::PLAYER_CRIT_PERCENTAGE),
fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE),
fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1),
fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1)
};
}
// 3a: Create the appropriate Entity subclass from the block's object type.
std::shared_ptr<Entity> EntityController::createEntityFromBlock(const UpdateBlock& block) {
switch (block.objectType) {
case ObjectType::PLAYER:
return std::make_shared<Player>(block.guid);
case ObjectType::UNIT:
return std::make_shared<Unit>(block.guid);
case ObjectType::GAMEOBJECT:
return std::make_shared<GameObject>(block.guid);
default: {
auto entity = std::make_shared<Entity>(block.guid);
entity->setType(block.objectType);
return entity;
}
}
}
// 3b: Track player-on-transport state from movement blocks.
// Consolidates near-identical logic from CREATE and MOVEMENT handlers.
// When updateMovementInfoPos is true (MOVEMENT), movementInfo.x/y/z are set
// to the raw canonical position when not on a resolved transport.
// When false (CREATE), movementInfo is only set for resolved transport positions.
void EntityController::applyPlayerTransportState(const UpdateBlock& block,
const std::shared_ptr<Entity>& entity,
const glm::vec3& canonicalPos, float oCanonical,
bool updateMovementInfoPos) {
if (block.onTransport) {
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
owner_.setPlayerOnTransport(block.transportGuid, canonicalOffset);
if (owner_.transportManager_ && owner_.transportManager_->getTransport(owner_.playerTransportGuid_)) {
glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
owner_.movementInfo.x = composed.x;
owner_.movementInfo.y = composed.y;
owner_.movementInfo.z = composed.z;
} else if (updateMovementInfoPos) {
owner_.movementInfo.x = canonicalPos.x;
owner_.movementInfo.y = canonicalPos.y;
owner_.movementInfo.z = canonicalPos.z;
}
LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuid_, std::dec,
" offset=(", owner_.playerTransportOffset_.x, ", ", owner_.playerTransportOffset_.y,
", ", owner_.playerTransportOffset_.z, ")");
} else {
if (updateMovementInfoPos) {
owner_.movementInfo.x = canonicalPos.x;
owner_.movementInfo.y = canonicalPos.y;
owner_.movementInfo.z = canonicalPos.z;
}
// Don't clear client-side M2 transport boarding (trams) —
// the server doesn't know about client-detected transport attachment.
bool isClientM2Transport = false;
if (owner_.playerTransportGuid_ != 0 && owner_.transportManager_) {
auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_);
isClientM2Transport = (tr && tr->isM2);
}
if (owner_.playerTransportGuid_ != 0 && !isClientM2Transport) {
LOG_INFO("Player left transport");
owner_.clearPlayerTransport();
}
}
}
// 3c: Apply unit fields during CREATE — sets health/power/level/flags/displayId/etc.
// Returns true if the entity is initially dead (health=0 or DYNFLAG_DEAD).
bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block,
std::shared_ptr<Unit>& unit,
const UnitFieldIndices& ufi) {
bool unitInitiallyDead = false;
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
for (const auto& [key, val] : block.fields) {
// Check all specific fields BEFORE power/maxpower range checks.
// In Classic, power indices (23-27) are adjacent to maxHealth (28),
// and maxPower indices (29-33) are adjacent to level (34) and faction (35).
// A range check like "key >= powerBase && key < powerBase+7" would
// incorrectly capture maxHealth/level/faction in Classic's tight layout.
if (key == ufi.health) {
unit->setHealth(val);
if (block.objectType == ObjectType::UNIT && val == 0) {
unitInitiallyDead = true;
}
if (block.guid == owner_.playerGuid && val == 0) {
owner_.playerDead_ = true;
LOG_INFO("Player logged in dead");
}
} else if (key == ufi.maxHealth) { unit->setMaxHealth(val); }
else if (key == ufi.level) {
unit->setLevel(val);
} else if (key == ufi.faction) {
unit->setFactionTemplate(val);
if (owner_.addonEventCallback_) {
auto uid = owner_.guidToUnitId(block.guid);
if (!uid.empty())
pendingEvents_.emit("UNIT_FACTION", {uid});
}
}
else if (key == ufi.flags) {
unit->setUnitFlags(val);
if (owner_.addonEventCallback_) {
auto uid = owner_.guidToUnitId(block.guid);
if (!uid.empty())
pendingEvents_.emit("UNIT_FLAGS", {uid});
}
}
else if (key == ufi.bytes0) {
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
} else if (key == ufi.displayId) {
unit->setDisplayId(val);
if (owner_.addonEventCallback_) {
auto uid = owner_.guidToUnitId(block.guid);
if (!uid.empty())
pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid});
}
}
else if (key == ufi.npcFlags) { unit->setNpcFlags(val); }
else if (key == ufi.dynFlags) {
unit->setDynamicFlags(val);
if (block.objectType == ObjectType::UNIT &&
((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) {
unitInitiallyDead = true;
}
}
// Power/maxpower range checks AFTER all specific fields
else if (key >= ufi.powerBase && key < ufi.powerBase + 7) {
unit->setPowerByType(static_cast<uint8_t>(key - ufi.powerBase), val);
} else if (key >= ufi.maxPowerBase && key < ufi.maxPowerBase + 7) {
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufi.maxPowerBase), val);
}
else if (key == ufi.mountDisplayId) {
if (block.guid == owner_.playerGuid) {
detectPlayerMountChange(val, block.fields);
}
unit->setMountDisplayId(val);
}
}
return unitInitiallyDead;
}
// Consolidates player-death state into one place so both the health==0 and
// dynFlags UNIT_DYNFLAG_DEAD paths share the same corpse-caching logic.
// Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so this cached position
// is the primary source for canReclaimCorpse().
void EntityController::markPlayerDead(const char* source) {
owner_.playerDead_ = true;
owner_.releasedSpirit_ = false;
// owner_.movementInfo is canonical (x=north, y=west); corpseX_/Y_ are
// raw server coords (x=west, y=north) — swap axes.
owner_.corpseX_ = owner_.movementInfo.y;
owner_.corpseY_ = owner_.movementInfo.x;
owner_.corpseZ_ = owner_.movementInfo.z;
owner_.corpseMapId_ = owner_.currentMapId_;
LOG_INFO("Player died (", source, "). Corpse cached at server=(",
owner_.corpseX_, ",", owner_.corpseY_, ",", owner_.corpseZ_,
") map=", owner_.corpseMapId_);
}
// 3c: Apply unit fields during VALUES update — tracks health/power/display changes
// and fires events for transitions (death, resurrect, level up, etc.).
EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdate(
const UpdateBlock& block, const std::shared_ptr<Entity>& entity,
std::shared_ptr<Unit>& unit, const UnitFieldIndices& ufi) {
UnitFieldUpdateResult result;
result.oldDisplayId = unit->getDisplayId();
uint32_t oldHealth = unit->getHealth();
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
for (const auto& [key, val] : block.fields) {
if (key == ufi.health) {
unit->setHealth(val);
result.healthChanged = true;
if (val == 0) {
if (owner_.combatHandler_ && block.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) {
owner_.stopAutoAttack();
}
if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(block.guid);
if (block.guid == owner_.playerGuid) {
markPlayerDead("health=0");
owner_.stopAutoAttack();
pendingEvents_.emit("PLAYER_DEAD", {});
}
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallback_) {
owner_.npcDeathCallback_(block.guid);
result.npcDeathNotified = true;
}
} else if (oldHealth == 0 && val > 0) {
if (block.guid == owner_.playerGuid) {
bool wasGhost = owner_.releasedSpirit_;
owner_.playerDead_ = false;
if (!wasGhost) {
LOG_INFO("Player resurrected!");
pendingEvents_.emit("PLAYER_ALIVE", {});
} else {
LOG_INFO("Player entered ghost form");
owner_.releasedSpirit_ = false;
pendingEvents_.emit("PLAYER_UNGHOST", {});
}
}
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallback_) {
owner_.npcRespawnCallback_(block.guid);
result.npcRespawnNotified = true;
}
}
// Specific fields checked BEFORE power/maxpower range checks
// (Classic packs maxHealth/level/faction adjacent to power indices)
} else if (key == ufi.maxHealth) { unit->setMaxHealth(val); result.healthChanged = true; }
else if (key == ufi.bytes0) {
uint8_t oldPT = unit->getPowerType();
unit->setPowerType(static_cast<uint8_t>((val >> 24) & 0xFF));
if (unit->getPowerType() != oldPT) {
auto uid = owner_.guidToUnitId(block.guid);
if (!uid.empty())
pendingEvents_.emit("UNIT_DISPLAYPOWER", {uid});
}
} else if (key == ufi.flags) { unit->setUnitFlags(val); }
else if (ufi.bytes1 != 0xFFFF && key == ufi.bytes1 && block.guid == owner_.playerGuid) {
uint8_t newForm = static_cast<uint8_t>((val >> 24) & 0xFF);
if (newForm != owner_.shapeshiftFormId_) {
owner_.shapeshiftFormId_ = newForm;
LOG_INFO("Shapeshift form changed: ", static_cast<int>(newForm));
pendingEvents_.emit("UPDATE_SHAPESHIFT_FORM", {});
pendingEvents_.emit("UPDATE_SHAPESHIFT_FORMS", {});
}
}
else if (key == ufi.dynFlags) {
uint32_t oldDyn = unit->getDynamicFlags();
unit->setDynamicFlags(val);
if (block.guid == owner_.playerGuid) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
markPlayerDead("dynFlags");
} else if (wasDead && !nowDead) {
owner_.playerDead_ = false;
owner_.releasedSpirit_ = false;
owner_.selfResAvailable_ = false;
LOG_INFO("Player resurrected (dynamic flags)");
}
} else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
if (!result.npcDeathNotified && owner_.npcDeathCallback_) {
owner_.npcDeathCallback_(block.guid);
result.npcDeathNotified = true;
}
} else if (wasDead && !nowDead) {
if (!result.npcRespawnNotified && owner_.npcRespawnCallback_) {
owner_.npcRespawnCallback_(block.guid);
result.npcRespawnNotified = true;
}
}
}
} else if (key == ufi.level) {
uint32_t oldLvl = unit->getLevel();
unit->setLevel(val);
if (val != oldLvl) {
auto uid = owner_.guidToUnitId(block.guid);
if (!uid.empty())
pendingEvents_.emit("UNIT_LEVEL", {uid});
}
if (block.guid != owner_.playerGuid &&
entity->getType() == ObjectType::PLAYER &&
val > oldLvl && oldLvl > 0 &&
owner_.otherPlayerLevelUpCallback_) {
owner_.otherPlayerLevelUpCallback_(block.guid, val);
}
}
else if (key == ufi.faction) {
unit->setFactionTemplate(val);
unit->setHostile(owner_.isHostileFaction(val));
} else if (key == ufi.displayId) {
if (val != unit->getDisplayId()) {
unit->setDisplayId(val);
result.displayIdChanged = true;
}
} else if (key == ufi.mountDisplayId) {
if (block.guid == owner_.playerGuid) {
detectPlayerMountChange(val, block.fields);
}
unit->setMountDisplayId(val);
} else if (key == ufi.npcFlags) { unit->setNpcFlags(val); }
// Power/maxpower range checks AFTER all specific fields
else if (key >= ufi.powerBase && key < ufi.powerBase + 7) {
unit->setPowerByType(static_cast<uint8_t>(key - ufi.powerBase), val);
result.powerChanged = true;
} else if (key >= ufi.maxPowerBase && key < ufi.maxPowerBase + 7) {
unit->setMaxPowerByType(static_cast<uint8_t>(key - ufi.maxPowerBase), val);
result.powerChanged = true;
}
}
// Fire UNIT_HEALTH / UNIT_POWER events for Lua addons
if ((result.healthChanged || result.powerChanged)) {
auto unitId = owner_.guidToUnitId(block.guid);
if (!unitId.empty()) {
if (result.healthChanged) pendingEvents_.emit("UNIT_HEALTH", {unitId});
if (result.powerChanged) {
pendingEvents_.emit("UNIT_POWER", {unitId});
// When player power changes, action bar usability may change
if (block.guid == owner_.playerGuid) {
pendingEvents_.emit("ACTIONBAR_UPDATE_USABLE", {});
pendingEvents_.emit("SPELL_UPDATE_USABLE", {});
}
}
}
}
return result;
}
// 3d: Apply player stat fields (XP, coinage, combat stats, etc.).
// Shared between CREATE and VALUES — isCreate controls event firing differences.
bool EntityController::applyPlayerStatFields(const std::map<uint16_t, uint32_t>& fields,
const PlayerFieldIndices& pfi,
bool isCreate) {
bool slotsChanged = false;
for (const auto& [key, val] : fields) {
if (key == pfi.xp) {
owner_.playerXp_ = val;
if (!isCreate) {
LOG_DEBUG("XP updated: ", val);
pendingEvents_.emit("PLAYER_XP_UPDATE", {std::to_string(val)});
}
}
else if (key == pfi.nextXp) {
owner_.playerNextLevelXp_ = val;
if (!isCreate) LOG_DEBUG("Next level XP updated: ", val);
}
else if (pfi.restedXp != 0xFFFF && key == pfi.restedXp) {
owner_.playerRestedXp_ = val;
if (!isCreate) pendingEvents_.emit("UPDATE_EXHAUSTION", {});
}
else if (key == pfi.level) {
owner_.serverPlayerLevel_ = val;
if (!isCreate) LOG_DEBUG("Level updated: ", val);
for (auto& ch : owner_.characters) {
if (ch.guid == owner_.playerGuid) { ch.level = val; break; }
}
}
else if (key == pfi.coinage) {
uint64_t oldMoney = owner_.playerMoneyCopper_;
owner_.playerMoneyCopper_ = val;
LOG_DEBUG("Money ", isCreate ? "set from update fields: " : "updated via VALUES: ", val, " copper");
if (val != oldMoney)
pendingEvents_.emit("PLAYER_MONEY", {});
}
else if (pfi.honor != 0xFFFF && key == pfi.honor) {
owner_.playerHonorPoints_ = val;
LOG_DEBUG("Honor points ", isCreate ? "from update fields: " : "updated: ", val);
}
else if (pfi.arena != 0xFFFF && key == pfi.arena) {
owner_.playerArenaPoints_ = val;
LOG_DEBUG("Arena points ", isCreate ? "from update fields: " : "updated: ", val);
}
else if (pfi.armor != 0xFFFF && key == pfi.armor) {
owner_.playerArmorRating_ = static_cast<int32_t>(val);
if (isCreate) LOG_DEBUG("Armor rating from update fields: ", owner_.playerArmorRating_);
}
else if (pfi.armor != 0xFFFF && key > pfi.armor && key <= pfi.armor + 6) {
owner_.playerResistances_[key - pfi.armor - 1] = static_cast<int32_t>(val);
}
else if (pfi.pBytes2 != 0xFFFF && key == pfi.pBytes2) {
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
owner_.inventory.setPurchasedBankBagSlots(bankBagSlots);
// Byte 3 (bits 24-31): REST_STATE
// 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
if (isCreate) {
LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots));
bool wasResting = owner_.isResting_;
owner_.isResting_ = (restStateByte != 0);
if (owner_.isResting_ != wasResting) {
pendingEvents_.emit("UPDATE_EXHAUSTION", {});
pendingEvents_.emit("PLAYER_UPDATE_RESTING", {});
}
} else {
// Byte 0 (bits 0-7): facial hair / piercings
uint8_t facialHair = static_cast<uint8_t>(val & 0xFF);
for (auto& ch : owner_.characters) {
if (ch.guid == owner_.playerGuid) { ch.facialFeatures = facialHair; break; }
}
LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots),
" facial=", static_cast<int>(facialHair));
owner_.isResting_ = (restStateByte != 0);
if (owner_.appearanceChangedCallback_)
owner_.appearanceChangedCallback_();
}
}
else if (pfi.chosenTitle != 0xFFFF && key == pfi.chosenTitle) {
owner_.chosenTitleBit_ = static_cast<int32_t>(val);
LOG_DEBUG("PLAYER_CHOSEN_TITLE ", isCreate ? "from update fields: " : "updated: ",
owner_.chosenTitleBit_);
}
// VALUES-only fields: PLAYER_BYTES (appearance) and PLAYER_FLAGS (ghost state)
else if (!isCreate && pfi.pBytes != 0xFFFF && key == pfi.pBytes) {
// PLAYER_BYTES changed (barber shop, polymorph, etc.)
for (auto& ch : owner_.characters) {
if (ch.guid == owner_.playerGuid) { ch.appearanceBytes = val; break; }
}
if (owner_.appearanceChangedCallback_)
owner_.appearanceChangedCallback_();
}
else if (!isCreate && key == pfi.playerFlags) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
bool wasGhost = owner_.releasedSpirit_;
bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0;
if (!wasGhost && nowGhost) {
owner_.releasedSpirit_ = true;
LOG_INFO("Player entered ghost form (PLAYER_FLAGS)");
if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true);
} else if (wasGhost && !nowGhost) {
owner_.releasedSpirit_ = false;
owner_.playerDead_ = false;
owner_.repopPending_ = false;
owner_.resurrectPending_ = false;
owner_.selfResAvailable_ = false;
owner_.corpseMapId_ = 0; // corpse reclaimed
owner_.corpseGuid_ = 0;
owner_.corpseReclaimAvailableMs_ = 0;
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
pendingEvents_.emit("PLAYER_ALIVE", {});
if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(false);
}
pendingEvents_.emit("PLAYER_FLAGS_CHANGED", {});
}
else if (pfi.meleeAP != 0xFFFF && key == pfi.meleeAP) { owner_.playerMeleeAP_ = static_cast<int32_t>(val); }
else if (pfi.rangedAP != 0xFFFF && key == pfi.rangedAP) { owner_.playerRangedAP_ = static_cast<int32_t>(val); }
else if (pfi.spDmg1 != 0xFFFF && key >= pfi.spDmg1 && key < pfi.spDmg1 + 7) {
owner_.playerSpellDmgBonus_[key - pfi.spDmg1] = static_cast<int32_t>(val);
}
else if (pfi.healBonus != 0xFFFF && key == pfi.healBonus) { owner_.playerHealBonus_ = static_cast<int32_t>(val); }
else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); }
else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); }
else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); }
else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); }
else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); }
else if (pfi.sCrit1 != 0xFFFF && key >= pfi.sCrit1 && key < pfi.sCrit1 + 7) {
std::memcpy(&owner_.playerSpellCritPct_[key - pfi.sCrit1], &val, 4);
}
else if (pfi.rating1 != 0xFFFF && key >= pfi.rating1 && key < pfi.rating1 + 25) {
owner_.playerCombatRatings_[key - pfi.rating1] = static_cast<int32_t>(val);
}
else {
for (int si = 0; si < 5; ++si) {
if (pfi.stats[si] != 0xFFFF && key == pfi.stats[si]) {
owner_.playerStats_[si] = static_cast<int32_t>(val);
break;
}
}
}
// Do not synthesize quest-log entries from raw update-field slots.
// Slot layouts differ on some classic-family realms and can produce
// phantom "already accepted" quests that block quest acceptance.
}
if (owner_.applyInventoryFields(fields)) slotsChanged = true;
return slotsChanged;
}
// 3e: Dispatch entity spawn callbacks for units/players.
// Consolidates player/creature spawn callback invocation from CREATE and VALUES handlers.
// isDead = unitInitiallyDead (CREATE) or computed isDeadNow && !npcDeathNotified (VALUES).
void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType,
const std::shared_ptr<Entity>& entity,
const std::shared_ptr<Unit>& unit,
bool isDead) {
if (objectType == ObjectType::PLAYER && guid == owner_.playerGuid) {
return; // Skip local player — spawned separately via spawnPlayerCharacter()
}
if (objectType == ObjectType::PLAYER) {
if (owner_.playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0;
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
owner_.playerSpawnCallback_(guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
} else {
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, guid, std::dec,
" displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render");
}
}
} else if (owner_.creatureSpawnCallback_) {
LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, guid, std::dec,
" displayId=", unit->getDisplayId(), " at (",
unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")");
float unitScale = 1.0f;
uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
if (scaleIdx != 0xFFFF) {
uint32_t raw = entity->getField(scaleIdx);
if (raw != 0) {
std::memcpy(&unitScale, &raw, sizeof(float));
if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f;
}
}
owner_.creatureSpawnCallback_(guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale);
}
if (isDead && owner_.npcDeathCallback_) {
owner_.npcDeathCallback_(guid);
}
// Query quest giver status for NPCs with questgiver flag (0x02)
if (objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.socket) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
qsPkt.writeUInt64(guid);
owner_.socket->send(qsPkt);
}
}
// 3g: Track online item/container objects during CREATE.
void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItemCreated) {
auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT));
auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY));
auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY));
const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF)
? static_cast<uint16_t>(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu;
auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end();
auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end();
auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end();
auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end();
auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end();
if (entryIt != block.fields.end() && entryIt->second != 0) {
// Preserve existing info when doing partial updates
GameHandler::OnlineItemInfo info = owner_.onlineItems_.count(block.guid)
? owner_.onlineItems_[block.guid] : GameHandler::OnlineItemInfo{};
info.entry = entryIt->second;
if (stackIt != block.fields.end()) info.stackCount = stackIt->second;
if (durIt != block.fields.end()) info.curDurability = durIt->second;
if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second;
if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second;
if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second;
if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second;
if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second;
if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second;
auto [itemIt, isNew] = owner_.onlineItems_.insert_or_assign(block.guid, info);
if (isNew) newItemCreated = true;
owner_.queryItemInfo(info.entry, block.guid);
}
// Extract container slot GUIDs for bags
if (block.objectType == ObjectType::CONTAINER) {
owner_.extractContainerFields(block.guid, block.fields);
}
}
// 3g: Update item stack count / durability / enchants for existing items during VALUES.
void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block,
const std::shared_ptr<Entity>& entity) {
bool inventoryChanged = false;
const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT);
const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY);
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
// ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset
// across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8).
// Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12).
const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF;
const uint16_t itemPermEnchField = itemEnchBase;
const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF;
const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF;
const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF;
const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF;
auto it = owner_.onlineItems_.find(block.guid);
bool isItemInInventory = (it != owner_.onlineItems_.end());
for (const auto& [key, val] : block.fields) {
if (key == itemStackField && isItemInInventory) {
if (it->second.stackCount != val) {
it->second.stackCount = val;
inventoryChanged = true;
}
} else if (key == itemDurField && isItemInInventory) {
if (it->second.curDurability != val) {
const uint32_t prevDur = it->second.curDurability;
it->second.curDurability = val;
inventoryChanged = true;
// Warn once when durability drops below 20% for an equipped item.
const uint32_t maxDur = it->second.maxDurability;
if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) {
// Check if this item is in an equip slot (not bag inventory).
bool isEquipped = false;
for (uint64_t slotGuid : owner_.equipSlotGuids_) {
if (slotGuid == block.guid) { isEquipped = true; break; }
}
if (isEquipped) {
std::string itemName;
const auto* info = owner_.getItemInfo(it->second.entry);
if (info) itemName = info->name;
char buf[128];
if (!itemName.empty())
std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str());
else
std::snprintf(buf, sizeof(buf), "An equipped item is about to break!");
owner_.addUIError(buf);
owner_.addSystemChatMessage(buf);
}
}
}
} else if (key == itemMaxDurField && isItemInInventory) {
if (it->second.maxDurability != val) {
it->second.maxDurability = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) {
if (it->second.permanentEnchantId != val) {
it->second.permanentEnchantId = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) {
if (it->second.temporaryEnchantId != val) {
it->second.temporaryEnchantId = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) {
if (it->second.socketEnchantIds[0] != val) {
it->second.socketEnchantIds[0] = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) {
if (it->second.socketEnchantIds[1] != val) {
it->second.socketEnchantIds[1] = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) {
if (it->second.socketEnchantIds[2] != val) {
it->second.socketEnchantIds[2] = val;
inventoryChanged = true;
}
}
}
// Update container slot GUIDs on bag content changes
if (entity->getType() == ObjectType::CONTAINER) {
for (const auto& [key, _] : block.fields) {
if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) ||
(containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) {
inventoryChanged = true;
break;
}
}
owner_.extractContainerFields(block.guid, block.fields);
}
if (inventoryChanged) {
owner_.rebuildOnlineInventory();
pendingEvents_.emit("BAG_UPDATE", {});
pendingEvents_.emit("UNIT_INVENTORY_CHANGED", {"player"});
}
}
// ============================================================
// Phase 5: Object-type handler struct definitions
// ============================================================
struct EntityController::UnitTypeHandler : EntityController::IObjectTypeHandler {
EntityController& ctl_;
explicit UnitTypeHandler(EntityController& c) : ctl_(c) {}
void onCreate(const UpdateBlock& block, std::shared_ptr<Entity>& entity, bool&) override { ctl_.onCreateUnit(block, entity); }
void onValuesUpdate(const UpdateBlock& block, std::shared_ptr<Entity>& entity) override { ctl_.onValuesUpdateUnit(block, entity); }
};
struct EntityController::PlayerTypeHandler : EntityController::IObjectTypeHandler {
EntityController& ctl_;
explicit PlayerTypeHandler(EntityController& c) : ctl_(c) {}
void onCreate(const UpdateBlock& block, std::shared_ptr<Entity>& entity, bool&) override { ctl_.onCreatePlayer(block, entity); }
void onValuesUpdate(const UpdateBlock& block, std::shared_ptr<Entity>& entity) override { ctl_.onValuesUpdatePlayer(block, entity); }
};
struct EntityController::GameObjectTypeHandler : EntityController::IObjectTypeHandler {
EntityController& ctl_;
explicit GameObjectTypeHandler(EntityController& c) : ctl_(c) {}
void onCreate(const UpdateBlock& block, std::shared_ptr<Entity>& entity, bool&) override { ctl_.onCreateGameObject(block, entity); }
void onValuesUpdate(const UpdateBlock& block, std::shared_ptr<Entity>& entity) override { ctl_.onValuesUpdateGameObject(block, entity); }
};
struct EntityController::ItemTypeHandler : EntityController::IObjectTypeHandler {
EntityController& ctl_;
explicit ItemTypeHandler(EntityController& c) : ctl_(c) {}
void onCreate(const UpdateBlock& block, std::shared_ptr<Entity>& entity, bool& newItemCreated) override { ctl_.onCreateItem(block, newItemCreated); }
void onValuesUpdate(const UpdateBlock& block, std::shared_ptr<Entity>& entity) override { ctl_.onValuesUpdateItem(block, entity); }
};
struct EntityController::CorpseTypeHandler : EntityController::IObjectTypeHandler {
EntityController& ctl_;
explicit CorpseTypeHandler(EntityController& c) : ctl_(c) {}
void onCreate(const UpdateBlock& block, std::shared_ptr<Entity>& entity, bool&) override { ctl_.onCreateCorpse(block); }
};
// ============================================================
// Phase 5: Handler registry infrastructure
// ============================================================
void EntityController::initTypeHandlers() {
typeHandlers_[static_cast<uint8_t>(ObjectType::UNIT)] = std::make_unique<UnitTypeHandler>(*this);
typeHandlers_[static_cast<uint8_t>(ObjectType::PLAYER)] = std::make_unique<PlayerTypeHandler>(*this);
typeHandlers_[static_cast<uint8_t>(ObjectType::GAMEOBJECT)] = std::make_unique<GameObjectTypeHandler>(*this);
typeHandlers_[static_cast<uint8_t>(ObjectType::ITEM)] = std::make_unique<ItemTypeHandler>(*this);
typeHandlers_[static_cast<uint8_t>(ObjectType::CONTAINER)] = std::make_unique<ItemTypeHandler>(*this);
typeHandlers_[static_cast<uint8_t>(ObjectType::CORPSE)] = std::make_unique<CorpseTypeHandler>(*this);
}
EntityController::IObjectTypeHandler* EntityController::getTypeHandler(ObjectType type) const {
auto it = typeHandlers_.find(static_cast<uint8_t>(type));
return it != typeHandlers_.end() ? it->second.get() : nullptr;
}
// ============================================================
// Phase 6: Deferred event bus flush
// ============================================================
void EntityController::flushPendingEvents() {
for (const auto& [name, args] : pendingEvents_.events) {
owner_.fireAddonEvent(name, args);
}
pendingEvents_.clear();
}
// ============================================================
// Phase 5: Type-specific CREATE handlers
// ============================================================
void EntityController::onCreateUnit(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
// Name query for creatures
auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
if (it != block.fields.end() && it->second != 0) {
auto unit = std::static_pointer_cast<Unit>(entity);
unit->setEntry(it->second);
std::string cached = getCachedCreatureName(it->second);
if (!cached.empty()) {
unit->setName(cached);
}
queryCreatureInfo(it->second, block.guid);
}
// Unit fields
auto unit = std::static_pointer_cast<Unit>(entity);
UnitFieldIndices ufi = UnitFieldIndices::resolve();
bool unitInitiallyDead = applyUnitFieldsOnCreate(block, unit, ufi);
// Hostility
unit->setHostile(owner_.isHostileFaction(unit->getFactionTemplate()));
// Spawn dispatch
if (unit->getDisplayId() == 0) {
LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
" has displayId=0 — no spawn (entry=", unit->getEntry(),
" at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")");
}
if (unit->getDisplayId() != 0) {
dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead);
if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ &&
block.guid != owner_.playerGuid) {
owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags);
}
}
}
void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false);
// For the local player, capture the full initial field state
if (block.guid == owner_.playerGuid) {
owner_.lastPlayerFields_ = entity->getFields();
owner_.maybeDetectVisibleItemLayout();
}
// Name query + visible items
queryPlayerName(block.guid);
if (block.guid != owner_.playerGuid) {
owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields());
}
// Unit fields (PLAYER is a unit)
auto unit = std::static_pointer_cast<Unit>(entity);
UnitFieldIndices ufi = UnitFieldIndices::resolve();
bool unitInitiallyDead = applyUnitFieldsOnCreate(block, unit, ufi);
// Self-player post-unit-field handling
if (block.guid == owner_.playerGuid) {
constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100;
if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !owner_.onTaxiFlight_ && owner_.taxiLandingCooldown_ <= 0.0f) {
owner_.onTaxiFlight_ = true;
owner_.taxiStartGrace_ = std::max(owner_.taxiStartGrace_, 2.0f);
owner_.sanitizeMovementForTaxi();
if (owner_.movementHandler_) owner_.movementHandler_->applyTaxiMountForCurrentNode();
}
}
if (block.guid == owner_.playerGuid &&
(unit->getDynamicFlags() & 0x0008 /*UNIT_DYNFLAG_DEAD*/) != 0) {
owner_.playerDead_ = true;
LOG_INFO("Player logged in dead (dynamic flags)");
}
// Detect ghost state on login via PLAYER_FLAGS
if (block.guid == owner_.playerGuid) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS));
if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) {
owner_.releasedSpirit_ = true;
owner_.playerDead_ = true;
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true);
// Query corpse position so minimap marker is accurate on reconnect
if (owner_.socket) {
network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY));
owner_.socket->send(cq);
}
}
}
// 3f: Classic aura sync on initial object create
if (block.guid == owner_.playerGuid) {
syncClassicAurasFromFields(entity);
}
// Hostility
unit->setHostile(owner_.isHostileFaction(unit->getFactionTemplate()));
// Spawn dispatch
if (unit->getDisplayId() != 0) {
dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead);
if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ &&
block.guid != owner_.playerGuid) {
owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags);
}
}
// 3d: Player stat fields (self only)
if (block.guid == owner_.playerGuid) {
// Auto-detect coinage index using the previous snapshot vs this full snapshot.
maybeDetectCoinageIndex(owner_.lastPlayerFields_, block.fields);
owner_.lastPlayerFields_ = block.fields;
owner_.detectInventorySlotBases(block.fields);
if (kVerboseUpdateObject) {
uint16_t maxField = 0;
for (const auto& [key, _val] : block.fields) {
if (key > maxField) maxField = key;
}
LOG_INFO("Player update with ", block.fields.size(),
" fields (max index=", maxField, ")");
}
PlayerFieldIndices pfi = PlayerFieldIndices::resolve();
bool slotsChanged = applyPlayerStatFields(block.fields, pfi, true);
if (slotsChanged) owner_.rebuildOnlineInventory();
owner_.maybeDetectVisibleItemLayout();
owner_.extractSkillFields(owner_.lastPlayerFields_);
owner_.extractExploredZoneFields(owner_.lastPlayerFields_);
owner_.applyQuestStateFromFields(owner_.lastPlayerFields_);
}
}
void EntityController::onCreateGameObject(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
auto go = std::static_pointer_cast<GameObject>(entity);
auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID));
if (itDisp != block.fields.end()) {
go->setDisplayId(itDisp->second);
}
auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
if (itEntry != block.fields.end() && itEntry->second != 0) {
go->setEntry(itEntry->second);
auto cacheIt = gameObjectInfoCache_.find(itEntry->second);
if (cacheIt != gameObjectInfoCache_.end()) {
go->setName(cacheIt->second.name);
}
queryGameObjectInfo(itEntry->second, block.guid);
}
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(), " displayId=", go->getDisplayId(),
" updateFlags=0x", std::hex, block.updateFlags, std::dec,
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
if (block.updateFlags & 0x0002) {
transportGuids_.insert(block.guid);
LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(),
" displayId=", go->getDisplayId(),
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
// Note: TransportSpawnCallback will be invoked from Application after WMO instance is created
}
if (go->getDisplayId() != 0 && owner_.gameObjectSpawnCallback_) {
float goScale = 1.0f;
{
uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
if (scaleIdx != 0xFFFF) {
uint32_t raw = entity->getField(scaleIdx);
if (raw != 0) {
std::memcpy(&goScale, &raw, sizeof(float));
if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f;
}
}
}
owner_.gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(),
go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale);
}
// Fire transport move callback for transports (position update on re-creation)
if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
owner_.transportMoveCallback_(block.guid,
go->getX(), go->getY(), go->getZ(), go->getOrientation());
}
}
void EntityController::onCreateItem(const UpdateBlock& block, bool& newItemCreated) {
trackItemOnCreate(block, newItemCreated);
}
void EntityController::onCreateCorpse(const UpdateBlock& block) {
// Detect player's own corpse object so we have the position even when
// SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost).
if (block.hasMovement) {
// CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7)
uint16_t ownerLowIdx = 6;
auto ownerLowIt = block.fields.find(ownerLowIdx);
uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0;
auto ownerHighIt = block.fields.find(ownerLowIdx + 1);
uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0;
uint64_t ownerGuid = (static_cast<uint64_t>(ownerHigh) << 32) | ownerLow;
if (ownerGuid == owner_.playerGuid || ownerLow == static_cast<uint32_t>(owner_.playerGuid)) {
// Server coords from movement block
owner_.corpseGuid_ = block.guid;
owner_.corpseX_ = block.x;
owner_.corpseY_ = block.y;
owner_.corpseZ_ = block.z;
owner_.corpseMapId_ = owner_.currentMapId_;
LOG_INFO("Corpse object detected: guid=0x", std::hex, owner_.corpseGuid_, std::dec,
" server=(", block.x, ", ", block.y, ", ", block.z,
") map=", owner_.corpseMapId_);
}
}
}
// ============================================================
// Phase 5: Type-specific VALUES UPDATE handlers
// ============================================================
void EntityController::handleDisplayIdChange(const UpdateBlock& block,
const std::shared_ptr<Entity>& entity,
const std::shared_ptr<Unit>& unit,
const UnitFieldUpdateResult& result) {
if (!result.displayIdChanged || unit->getDisplayId() == 0 ||
unit->getDisplayId() == result.oldDisplayId)
return;
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001;
bool isDeadNow = (unit->getHealth() == 0) ||
((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0);
dispatchEntitySpawn(block.guid, entity->getType(), entity, unit,
isDeadNow && !result.npcDeathNotified);
if (owner_.addonEventCallback_) {
std::string uid;
if (block.guid == owner_.targetGuid) uid = "target";
else if (block.guid == owner_.focusGuid) uid = "focus";
else if (block.guid == owner_.petGuid_) uid = "pet";
if (!uid.empty())
pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid});
}
}
void EntityController::onValuesUpdateUnit(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
auto unit = std::static_pointer_cast<Unit>(entity);
UnitFieldIndices ufi = UnitFieldIndices::resolve();
UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi);
handleDisplayIdChange(block, entity, unit, result);
}
void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
// Other player visible items
if (block.guid != owner_.playerGuid) {
owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields());
}
// Unit field update (player IS a unit)
auto unit = std::static_pointer_cast<Unit>(entity);
UnitFieldIndices ufi = UnitFieldIndices::resolve();
UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi);
// 3f: Classic aura sync from UNIT_FIELD_AURAS when those fields are updated
if (block.guid == owner_.playerGuid) {
syncClassicAurasFromFields(entity);
}
// 3e: Display ID changed — re-spawn/model-change
handleDisplayIdChange(block, entity, unit, result);
// 3d: Self-player stat/inventory/quest field updates
if (block.guid == owner_.playerGuid) {
const bool needCoinageDetectSnapshot =
(owner_.pendingMoneyDelta_ != 0 && owner_.pendingMoneyDeltaTimer_ > 0.0f);
std::map<uint16_t, uint32_t> oldFieldsSnapshot;
if (needCoinageDetectSnapshot) {
oldFieldsSnapshot = owner_.lastPlayerFields_;
}
if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
owner_.serverRunSpeed_ = block.runSpeed;
// Some server dismount paths update run speed without updating mount display field.
if (!owner_.onTaxiFlight_ && !owner_.taxiMountActive_ &&
owner_.currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) {
LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed,
" displayId=", owner_.currentMountDisplayId_);
owner_.currentMountDisplayId_ = 0;
if (owner_.mountCallback_) {
owner_.mountCallback_(0);
}
}
}
auto mergeHint = owner_.lastPlayerFields_.end();
for (const auto& [key, val] : block.fields) {
mergeHint = owner_.lastPlayerFields_.insert_or_assign(mergeHint, key, val);
}
if (needCoinageDetectSnapshot) {
maybeDetectCoinageIndex(oldFieldsSnapshot, owner_.lastPlayerFields_);
}
owner_.maybeDetectVisibleItemLayout();
owner_.detectInventorySlotBases(block.fields);
PlayerFieldIndices pfi = PlayerFieldIndices::resolve();
bool slotsChanged = applyPlayerStatFields(block.fields, pfi, false);
if (slotsChanged) {
owner_.rebuildOnlineInventory();
pendingEvents_.emit("PLAYER_EQUIPMENT_CHANGED", {});
}
owner_.extractSkillFields(owner_.lastPlayerFields_);
owner_.extractExploredZoneFields(owner_.lastPlayerFields_);
owner_.applyQuestStateFromFields(owner_.lastPlayerFields_);
}
}
void EntityController::onValuesUpdateItem(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
updateItemOnValuesUpdate(block, entity);
}
void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::shared_ptr<Entity>& entity) {
if (block.hasMovement) {
if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
owner_.transportMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
} else if (owner_.gameObjectMoveCallback_) {
owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
}
}
}
// ============================================================
// Phase 2: Update type handlers (refactored with Phase 5 dispatch)
// ============================================================
void EntityController::handleCreateObject(const UpdateBlock& block, bool& newItemCreated) {
pendingEvents_.clear();
// 3a: Create entity from block type
std::shared_ptr<Entity> entity = createEntityFromBlock(block);
// Set position from movement block (server → canonical)
if (block.hasMovement) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")");
if (block.guid == owner_.playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
owner_.serverRunSpeed_ = block.runSpeed;
}
// 3b: Track player-on-transport state
if (block.guid == owner_.playerGuid) {
applyPlayerTransportState(block, entity, pos, oCanonical, false);
}
// 3i: Track transport-relative children so they follow parent transport motion.
updateNonPlayerTransportAttachment(block, entity, block.objectType);
}
// Set fields
for (const auto& field : block.fields) {
entity->setField(field.first, field.second);
}
// Add to manager
entityManager.addEntity(block.guid, entity);
// Phase 5: Dispatch to type-specific handler
auto* handler = getTypeHandler(block.objectType);
if (handler) handler->onCreate(block, entity, newItemCreated);
flushPendingEvents();
}
void EntityController::handleValuesUpdate(const UpdateBlock& block) {
auto entity = entityManager.getEntity(block.guid);
if (!entity) return;
pendingEvents_.clear();
// Position update (common)
if (block.hasMovement) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
updateNonPlayerTransportAttachment(block, entity, entity->getType());
}
// Set fields (common)
for (const auto& field : block.fields) {
entity->setField(field.first, field.second);
}
// Phase 5: Dispatch to type-specific handler
auto* handler = getTypeHandler(entity->getType());
if (handler) handler->onValuesUpdate(block, entity);
flushPendingEvents();
LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec);
}
void EntityController::handleMovementUpdate(const UpdateBlock& block) {
// Diagnostic: Log if we receive MOVEMENT blocks for transports
if (transportGuids_.count(block.guid)) {
LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, ")");
}
// Update entity position (server → canonical)
auto entity = entityManager.getEntity(block.guid);
if (entity) {
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec);
updateNonPlayerTransportAttachment(block, entity, entity->getType());
// 3b: Track player-on-transport state from MOVEMENT updates
if (block.guid == owner_.playerGuid) {
owner_.movementInfo.orientation = oCanonical;
applyPlayerTransportState(block, entity, pos, oCanonical, true);
}
// Fire transport move callback if this is a known transport
if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) {
serverUpdatedTransportGuids_.insert(block.guid);
owner_.transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical);
}
// Fire move callback for non-transport gameobjects.
if (entity->getType() == ObjectType::GAMEOBJECT &&
transportGuids_.count(block.guid) == 0 &&
owner_.gameObjectMoveCallback_) {
owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
entity->getZ(), entity->getOrientation());
}
// Fire move callback for non-player units (creatures).
// SMSG_MONSTER_MOVE handles smooth interpolated movement, but many
// servers (especially vanilla/Turtle WoW) communicate NPC positions
// via MOVEMENT blocks instead. Use duration=0 for an instant snap.
if (block.guid != owner_.playerGuid &&
entity->getType() == ObjectType::UNIT &&
transportGuids_.count(block.guid) == 0 &&
owner_.creatureMoveCallback_) {
owner_.creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0);
}
} else {
LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec);
}
}
void EntityController::finalizeUpdateObjectBatch(bool newItemCreated) {
owner_.tabCycleStale = true;
// Entity count logging disabled
// Deferred rebuild: if new item objects were created in this packet, rebuild
// owner_.inventory so that slot GUIDs updated earlier in the same packet can resolve.
if (newItemCreated) {
owner_.rebuildOnlineInventory();
}
// Late owner_.inventory base detection once items are known
if (owner_.playerGuid != 0 && owner_.invSlotBase_ < 0 && !owner_.lastPlayerFields_.empty() && !owner_.onlineItems_.empty()) {
owner_.detectInventorySlotBases(owner_.lastPlayerFields_);
if (owner_.invSlotBase_ >= 0) {
if (owner_.applyInventoryFields(owner_.lastPlayerFields_)) {
owner_.rebuildOnlineInventory();
}
}
}
}
void EntityController::handleCompressedUpdateObject(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize());
// First 4 bytes = decompressed size
if (packet.getSize() < 4) {
LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small");
return;
}
uint32_t decompressedSize = packet.readUInt32();
LOG_DEBUG(" Decompressed size: ", decompressedSize);
// Capital cities and large raids can produce very large update packets.
// The real WoW client handles up to ~10MB; 5MB covers all practical cases.
if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) {
LOG_WARNING("Invalid decompressed size: ", decompressedSize);
return;
}
// Remaining data is zlib compressed
size_t compressedSize = packet.getRemainingSize();
const uint8_t* compressedData = packet.getData().data() + packet.getReadPos();
// Decompress
std::vector<uint8_t> decompressed(decompressedSize);
uLongf destLen = decompressedSize;
int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize);
if (ret != Z_OK) {
LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret);
return;
}
// Create packet from decompressed data and parse it
network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed);
handleUpdateObject(decompressedPacket);
}
void EntityController::handleDestroyObject(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_DESTROY_OBJECT");
DestroyObjectData data;
if (!DestroyObjectParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT");
return;
}
// Remove entity
if (entityManager.hasEntity(data.guid)) {
if (transportGuids_.count(data.guid) > 0) {
const bool playerAboardNow = (owner_.playerTransportGuid_ == data.guid);
const bool stickyAboard = (owner_.playerTransportStickyGuid_ == data.guid && owner_.playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (owner_.movementInfo.transportGuid == data.guid);
if (playerAboardNow || stickyAboard || movementSaysAboard) {
serverUpdatedTransportGuids_.erase(data.guid);
LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
return;
}
}
// Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal.
auto entity = entityManager.getEntity(data.guid);
if (entity) {
if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) {
owner_.creatureDespawnCallback_(data.guid);
} else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) {
// Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range.
owner_.playerDespawnCallback_(data.guid);
owner_.otherPlayerVisibleItemEntries_.erase(data.guid);
owner_.otherPlayerVisibleDirty_.erase(data.guid);
owner_.otherPlayerMoveTimeMs_.erase(data.guid);
owner_.inspectedPlayerItemEntries_.erase(data.guid);
owner_.pendingAutoInspect_.erase(data.guid);
pendingNameQueries.erase(data.guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) {
owner_.gameObjectDespawnCallback_(data.guid);
}
}
if (transportGuids_.count(data.guid) > 0) {
transportGuids_.erase(data.guid);
serverUpdatedTransportGuids_.erase(data.guid);
if (owner_.playerTransportGuid_ == data.guid) {
owner_.clearPlayerTransport();
}
}
owner_.clearTransportAttachment(data.guid);
entityManager.removeEntity(data.guid);
LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec,
" (", (data.isDeath ? "death" : "despawn"), ")");
} else {
LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec);
}
// Clean up auto-attack and target if destroyed entity was our target
if (owner_.combatHandler_ && data.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) {
owner_.stopAutoAttack();
}
if (data.guid == owner_.targetGuid) {
owner_.targetGuid = 0;
}
if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(data.guid);
// Remove online item/container tracking
owner_.containerContents_.erase(data.guid);
if (owner_.onlineItems_.erase(data.guid)) {
owner_.rebuildOnlineInventory();
}
// Clean up quest giver status
owner_.npcQuestStatus_.erase(data.guid);
// Remove combat text entries referencing the destroyed entity so floating
// damage numbers don't linger after the source/target despawns.
if (owner_.combatHandler_) owner_.combatHandler_->removeCombatTextForGuid(data.guid);
// Clean up unit cast owner_.state (cast bar) for the destroyed unit
if (owner_.spellHandler_) owner_.spellHandler_->unitCastStates_.erase(data.guid);
// Clean up cached auras
if (owner_.spellHandler_) owner_.spellHandler_->unitAurasCache_.erase(data.guid);
owner_.tabCycleStale = true;
}
// Name Queries
// ============================================================
void EntityController::queryPlayerName(uint64_t guid) {
// If already cached, apply the name to the entity (handles entity recreation after
// moving out/in range — the entity object is new but the cached name is valid).
auto cacheIt = playerNameCache.find(guid);
if (cacheIt != playerNameCache.end()) {
auto entity = entityManager.getEntity(guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(entity);
if (player->getName().empty()) {
player->setName(cacheIt->second);
}
}
return;
}
if (pendingNameQueries.count(guid)) return;
if (!owner_.isInWorld()) {
LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec,
" owner_.state=", worldStateName(owner_.state), " owner_.socket=", (owner_.socket ? "yes" : "no"));
return;
}
LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec);
pendingNameQueries.insert(guid);
auto packet = NameQueryPacket::build(guid);
owner_.socket->send(packet);
}
void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) {
if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return;
if (!owner_.isInWorld()) return;
pendingCreatureQueries.insert(entry);
auto packet = CreatureQueryPacket::build(entry, guid);
owner_.socket->send(packet);
}
void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) {
if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return;
if (!owner_.isInWorld()) return;
pendingGameObjectQueries_.insert(entry);
auto packet = GameObjectQueryPacket::build(entry, guid);
owner_.socket->send(packet);
}
std::string EntityController::getCachedPlayerName(uint64_t guid) const {
return std::string(lookupName(guid));
}
std::string EntityController::getCachedCreatureName(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.name : "";
}
void EntityController::handleNameQueryResponse(network::Packet& packet) {
NameQueryResponseData data;
if (!owner_.packetParsers_ || !owner_.packetParsers_->parseNameQueryResponse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")");
return;
}
pendingNameQueries.erase(data.guid);
LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec,
" found=", static_cast<int>(data.found), " name='", data.name, "'",
" race=", static_cast<int>(data.race), " class=", static_cast<int>(data.classId));
if (data.isValid()) {
playerNameCache[data.guid] = data.name;
// Cache class/race from name query for UnitClass/UnitRace fallback
if (data.classId != 0 || data.race != 0) {
playerClassRaceCache_[data.guid] = {data.classId, data.race};
}
// Update entity name
auto entity = entityManager.getEntity(data.guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(entity);
player->setName(data.name);
}
// Backfill chat history entries that arrived before we knew the name.
if (owner_.chatHandler_) {
for (auto& msg : owner_.chatHandler_->getChatHistory()) {
if (msg.senderGuid == data.guid && msg.senderName.empty()) {
msg.senderName = data.name;
}
}
}
// Backfill mail inbox sender names
for (auto& mail : owner_.mailInbox_) {
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
mail.senderName = data.name;
}
}
// Backfill friend list: if this GUID came from a friend list packet,
// register the name in owner_.friendsCache now that we know it.
if (owner_.friendGuids_.count(data.guid)) {
owner_.friendsCache[data.name] = data.guid;
}
// Backfill ignore list: SMSG_IGNORE_LIST only contains GUIDs, so
// ignoreCache (name→guid for UI) is populated here once names resolve.
if (owner_.ignoreListGuids_.count(data.guid)) {
owner_.ignoreCache[data.name] = data.guid;
}
// Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available
if (owner_.addonEventCallback_) {
std::string unitId;
if (data.guid == owner_.targetGuid) unitId = "target";
else if (data.guid == owner_.focusGuid) unitId = "focus";
else if (data.guid == owner_.playerGuid) unitId = "player";
if (!unitId.empty())
owner_.fireAddonEvent("UNIT_NAME_UPDATE", {unitId});
}
}
}
void EntityController::handleCreatureQueryResponse(network::Packet& packet) {
CreatureQueryResponseData data;
if (!owner_.packetParsers_->parseCreatureQueryResponse(packet, data)) return;
pendingCreatureQueries.erase(data.entry);
if (data.isValid()) {
creatureInfoCache[data.entry] = data;
// Update all unit entities with this entry
for (auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(entity);
if (unit->getEntry() == data.entry) {
unit->setName(data.name);
}
}
}
}
}
// ============================================================
// GameObject Query
// ============================================================
void EntityController::handleGameObjectQueryResponse(network::Packet& packet) {
GameObjectQueryResponseData data;
bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGameObjectQueryResponse(packet, data)
: GameObjectQueryResponseParser::parse(packet, data);
if (!ok) return;
pendingGameObjectQueries_.erase(data.entry);
if (data.isValid()) {
gameObjectInfoCache_[data.entry] = data;
// Update all gameobject entities with this entry
for (auto& [guid, entity] : entityManager.getEntities()) {
if (entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
if (go->getEntry() == data.entry) {
go->setName(data.name);
}
}
}
// MO_TRANSPORT (type 15): assign TaxiPathNode path if available
if (data.type == 15 && data.hasData && data.data[0] != 0 && owner_.transportManager_) {
uint32_t taxiPathId = data.data[0];
if (owner_.transportManager_->hasTaxiPath(taxiPathId)) {
if (owner_.transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) {
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
}
} else {
LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
" not found in TaxiPathNode.dbc");
}
}
}
}
void EntityController::handleGameObjectPageText(network::Packet& packet) {
if (!packet.hasRemaining(8)) return;
uint64_t guid = packet.readUInt64();
auto entity = entityManager.getEntity(guid);
if (!entity || entity->getType() != ObjectType::GAMEOBJECT) return;
auto go = std::static_pointer_cast<GameObject>(entity);
uint32_t entry = go->getEntry();
if (entry == 0) return;
auto cacheIt = gameObjectInfoCache_.find(entry);
if (cacheIt == gameObjectInfoCache_.end()) {
queryGameObjectInfo(entry, guid);
return;
}
const GameObjectQueryResponseData& info = cacheIt->second;
uint32_t pageId = 0;
// AzerothCore layout:
// type 9 (TEXT): data[0]=pageID
// type 10 (GOOBER): data[7]=pageId
if (info.type == 9) pageId = info.data[0];
else if (info.type == 10) pageId = info.data[7];
if (pageId != 0 && owner_.socket && owner_.state == WorldState::IN_WORLD) {
owner_.bookPages_.clear(); // start a fresh book for this interaction
auto req = PageTextQueryPacket::build(pageId, guid);
owner_.socket->send(req);
return;
}
if (!info.name.empty()) {
owner_.addSystemChatMessage(info.name);
}
}
void EntityController::handlePageTextQueryResponse(network::Packet& packet) {
PageTextQueryResponseData data;
if (!PageTextQueryResponseParser::parse(packet, data)) return;
if (!data.isValid()) return;
// Append page if not already collected
bool alreadyHave = false;
for (const auto& bp : owner_.bookPages_) {
if (bp.pageId == data.pageId) { alreadyHave = true; break; }
}
if (!alreadyHave) {
owner_.bookPages_.push_back({data.pageId, data.text});
}
// Follow the chain: if there's a next page we haven't fetched yet, request it
if (data.nextPageId != 0) {
bool nextHave = false;
for (const auto& bp : owner_.bookPages_) {
if (bp.pageId == data.nextPageId) { nextHave = true; break; }
}
if (!nextHave && owner_.socket && owner_.state == WorldState::IN_WORLD) {
auto req = PageTextQueryPacket::build(data.nextPageId, owner_.playerGuid);
owner_.socket->send(req);
}
}
LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId,
" nextPage=", data.nextPageId,
" totalPages=", owner_.bookPages_.size());
}
} // namespace game
} // namespace wowee