Merge exp/property-replay: property models, info and behaviours in capture replays

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:54:20 -05:00
20 changed files with 1211 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS
"RocketLaunchTests.cpp"
"ReplicaConstructionTests.cpp"
"ReplicaDecoderTests.cpp"
"CapturePropertyTests.cpp"
"ItemRemovalTests.cpp"
"LootMetricsTests.cpp"
)

View File

@@ -0,0 +1,231 @@
// A property as a packet capture saw it (CaptureProperty): a synthetic capture made from the server's own writers (a
// brick built model and its replica packets, and the property's game messages as the server and client send them),
// read back into the models' timeline, the property's info and the events the capture page lists.
#include "GameDependencies.h"
#include <gtest/gtest.h>
#include "BitStream.h"
#include "BrickByBrick.h"
#include "CaptureBundle.h"
#include "CaptureProperty.h"
#include "CaptureTools.h"
#include "DestroyableComponent.h"
#include "Entity.h"
#include "GameMessageDecoder.h"
#include "MessageIdentifiers.h"
#include "MessageType/Client.h"
#include "MessageType/World.h"
#include "ModelComponent.h"
#include "PacketDecoder.h"
#include "PropertyMessages.h"
#include "ReplicaDecoder.h"
#include "ServiceType.h"
#include "SimplePhysicsComponent.h"
#include "eReplicaComponentType.h"
#include "eReplicaPacketType.h"
namespace {
using json = nlohmann::json;
using enum eReplicaComponentType;
constexpr LWOOBJID PLAYER = 1152921506064087003;
constexpr LWOOBJID FIRST = 288300744895900001, SECOND = 288300744895900002, THIRD = 288300744895900003;
std::string Bytes(const RakNet::BitStream& stream) { return std::string(reinterpret_cast<const char*>(stream.GetData()), stream.GetNumberOfBytesUsed()); }
std::string Construction(Entity& entity, uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_CONSTRUCTION);
stream.Write1();
stream.Write(network);
entity.WriteBaseReplicaData(stream, eReplicaPacketType::CONSTRUCTION);
entity.WriteComponents(stream, eReplicaPacketType::CONSTRUCTION);
return Bytes(stream);
}
std::string Serialization(Entity& entity, uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_SERIALIZE);
stream.Write(network);
entity.WriteBaseReplicaData(stream, eReplicaPacketType::SERIALIZATION);
entity.WriteComponents(stream, eReplicaPacketType::SERIALIZATION);
return Bytes(stream);
}
std::string Destruction(uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_DESTRUCTION);
stream.Write(network);
return Bytes(stream);
}
// A game message as the server sends it (CLIENT GAME_MSG) or the client does (WORLD GAME_MSG)
std::string GameMessage(bool toServer, LWOOBJID object, const GameMessages::NetGameMsg& message) {
RakNet::BitStream stream;
if (toServer) LUBitStream(ServiceType::WORLD, MessageType::World::GAME_MSG).WriteHeader(stream);
else LUBitStream(ServiceType::CLIENT, MessageType::Client::GAME_MSG).WriteHeader(stream);
stream.Write(object);
stream.Write(message.msgId);
message.Serialize(stream);
return Bytes(stream);
}
struct Capture {
std::vector<CaptureBundle::Record> records;
// One world packet, a second after the one before
void Add(bool fromClient, std::string bytes) {
CaptureBundle::Record record;
record.header.seq = static_cast<uint32_t>(records.size());
record.header.timeUs = 1000000 * static_cast<int64_t>(records.size() + 1);
record.header.source = static_cast<uint8_t>(eCaptureSource::WORLD);
record.header.direction = static_cast<uint8_t>(fromClient ? ePacketDirection::RECEIVED : ePacketDirection::SENT);
record.header.zoneId = 1150;
record.header.instanceId = 7;
record.header.cloneId = 42;
record.header.characterId = PLAYER;
record.header.bits = static_cast<uint32_t>(bytes.size() * 8);
record.bytes = std::move(bytes);
records.push_back(std::move(record));
}
};
GameMessages::DownloadPropertyData PropertyData(const std::u16string& name, uint64_t reputation) {
GameMessages::DownloadPropertyData data;
data.propertyId = 1234;
data.mapId = 1150;
data.name = name;
data.description = u"A garden";
data.ownerName = u"Builder";
data.ownerId = PLAYER;
data.reputation = reputation;
data.accessType = 2;
data.moderationStatus = 1;
return data;
}
}
class CapturePropertyTest : public GameDependenciesTest {
protected:
void SetUp() override {
SetUpDependencies();
PacketDecoder::SetGameMessageDecoder([](MessageType::Game id, bool toServer, RakNet::BitStream& payload) {
return GameMessageDecoder::Decode(id, toServer, payload);
});
}
void TearDown() override { TearDownDependencies(); }
std::unique_ptr<Entity> MakeModel(LWOOBJID id, const NiPoint3& position, LWOOBJID blueprint) {
info.lot = BrickByBrick::MODEL_OBJECT_LOT;
info.settings = {};
info.settings.Insert<LWOOBJID>(u"blueprintid", blueprint);
auto model = std::make_unique<Entity>(id, info);
model->AddComponent<SimplePhysicsComponent>(-1)->SetPosition(position);
model->AddComponent<ModelComponent>(-1)->LoadBehaviors();
model->AddComponent<DestroyableComponent>(-1)->SetIsSmashable(true);
return model;
}
};
TEST_F(CapturePropertyTest, ModelsInfoAndEventsOverTime) {
Capture capture;
const NiPoint3 a(1.0f, 2.0f, 3.0f), b(10.0f, 2.0f, -4.0f), c(5.0f, 2.0f, 5.0f);
auto first = MakeModel(FIRST, a, 5551);
auto second = MakeModel(SECOND, b, 5552);
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"Old name", 10))); // 0: the property as it loads
capture.Add(false, Construction(*first, 20)); // 1: a model already there
GameMessages::PlaceModelResponse placed;
placed.position = b;
placed.response = BrickByBrick::PLACE_MODEL_PLACED;
capture.Add(false, GameMessage(false, PLAYER, placed)); // 2: the server placed one...
capture.Add(false, Construction(*second, 21)); // 3: ...and made it
first->GetComponent<SimplePhysicsComponent>()->SetPosition(c);
capture.Add(false, Serialization(*first, 20)); // 4: the first one moves
GameMessages::PlayBehaviorSound sound;
sound.soundID = 3;
capture.Add(false, GameMessage(false, FIRST, sound)); // 5: its behavior plays a sound
GameMessages::DeleteModelFromClient pickUp;
pickUp.modelID = SECOND;
pickUp.reason = static_cast<int32_t>(BrickByBrick::eDeleteReason::PICKING_MODEL_UP);
capture.Add(true, GameMessage(true, PLAYER, pickUp)); // 6: the player picks the second up
capture.Add(false, Destruction(21)); // 7: and it goes
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 8: renamed
auto elsewhere = PropertyData(u"Another property", 5);
elsewhere.mapId = 1250;
capture.Add(false, GameMessage(false, PLAYER, elsewhere)); // 9: the player's property on another map
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 10: the same data again
// This server makes the model before it answers the placement
const NiPoint3 d(-8.8f, 455.04f, 126.4f);
auto third = MakeModel(THIRD, d, 5553);
capture.Add(false, Construction(*third, 22)); // 11: made...
placed.position = d;
capture.Add(false, GameMessage(false, PLAYER, placed)); // 12: ...then placed
const ReplicaDecoder::ComponentTable table{ { BrickByBrick::MODEL_OBJECT_LOT, {} } };
ReplicaDecoder::Session session(table);
std::vector<std::optional<json>> replica(capture.records.size());
for (size_t i = 0; i < capture.records.size(); i++) replica[i] = session.Decode(capture.records[i].bytes, CaptureTools::ReplicaConnection(capture.records[i].header));
const auto out = CaptureProperty::Build(capture.records, 0, [&](size_t i) { return replica[i] ? &*replica[i] : nullptr; });
ASSERT_EQ(out["worlds"].size(), 1u) << out.dump();
const auto& world = out["worlds"][0];
EXPECT_EQ(world["zone"], 1150);
EXPECT_EQ(world["instance"], 7);
EXPECT_EQ(world["clone"], 42);
// The property as each DownloadPropertyData said it, leaving out the one about another map
ASSERT_EQ(world["info"].size(), 2u);
EXPECT_EQ(world["info"][0]["name"], "Old name");
EXPECT_EQ(world["info"][0]["ownerName"], "Builder");
EXPECT_EQ(world["info"][0]["reputation"], "10");
EXPECT_EQ(world["info"][0]["i"], 0);
EXPECT_EQ(world["info"][1]["name"], "New name");
EXPECT_EQ(world["info"][1]["reputation"], "25");
// The models: where each stood and when
ASSERT_EQ(world["models"].size(), 3u);
const auto& m1 = world["models"][0];
EXPECT_EQ(m1["object"], std::to_string(FIRST));
EXPECT_EQ(m1["lot"], BrickByBrick::MODEL_OBJECT_LOT);
EXPECT_EQ(m1["ugcId"], "5551");
ASSERT_EQ(m1["spans"].size(), 2u) << m1.dump();
EXPECT_EQ(m1["spans"][0]["from"], 2.0);
EXPECT_EQ(m1["spans"][0]["to"], 5.0);
EXPECT_EQ(m1["spans"][0]["position"], json::array({ 1.0f, 2.0f, 3.0f }));
EXPECT_EQ(m1["spans"][1]["from"], 5.0);
EXPECT_TRUE(m1["spans"][1]["to"].is_null());
EXPECT_EQ(m1["spans"][1]["position"], json::array({ 5.0f, 2.0f, 5.0f }));
const auto& m2 = world["models"][1];
EXPECT_EQ(m2["ugcId"], "5552");
ASSERT_EQ(m2["spans"].size(), 1u);
EXPECT_EQ(m2["spans"][0]["from"], 4.0);
EXPECT_EQ(m2["spans"][0]["to"], 8.0);
// What the capture page lists: placed (the server's answer, with the model it made), moved, the behavior's
// message and removed (with the reason the client gave)
std::vector<std::string> kinds;
for (const auto& event : world["events"]) kinds.push_back(event["kind"]);
EXPECT_EQ(kinds, (std::vector<std::string>{ "placed", "moved", "behavior", "removed", "placed" })) << world["events"].dump();
EXPECT_EQ(world["events"][0]["i"], 2);
EXPECT_EQ(world["events"][0]["object"], std::to_string(SECOND));
EXPECT_EQ(world["events"][0]["made"], 3);
EXPECT_EQ(world["events"][1]["object"], std::to_string(FIRST));
EXPECT_EQ(world["events"][2]["message"], "PLAY_BEHAVIOR_SOUND");
EXPECT_EQ(world["events"][2]["object"], std::to_string(FIRST));
EXPECT_EQ(world["events"][3]["object"], std::to_string(SECOND));
EXPECT_EQ(world["events"][3]["reason"], "picked up");
EXPECT_EQ(world["events"][3]["asked"], 6);
EXPECT_EQ(world["events"][4]["i"], 12);
EXPECT_EQ(world["events"][4]["object"], std::to_string(THIRD));
EXPECT_EQ(world["events"][4]["made"], 11);
}
// A world with no property message and no model isn't a property
TEST_F(CapturePropertyTest, OtherWorldsAreLeftOut) {
Capture capture;
GameMessages::PlayBehaviorSound sound;
capture.Add(false, GameMessage(false, PLAYER, sound));
const auto out = CaptureProperty::Build(capture.records, 0, [](size_t) { return nullptr; });
EXPECT_TRUE(out["worlds"].empty()) << out.dump();
}

