feat(entity): the object's interaction distance, preconditions and world state, as the client

LWOGameObject loads these itself: interactionDistance from its Objects row
(16 without one) and the level's interaction_distance over it,
CheckPrecondition and objectWorldState from the level config, before its
components load theirs. The quickbuild, the pet and the race instancer read
them from the object; the race instancer now uses the Objects row's
distance like the client does, instead of a fixed 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-02 08:44:53 -05:00
parent 0c215de446
commit 6f4fd4054a
10 changed files with 47 additions and 7 deletions

View File

@@ -436,6 +436,18 @@ void Entity::DoObjectComponentLoad(ObjectLoadState& state) {
if (!triggerInfo.empty()) AddComponent<TriggerComponent>(-1, triggerInfo);
}
void Entity::LoadTemplateData() {
const auto& object = CDClientManager::GetTable<CDObjectsTable>()->GetByID(m_TemplateID);
if (object.id == m_TemplateID && object.interactionDistance >= 0.0f) m_InteractionDistance = object.interactionDistance;
}
// The client erases interaction_distance from the config once read; server scripts still read it, so it stays
void Entity::LoadConfigData() {
if (HasVar(u"interaction_distance")) m_InteractionDistance = GetVarAs<float>(u"interaction_distance");
if (HasVar(u"objectWorldState")) m_ObjectWorldState = GetVarAs<int32_t>(u"objectWorldState");
if (HasVar(u"CheckPrecondition")) m_CheckPrecondition = GetVar<std::u16string>(u"CheckPrecondition");
}
void Entity::Initialize() {
RegisterMsg(&Entity::MsgRequestServerObjectInfo);
RegisterMsg(&Entity::MsgDropClientLoot);
@@ -464,13 +476,16 @@ void Entity::Initialize() {
m_ParentEntity->AddChild(this);
}
LoadTemplateData();
m_LoadingComponents = true;
auto loadState = DoObjectPreLoad();
DoObjectComponentLoad(loadState);
m_LoadingComponents = false;
// LWOGameObject::MakeReadyForSerialization (0x0101c230): every component exists; each loads the object's config,
// then a character's save, in the order they were made
// LWOGameObject::MakeReadyForSerialization (0x0101c230): every component exists; the object loads its config, then
// each component the config and a character's save, in the order they were made
LoadConfigData();
for (auto* const component : m_ComponentList) {
component->LoadConfigData();
if (m_Character) component->LoadFromSaveData(m_Character->GetXMLDoc());

View File

@@ -370,6 +370,18 @@ public:
// Scale will only be communicated to the client when the construction packet is sent
void SetScale(const float scale) { m_Scale = scale; };
// How close a player must be to use the object (LWOGameObject::interactionDistance): the Objects row's, else 16,
// over which the level's interaction_distance goes
float GetInteractionDistance() const { return m_InteractionDistance; }
// The precondition expression for using the object (LWOGameObject::CheckPrecondition), from the level config
const std::u16string& GetCheckPrecondition() const { return m_CheckPrecondition; }
// The client's SetPreconditions message
void SetCheckPrecondition(const std::u16string& precondition) { m_CheckPrecondition = precondition; }
// The object's world state (LWOGameObject::objectWorldState), from the level config's objectWorldState
int32_t GetObjectWorldState() const { return m_ObjectWorldState; }
// The client moves the object with its parent (LWOGameObject::updatesWithParentPosition), sent with the parent;
// the spawn behavior's updatePositionWithParent sets it (live: the Engineer Personal Fortress, LOT 9479)
void SetUpdatesWithParentPosition(const bool value) { m_UpdatesWithParentPosition = value; m_IsParentChildDirty = true; }
@@ -403,6 +415,11 @@ public:
private:
// LWOGameObject::LoadTemplateData (0x00f997c0) and LoadConfigData (0x00f86f60): the object's own data from its
// Objects row and its level config
void LoadTemplateData();
void LoadConfigData();
// ObjectLoader2::DoObjectPreLoad: what the components will be, before any is made
ObjectLoadState DoObjectPreLoad();
// ObjectLoader2::DoObjectComponentLoad: the components in the client's order, then the ones it adds after
@@ -471,6 +488,9 @@ private:
bool m_IsParentChildDirty = true;
bool m_UpdatesWithParentPosition = false;
float m_InteractionDistance = 16.0f;
std::u16string m_CheckPrecondition;
int32_t m_ObjectWorldState = 0;
// When this object was created on this server. Construction sends its age from this, as live did.
std::chrono::steady_clock::time_point m_CreationTime = std::chrono::steady_clock::now();

View File

@@ -59,7 +59,7 @@ void QuickBuildComponent::LoadDataFromTemplate() {
}
void QuickBuildComponent::LoadConfigData() {
std::u16string checkPreconditions = m_Parent->GetVar<std::u16string>(u"CheckPrecondition");
const auto& checkPreconditions = m_Parent->GetCheckPrecondition();
if (!checkPreconditions.empty()) {
m_Precondition = new PreconditionExpression(GeneralUtils::UTF16ToWTF8(checkPreconditions));

View File

@@ -79,7 +79,7 @@ void PetComponent::LoadDataFromTemplate() {
// The level config: the taming preconditions
void PetComponent::LoadConfigData() {
std::string checkPreconditions = GeneralUtils::UTF16ToWTF8(m_Parent->GetVar<std::u16string>(u"CheckPrecondition"));
std::string checkPreconditions = GeneralUtils::UTF16ToWTF8(m_Parent->GetCheckPrecondition());
if (!checkPreconditions.empty()) {
SetPreconditions(checkPreconditions);

View File

@@ -3,5 +3,5 @@
#include "Entity.h"
void GfRaceInstancer::OnStartup(Entity* self) {
self->SetProximityRadius(self->HasVar(u"interaction_distance") ? self->GetVar<float>(u"interaction_distance") : 16.0f, "Interaction_Distance");
self->SetProximityRadius(self->GetInteractionDistance(), "Interaction_Distance");
}

View File

@@ -133,6 +133,7 @@ TEST_F(PlayerStatisticTest, SmashingCountsEnemiesAndSmashables) {
// Smash finds the killer through the entity manager, which looks the new entity up in the CDClient
CDClientDatabase::Connect(":memory:");
CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);");
CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL);");
ASSERT_NE(Game::entityManager->CreateEntity(info, nullptr, nullptr, true, 0x3FFFFFFFFFFELL), nullptr); // the zone control object loot asks
auto* killer = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, PLAYER + 1);
ASSERT_NE(killer, nullptr);

View File

@@ -40,7 +40,7 @@ protected:
SetUpDependencies();
CDClientDatabase::Connect(":memory:");
CDClientDatabase::ExecuteDML("CREATE TABLE ItemSets (setID INTEGER, itemIDs TEXT);");
CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT);");
CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL);");
CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);");
CDClientDatabase::ExecuteDML("CREATE TABLE ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER);");
CDClientDatabase::ExecuteDML("CREATE TABLE SkillBehavior (skillID INTEGER, behaviorID INTEGER);");
@@ -228,7 +228,7 @@ TEST_F(LootMetricsTests, QuickbuildTakesItsCostAsTheSource) {
auto pedestalInfo = info;
pedestalInfo.lot = 8551;
auto pedestal = std::make_unique<Entity>(0x0102030405060708LL, pedestalInfo);
pedestal->SetVar<std::u16string>(u"CheckPrecondition", GeneralUtils::ASCIIToUTF16(std::to_string(PRECONDITION)));
pedestal->SetCheckPrecondition(GeneralUtils::ASCIIToUTF16(std::to_string(PRECONDITION)));
auto* const quickBuild = pedestal->AddComponent<QuickBuildComponent>(-1);
quickBuild->SetResetTime(20.0f);
quickBuild->SetCompleteTime(10.0f);

View File

@@ -26,6 +26,7 @@ namespace {
CDClientDatabase::Connect(":memory:");
for (const auto* table : {
"ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER)",
"Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL)",
"ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER)",
"SkillBehavior (skillID INTEGER, behaviorID INTEGER)",
"ItemSets (setID INTEGER, itemIDs TEXT)",

View File

@@ -294,6 +294,7 @@ namespace {
CDClientDatabase::Connect(":memory:");
for (const auto* table : {
"ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER)",
"Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL)",
"BaseCombatAIComponent (id INTEGER, aggroRadius REAL, tetherSpeed REAL, pursuitSpeed REAL, softTetherRadius REAL, hardTetherRadius REAL, minRoundLength REAL, maxRoundLength REAL, combatRoundLength REAL)",
"ItemSets (setID INTEGER, itemIDs TEXT)",
"ObjectSkills (objectTemplate INTEGER, skillID INTEGER, castOnType INTEGER, AICombatWeight INTEGER)",

View File

@@ -105,6 +105,7 @@ TEST_F(ClientMessagesTests, CasterDeadMatchesLiveCapture) {
TEST_F(ClientMessagesTests, CasterDeadEndsTheDeadCastersSkill) {
CDClientDatabase::Connect(":memory:"); // the entity manager looks the new entity up
CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);");
CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL);");
auto* const caster = Game::entityManager->CreateEntity(info, nullptr, nullptr, false, 0x40040000000bdb5LL);
ASSERT_NE(caster, nullptr);
auto* const destroyable = caster->AddComponent<DestroyableComponent>(-1);
@@ -130,6 +131,7 @@ TEST_F(ClientMessagesTests, ResyncEquipmentResendsTheEquipment) {
auto msg = FromLiveClientCapture<GameMessages::ResyncEquipment>(std::string(PLAYER_HEADER) + "d604");
CDClientDatabase::Connect(":memory:"); // the inventory looks its component up
CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);");
CDClientDatabase::ExecuteDML("CREATE TABLE Objects (id INTEGER, name TEXT, type TEXT, interactionDistance REAL);");
auto* const inventory = player->AddComponent<InventoryComponent>(-1);
RakNet::BitStream first;
inventory->Serialize(first, false); // clears the dirty flag it starts with