diff --git a/dGame/dComponents/MissionComponent.cpp b/dGame/dComponents/MissionComponent.cpp index 8e1baee1f..be5e460f8 100644 --- a/dGame/dComponents/MissionComponent.cpp +++ b/dGame/dComponents/MissionComponent.cpp @@ -8,6 +8,7 @@ #include #include "MissionComponent.h" +#include "eMissionLockState.h" #include "Logger.h" #include "CDClientManager.h" #include "CDMissionTasksTable.h" @@ -493,6 +494,21 @@ void MissionComponent::LoadFromXml(const tinyxml2::XMLDocument& doc) { m_Missions.insert_or_assign(missionId, mission); } } + + // Live wrote .... The client's loader + // (LWOMissionComponent::LoadFromSaveData 0x00d171c0) reads v from instead of from each , so it files + // every state under one type; the server reads each as it was written. + m_MissionTypeStates.clear(); + const auto* const ts = mis->FirstChildElement("ts"); + if (ts) { + for (const auto* type = ts->FirstChildElement("type"); type; type = type->NextSiblingElement("type")) { + const char* const typeName = type->Attribute("v"); + for (const auto* st = type->FirstChildElement("st"); st; st = st->NextSiblingElement("st")) { + const char* const subtype = st->Attribute("sub"); + SetMissionTypeState(typeName ? typeName : "", subtype ? subtype : "", static_cast(st->IntAttribute("val"))); + } + } + } } @@ -543,11 +559,31 @@ void MissionComponent::UpdateXml(tinyxml2::XMLDocument& doc) { mis->InsertFirstChild(done); mis->InsertEndChild(cur); + // Live wrote after , even when it was empty + auto* const ts = doc.NewElement("ts"); + for (const auto& [type, subtypes] : m_MissionTypeStates) { + auto* const typeElement = doc.NewElement("type"); + typeElement->SetAttribute("v", type.c_str()); + for (const auto& [subtype, state] : subtypes) { + auto* const st = doc.NewElement("st"); + st->SetAttribute("sub", subtype.c_str()); + st->SetAttribute("val", static_cast(state)); + typeElement->LinkEndChild(st); + } + ts->LinkEndChild(typeElement); + } + mis->InsertEndChild(ts); + if (shouldInsertMis) { obj->LinkEndChild(mis); } } +void MissionComponent::SetMissionTypeState(const std::string& type, const std::string& subtype, const eMissionLockState state) { + // The client keeps the state as a byte (LWOMissionComponent::msgSetMissionTypeState 0x00c90d00) + m_MissionTypeStates[type][subtype] = static_cast(static_cast(state)); +} + void MissionComponent::AddCollectible(int32_t collectibleID) { // Check if this collectible is already in the list if (HasCollectible(collectibleID)) { diff --git a/dGame/dComponents/MissionComponent.h b/dGame/dComponents/MissionComponent.h index 8545b171c..1666e86dd 100644 --- a/dGame/dComponents/MissionComponent.h +++ b/dGame/dComponents/MissionComponent.h @@ -6,6 +6,8 @@ #ifndef MISSIONCOMPONENT_H #define MISSIONCOMPONENT_H +#include +#include #include #include #include "dCommonVars.h" @@ -171,6 +173,12 @@ public: void ResetMission(const int32_t missionId); void FixRacingMetaMissions(); + + // The journal state of a mission type and subtype (Missions.defined_type / defined_subtype), as the client sets it + // with SetMissionTypeState. Saved in the charxml: + using MissionTypeStates = std::map>; + void SetMissionTypeState(const std::string& type, const std::string& subtype, eMissionLockState state); + const MissionTypeStates& GetMissionTypeStates() const { return m_MissionTypeStates; } private: bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo); bool OnGetMissionState(GameMessages::GetMissionState& getMissionState); @@ -185,6 +193,8 @@ private: */ std::vector m_Collectibles; + MissionTypeStates m_MissionTypeStates; + /** * For the given parameters, finds the mission IDs of the achievements that may be unlcoked diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index 60a9a079b..7114f456c 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -81,6 +81,7 @@ namespace { { REQUEST_LINKED_MISSION, []() { return std::make_unique(); } }, { SET_FLAG, []() { return std::make_unique(); } }, { HAS_BEEN_COLLECTED, []() { return std::make_unique(); } }, + { SET_MISSION_TYPE_STATE, []() { return std::make_unique(); } }, // Effects, emotes, cinematics, UI { PLAY_EMOTE, []() { return std::make_unique(); } }, diff --git a/dGame/dGameMessages/MissionMessages.cpp b/dGame/dGameMessages/MissionMessages.cpp index 76e5d04aa..c576f4cfc 100644 --- a/dGame/dGameMessages/MissionMessages.cpp +++ b/dGame/dGameMessages/MissionMessages.cpp @@ -4,6 +4,7 @@ #include "Character.h" #include "CppScripts.h" #include "dConfig.h" +#include "eMissionLockState.h" #include "eMissionState.h" #include "eMissionTaskType.h" #include "ePlayerFlag.h" @@ -247,6 +248,26 @@ namespace GameMessages { } } + SetMissionTypeState::SetMissionTypeState() : NetGameMsg(MessageType::Game::SET_MISSION_TYPE_STATE), state{ eMissionLockState::NEW } {} + + void SetMissionTypeState::Serialize(RakNet::BitStream& bitStream) const { + BitStreamUtils::WriteOptional(bitStream, state, eMissionLockState::NEW); + BitStreamUtils::WriteLengthPrefixed(bitStream, subtype); + BitStreamUtils::WriteLengthPrefixed(bitStream, type); + } + + bool SetMissionTypeState::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, state, eMissionLockState::NEW)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, subtype)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, type)); + return true; + } + + void SetMissionTypeState::Handle(Entity& entity, const SystemAddress& sysAddr) { + auto* const missionComponent = entity.GetComponent(); + if (missionComponent) missionComponent->SetMissionTypeState(type, subtype, state); + } + void NotifyLevelRewards::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(level); bitStream.Write(sendingRewards); diff --git a/dGame/dGameMessages/MissionMessages.h b/dGame/dGameMessages/MissionMessages.h index 85b35c94d..8797a8836 100644 --- a/dGame/dGameMessages/MissionMessages.h +++ b/dGame/dGameMessages/MissionMessages.h @@ -6,6 +6,7 @@ #include enum class eMissionState : int; +enum class eMissionLockState : int; // Game messages for missions, player flags, collectibles and level rewards. // Field names follow the client (legouniverse.exe 1.10.64); fields are listed in wire order. @@ -134,6 +135,20 @@ namespace GameMessages { LWOOBJID playerID{}; }; + // Client -> server. LWOMissionComponent sends it when a mission of a journal type/subtype (Missions.defined_type, + // defined_subtype) is offered or updated, to mark that journal tab. Every live sample carried the default state + // (NEW). The server keeps the states and saves them in the charxml (), which the client reads on load. + struct SetMissionTypeState : public NetGameMsg { + SetMissionTypeState(); + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + eMissionLockState state; // optional, default NEW + std::string subtype{}; + std::string type{}; + }; + // Server -> client. struct NotifyLevelRewards : public NetGameMsg { NotifyLevelRewards() : NetGameMsg(MessageType::Game::NOTIFY_LEVEL_REWARDS) {} diff --git a/dGame/dMission/Mission.cpp b/dGame/dMission/Mission.cpp index 8ac8a4adf..b0ec505c2 100644 --- a/dGame/dMission/Mission.cpp +++ b/dGame/dMission/Mission.cpp @@ -646,7 +646,9 @@ void Mission::SetMissionState(const eMissionState state, const bool sendingRewar } void Mission::SetMissionTypeState(eMissionLockState state, const std::string& type, const std::string& subType) { - // TODO + // The client sends its own SetMissionTypeState for the journal; record the same state here so it is saved + // even if the player logs out before the client's message arrives + if (m_MissionComponent) m_MissionComponent->SetMissionTypeState(type, subType, state); } void Mission::SetCompletions(const uint32_t value) { diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 7e06f77cb..e57c616df 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -10,6 +10,7 @@ set(DCOMPONENTS_TESTS "PetComponentTests.cpp" "SimplePhysicsComponentTests.cpp" "SavingTests.cpp" + "CharacterSaveFieldsTests.cpp" "ReplicaComponentOrderTests.cpp" ) diff --git a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp new file mode 100644 index 000000000..1d79d3236 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp @@ -0,0 +1,74 @@ +// Charxml fields the client reads on load that DLU now saves. The formats are the ones live wrote (2011/2012 live +// captures of the charxml the world server sent) and the client reads (legouniverse.exe 1.10.64). +#include "GameDependencies.h" + +#include "CDClientDatabase.h" +#include "Entity.h" +#include "GameMessages.h" +#include "MissionComponent.h" +#include "eMissionLockState.h" + +#include "tinyxml2.h" + +#include + +#include + +namespace { + std::string Print(const tinyxml2::XMLDocument& doc) { + tinyxml2::XMLPrinter printer(nullptr, true); + doc.Print(&printer); + return printer.CStr(); + } + + void Parse(tinyxml2::XMLDocument& doc, const std::string& xml) { + ASSERT_EQ(doc.Parse(xml.c_str()), tinyxml2::XML_SUCCESS); + } +} + +class CharacterSaveFieldsTests : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + } + void TearDown() override { TearDownDependencies(); } +}; + +// : the states of the mission journal tabs, written after the way live wrote them. +TEST_F(CharacterSaveFieldsTests, MissionTypeStatesRoundTrip) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Entity player(20, info); + auto* const missions = player.AddComponent(-1); + missions->LoadFromXml(doc); + const auto& states = missions->GetMissionTypeStates(); + ASSERT_EQ(states.size(), 2); + EXPECT_EQ(states.at("Build").at(""), eMissionLockState::NEW); + EXPECT_EQ(states.at("Location").at("Avant Gardens"), eMissionLockState::NEW); + EXPECT_EQ(states.at("Location").at("Nimbus Station"), eMissionLockState::UNLOCKED); + + missions->SetMissionTypeState("Battle", "General", eMissionLockState::NEW); + missions->UpdateXml(doc); + EXPECT_EQ(Print(doc), R"()"); + + Entity reloaded(21, info); + auto* const reloadedMissions = reloaded.AddComponent(-1); + reloadedMissions->LoadFromXml(doc); + EXPECT_EQ(reloadedMissions->GetMissionTypeStates(), states); +} + +// Saves from before was written load with no states and gain an empty . +TEST_F(CharacterSaveFieldsTests, MissionTypeStatesMissingInOldSave) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Entity player(22, info); + auto* const missions = player.AddComponent(-1); + missions->LoadFromXml(doc); + EXPECT_TRUE(missions->GetMissionTypeStates().empty()); + missions->UpdateXml(doc); + EXPECT_EQ(Print(doc), R"()"); +} diff --git a/tests/dGameTests/dGameMessagesTests/GameMessageTestUtils.h b/tests/dGameTests/dGameMessagesTests/GameMessageTestUtils.h index 4f8dd591e..d384c17fe 100644 --- a/tests/dGameTests/dGameMessagesTests/GameMessageTestUtils.h +++ b/tests/dGameTests/dGameMessagesTests/GameMessageTestUtils.h @@ -97,6 +97,30 @@ namespace GameMessageTestUtils { return msg; } + // Reads a whole client -> server game message packet copied from a live capture (hex; header 53 04 00 05, then the + // object ID and the message ID), requires the message ID to be T's and the payload to be consumed up to the byte + // padding, and requires the struct to write the same payload back. + template + T FromLiveClientCapture(const std::string& hex) { + auto live = FromHex(hex); + EXPECT_GE(live.bytes.size(), 18u); + EXPECT_EQ(std::vector(live.bytes.begin(), live.bytes.begin() + 4), (std::vector{ 0x53, 0x04, 0x00, 0x05 })); + RakNet::BitStream bitStream(live.bytes.data(), live.bytes.size(), false); + bitStream.IgnoreBytes(8); + T msg; + MessageType::Game msgId{}; + EXPECT_TRUE(bitStream.Read(msg.target)); + EXPECT_TRUE(bitStream.Read(msgId)); + EXPECT_EQ(msgId, msg.msgId); + EXPECT_TRUE(msg.Deserialize(bitStream)); + EXPECT_LT(bitStream.GetNumberOfUnreadBits(), 8u); + RakNet::BitStream again; + again.WriteBits(live.bytes.data(), 18 * 8); + msg.Serialize(again); + EXPECT_EQ(FromBitStream(again).bytes, live.bytes); + return msg; + } + // Checks that every strict prefix of msg's serialized payload fails to deserialize. template void ExpectTruncatedFails(const T& msg) { diff --git a/tests/dGameTests/dGameMessagesTests/MissionMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/MissionMessagesTests.cpp index b68976946..a31786fc1 100644 --- a/tests/dGameTests/dGameMessagesTests/MissionMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/MissionMessagesTests.cpp @@ -3,7 +3,10 @@ #include "GameMessageTestUtils.h" #include "Legacy/MissionMessagesLegacy.h" +#include "eMissionLockState.h" #include "eMissionState.h" +#include "MissionComponent.h" +#include "CDClientDatabase.h" #include #include @@ -278,3 +281,47 @@ TEST_F(MissionMessagesTests, MissionDialogueOKWithUnknownResponderDoesNotCrash) ok.Handle(missionGiver, UNASSIGNED_SYSTEM_ADDRESS); SUCCEED(); } + +// Packets from 2011/2012 live captures (object ID replaced): the client's SetMissionTypeState (851). Every live +// sample left the optional state at its default (NEW) and wrote the subtype before the type. +TEST_F(MissionMessagesTests, SetMissionTypeStateMatchesLiveCapture) { + const auto location = FromLiveClientCapture( + "53040005000000000100000000000010" "5303" "0680000020bb30b73a1023b0b93232b739840000002637b1b0ba34b7b700"); + EXPECT_EQ(location.target, 0x1000000000000001LL); + EXPECT_EQ(location.state, eMissionLockState::NEW); + EXPECT_EQ(location.type, "Location"); + EXPECT_EQ(location.subtype, "Avant Gardens"); + + const auto build = FromLiveClientCapture( + "53040005000000000100000000000010" "5303" "0000000002800000213ab4b63200"); + EXPECT_EQ(build.type, "Build"); + EXPECT_EQ(build.subtype, ""); + + GameMessages::SetMissionTypeState unlocked; + unlocked.state = eMissionLockState::UNLOCKED; + unlocked.type = "Battle"; + unlocked.subtype = "General"; + const auto copy = RoundTrip(unlocked); + EXPECT_EQ(copy.state, eMissionLockState::UNLOCKED); + EXPECT_EQ(copy.type, "Battle"); + EXPECT_EQ(copy.subtype, "General"); + ExpectTruncatedFails(unlocked); + ExpectTruncatedFails(location); +} + +// The handler records the state on the player's mission component (saved in ). +TEST_F(MissionMessagesTests, SetMissionTypeStateIsRecorded) { + CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + Entity player(16, info); + auto* const missionComponent = player.AddComponent(-1); + GameMessages::SetMissionTypeState msg; + msg.type = "Location"; + msg.subtype = "Nimbus Station"; + msg.Handle(player, UNASSIGNED_SYSTEM_ADDRESS); + ASSERT_EQ(missionComponent->GetMissionTypeStates().at("Location").at("Nimbus Station"), eMissionLockState::NEW); + + msg.state = eMissionLockState::UNLOCKED; + msg.Handle(player, UNASSIGNED_SYSTEM_ADDRESS); + EXPECT_EQ(missionComponent->GetMissionTypeStates().at("Location").at("Nimbus Station"), eMissionLockState::UNLOCKED); +}