View File

@@ -12,7 +12,6 @@
#include "DestroyableComponent.h"
#include "Entity.h"
#include "InventoryComponent.h"
#include "ItemComponent.h"
#include "LevelProgressionComponent.h"
#include "MessageIdentifiers.h"
#include "ModelComponent.h"
@@ -142,11 +141,13 @@ TEST_F(ReplicaDecoderTest, PlayerConstructionReadsTheCharacterParts) {
EXPECT_EQ((*FieldsOf(*constructed, "CHARACTER"))["statistics"].size(), 27u);
}
// A model's destroyable is not in its registry rows: it is written after the other components
// A model's destroyable is not in its registry rows: it is written after the other components. Its item component is
// in the rows but not made (Entity::Initialize): the model component writes the item info
TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) {
info.lot = 6000;
Entity model(288300744895900200, info);
model.AddComponent<SimplePhysicsComponent>(-1);
auto* const physics = model.AddComponent<SimplePhysicsComponent>(-1);
physics->SetPosition(NiPoint3(-12.0f, 432.96f, -49.6f));
// The model component writes the item's user-generated-content block itself, where the client reads the item
model.AddComponent<ModelComponent>(-1)->LoadBehaviors();
auto* const destroyable = model.AddComponent<DestroyableComponent>(-1);
@@ -157,8 +158,10 @@ TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) {
const auto constructed = session.Decode(Construction(model, 3), 1);
ASSERT_TRUE(constructed);
EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump();
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS ITEM MODEL DESTROYABLE ");
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL DESTROYABLE ");
EXPECT_EQ((*FieldsOf(*constructed, "DESTROYABLE"))["smashable"], true);
EXPECT_EQ((*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"], json::array({ -12.0f, 432.96f, -49.6f }));
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "288300744895900200");
}
// When no layout reads the packet exactly, what read is shown with the rest as bits, never a guess
@@ -366,4 +369,45 @@ TEST_F(ReplicaDecoderTest, ModelBehaviorsOnlyOnPropertyModels) {
EXPECT_EQ(fields->contains("behaviors"), onProperty);
}
info.settings.values.clear();
// A live premade model placed on a property (LOT 12504: simple physics, item, model and render in the registry): its
// config is compressed, and it reads as the server writes a model, with the destroyable after the render
TEST_F(ReplicaDecoderTest, LivePlacedModel) {
const unsigned char live[] = {
0x24, 0xa1, 0x9b, 0xb6, 0xa7, 0x00, 0x00, 0x00, 0x20, 0x00, 0x02, 0x6c, 0x18, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00,
0x5d, 0x40, 0x00, 0x00, 0x00, 0x62, 0x40, 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x1e, 0x27, 0x18, 0xd8, 0x58, 0x18, 0x14,
0x12, 0x59, 0xb2, 0x19, 0xf2, 0x19, 0x4a, 0x00, 0x24, 0x9e, 0x43, 0x2a, 0x50, 0x17, 0x30, 0x84, 0x33, 0xa4, 0x30, 0x64,
0x02, 0x69, 0x14, 0xc6, 0x5c, 0xa0, 0x1d, 0x0c, 0x64, 0x31, 0x72, 0x02, 0x15, 0x0a, 0x65, 0x02, 0xd5, 0x24, 0xc0, 0x85,
0x83, 0x58, 0x6a, 0x01, 0x3a, 0x92, 0x19, 0x58, 0x19, 0x00, 0xa2, 0x8a, 0xc5, 0x0c, 0x05, 0x00, 0x59, 0x50, 0x3f, 0x08,
0xa8, 0x36, 0x92, 0x81, 0x9f, 0xe1, 0x89, 0x21, 0x8b, 0x08, 0x0b, 0xc6, 0x72, 0x3c, 0xd9, 0x1c, 0x18, 0x58, 0x19, 0xc5,
0x0a, 0xc1, 0x46, 0x80, 0xe4, 0xbd, 0xa1, 0x46, 0xc0, 0x64, 0xb8, 0x2f, 0x18, 0x70, 0x00, 0x01, 0x80, 0x1b, 0xc4, 0xc5,
0xde, 0x10, 0x63, 0x1c, 0x85, 0xc0, 0x10, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x08, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x81, 0x83, 0xc2, 0xf5, 0xb0, 0x86, 0xce, 0xcc, 0x8d, 0x84, 0x00,
0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7e, 0x00, 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7f, 0xd8, 0x30, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x08, 0x18, 0x3c, 0x2f, 0x5b, 0x08, 0x6c,
0xec, 0xc8, 0xd8, 0x5e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x1e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x10,
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x07, 0xe0, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xf0, 0x80,
};
const ReplicaDecoder::ComponentTable table{ { 12504, { RENDER, SIMPLE_PHYSICS, ITEM, MODEL } } };
ReplicaDecoder::Session session(table);
const auto constructed = session.Decode(std::string(reinterpret_cast<const char*>(live), sizeof(live)), 1);
ASSERT_TRUE(constructed);
EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump();
EXPECT_EQ((*constructed)["lot"], 12504);
EXPECT_EQ((*constructed)["objectID"], "288300744895909485");
EXPECT_EQ((*constructed)["spawner"], "1152921510458437894");
const auto config = (*constructed)["config"].dump();
EXPECT_NE(config.find("userModelID=9:12504"), std::string::npos) << config;
EXPECT_NE(config.find("propertyObjectID=7:1"), std::string::npos) << config;
EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL RENDER DESTROYABLE ");
const auto& position = (*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"];
EXPECT_FLOAT_EQ(position[0].get<float>(), -12.0f);
EXPECT_NEAR(position[1].get<float>(), 432.96f, 0.001f);
EXPECT_NEAR(position[2].get<float>(), -49.6f, 0.001f);
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "12504");
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["behaviors"], 0);
}

