mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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:
@@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS
|
||||
"RocketLaunchTests.cpp"
|
||||
"ReplicaConstructionTests.cpp"
|
||||
"ReplicaDecoderTests.cpp"
|
||||
"CapturePropertyTests.cpp"
|
||||
"ItemRemovalTests.cpp"
|
||||
"LootMetricsTests.cpp"
|
||||
)
|
||||
|
||||
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal file
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
63
tests/dWebTests/world3d-property.test.mjs
Normal file
63
tests/dWebTests/world3d-property.test.mjs
Normal 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, '<'));
|
||||
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');
|
||||
Reference in New Issue
Block a user