View File

@@ -119,4 +119,7 @@ if(NODE_EXECUTABLE)
# Following a player across worlds in World 3D (live and capture playback) and the timeline's world markers (static/js/world3d-core.js)
add_test(NAME World3dFollowJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-follow.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js")
# A property in a capture replay: its models at the playhead, its data then, and its behavior ticks (static/js/world3d-core.js)
add_test(NAME World3dPropertyJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-property.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js")
endif()

View File

@@ -0,0 +1,63 @@
// A property in a capture replay (static/js/world3d-core.js): which models stand where at the playhead, the property
// data then, which property world the zone on screen shows, and the behavior ticks.
// Run by ctest: node world3d-property.test.mjs <world3d-core.js>
import { pathToFileURL } from 'node:url';
const [modulePath] = process.argv.slice(2);
const W = await import(pathToFileURL(modulePath).href);
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
// The property route's shape: one model there from the start that moves at 5s, one placed at 4s and picked up at 8s
const world = {
zone: 1150, instance: 7, clone: 42,
info: [{ t: 1, name: 'Old name', reputation: 10 }, { t: 9, name: 'New name', reputation: 25 }],
counts: [{ t: 1, count: 1 }, { t: 4, count: 2 }, { t: 8, count: 1 }],
models: [
{ object: '1', lot: 14, spans: [{ from: 2, to: 5, position: [1, 2, 3] }, { from: 5, to: null, position: [5, 2, 5] }] },
{ object: '2', lot: 14, spans: [{ from: 4, to: 8, position: [10, 2, -4] }] }
],
events: [
{ kind: 'placed', t: 3, object: '2' },
{ kind: 'behavior', t: 6, message: 'PLAY_BEHAVIOR_SOUND', object: '1', lot: 14 },
{ kind: 'removed', t: 8, object: '2', reason: 'picked up' }
]
};
const at = (t) => {
const p = W.propertyAt(world, t);
return { models: p.models.map((m) => m.model.object + '@' + m.span.position.join(',')), name: p.info && p.info.name, count: p.count && p.count.count };
};
same(at(0), { models: [], name: null, count: null }, 'before anything was captured');
same(at(2), { models: ['1@1,2,3'], name: 'Old name', count: 1 }, 'the model already there');
same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the placed model appears');
same(at(5), { models: ['1@5,2,5', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the first one moved (a span ends where the next begins)');
same(at(8), { models: ['1@5,2,5'], name: 'Old name', count: 1 }, 'the second one was picked up');
same(at(20), { models: ['1@5,2,5'], name: 'New name', count: 1 }, 'still there at the end, renamed');
// Seeking back gives the same as playing forward
same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'seeking back');
same(W.propertyAt(null, 3), { models: [], info: null, count: null }, 'no property');
// Which world: the followed character's, else the first of the zone
const other = { ...world, instance: 9, clone: 43 };
const moves = [{ character: 'c', t: 0, zone: 1150, instance: 9, clone: 43 }, { character: 'c', t: 10, zone: 1150, instance: 7, clone: 42 }];
same(W.propertyWorldFor([world, other], 1150, moves, 'c', 5).clone, 43, 'the followed character\'s property');
same(W.propertyWorldFor([world, other], 1150, moves, 'c', 11).clone, 42, 'they moved to another property');
same(W.propertyWorldFor([world, other], 1150, moves, null, 5).clone, 42, 'nobody followed: the first');
same(W.propertyWorldFor([world, other], 1100, moves, 'c', 5), null, 'not a property zone');
// Behavior ticks
same(W.behaviorMarkers(world, 12), [{ t: 6, at: 0.5, title: 'PLAY_BEHAVIOR_SOUND (LOT 14)' }], 'one behavior tick');
const html = W.behaviorMarkersHtml(W.behaviorMarkers(world, 12), (s) => String(s).replace(/</g, '&lt;'));
same(html.includes('data-t="6"') && html.includes('left:50.000%'), true, 'the tick seeks to its message');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('world3d property: ok');