From 529bacd0773c7da96b1c016b796658e217fbebd7 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 2 Oct 2026 15:33:35 -0500 Subject: [PATCH] feat(missions): keep the viewed flag of missions in progress, as the client The client keeps a viewed flag on each mission in progress, sets it with SetMissionViewed (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0, only for missions in progress) and reads it as v on the mission's entry (LWOMissionComponent::LoadFromSaveData 0x00d171c0, a number or "true", false when missing). The server now handles SetMissionViewed, saves v="1" for a viewed mission in progress and loads it back; saves without v load as not viewed. Co-Authored-By: Claude Opus 5.5 --- .../Character/MissionComponent.cpp | 5 + .../dComponents/Character/MissionComponent.h | 4 + dGame/dGameMessages/GameMessageFields.inc | 8 ++ dGame/dGameMessages/GameMessageHandler.cpp | 1 + dGame/dGameMessages/MissionMessages.cpp | 16 +++ dGame/dGameMessages/MissionMessages.h | 12 ++ dGame/dMission/Mission.cpp | 19 +++ dGame/dMission/Mission.h | 12 ++ .../dComponentsTests/CMakeLists.txt | 1 + .../MissionClientRulesTests.cpp | 112 ++++++++++++++++++ 10 files changed, 190 insertions(+) create mode 100644 tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp diff --git a/dGame/dComponents/Character/MissionComponent.cpp b/dGame/dComponents/Character/MissionComponent.cpp index b7bbf119b..a2810f330 100644 --- a/dGame/dComponents/Character/MissionComponent.cpp +++ b/dGame/dComponents/Character/MissionComponent.cpp @@ -618,6 +618,11 @@ void MissionComponent::SetMissionTypeState(const std::string& type, const std::s m_MissionTypeStates[type][subtype] = static_cast(static_cast(state)); } +void MissionComponent::SetMissionViewed(const uint32_t missionId, const bool viewed) { + auto* const mission = GetMission(missionId); + if (mission && !mission->IsComplete()) mission->SetViewed(viewed); +} + void MissionComponent::AddCollectible(int32_t collectibleID) { // Check if this collectible is already in the list if (HasCollectible(collectibleID)) { diff --git a/dGame/dComponents/Character/MissionComponent.h b/dGame/dComponents/Character/MissionComponent.h index 3e0d60756..a2cdc5381 100644 --- a/dGame/dComponents/Character/MissionComponent.h +++ b/dGame/dComponents/Character/MissionComponent.h @@ -195,6 +195,10 @@ public: using MissionTypeStates = std::map>; void SetMissionTypeState(const std::string& type, const std::string& subtype, eMissionLockState state); const MissionTypeStates& GetMissionTypeStates() const { return m_MissionTypeStates; } + + // Marks a mission in progress as viewed or not (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0); missions not + // in progress are left alone, as the client + void SetMissionViewed(uint32_t missionId, bool viewed); private: bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo); bool OnGetMissionState(GameMessages::GetMissionState& getMissionState); diff --git a/dGame/dGameMessages/GameMessageFields.inc b/dGame/dGameMessages/GameMessageFields.inc index cc7c5d05c..14a7fcfc4 100644 --- a/dGame/dGameMessages/GameMessageFields.inc +++ b/dGame/dGameMessages/GameMessageFields.inc @@ -130,6 +130,7 @@ json ToJson(const GameMessages::SetFlag& m); json ToJson(const GameMessages::NotifyClientFlagChange& m); json ToJson(const GameMessages::HasBeenCollected& m); json ToJson(const GameMessages::SetMissionTypeState& m); +json ToJson(const GameMessages::SetMissionViewed& m); json ToJson(const GameMessages::NotifyLevelRewards& m); json ToJson(const GameMessages::Teleport& m); json ToJson(const GameMessages::StartPathing& m); @@ -1319,6 +1320,12 @@ json ToJson(const GameMessages::SetMissionTypeState& m) { j["type"] = ToJson(m.type); return j; } +json ToJson(const GameMessages::SetMissionViewed& m) { + json j = json::object(); + j["missionID"] = ToJson(m.missionID); + j["viewed"] = ToJson(m.viewed); + return j; +} json ToJson(const GameMessages::NotifyLevelRewards& m) { json j = json::object(); j["level"] = ToJson(m.level); @@ -2643,6 +2650,7 @@ const std::vector& Entries() { { MessageType::Game::NOTIFY_CLIENT_FLAG_CHANGE, eDirection::TO_CLIENT, "NotifyClientFlagChange", &ReadWith }, { MessageType::Game::HAS_BEEN_COLLECTED, eDirection::TO_SERVER, "HasBeenCollected", &ReadWith }, { MessageType::Game::SET_MISSION_TYPE_STATE, eDirection::TO_SERVER, "SetMissionTypeState", &ReadWith }, + { MessageType::Game::SET_MISSION_VIEWED, eDirection::TO_SERVER, "SetMissionViewed", &ReadWith }, { MessageType::Game::NOTIFY_LEVEL_REWARDS, eDirection::TO_CLIENT, "NotifyLevelRewards", &ReadWith }, { MessageType::Game::TELEPORT, eDirection::TO_CLIENT, "Teleport", &ReadWith }, { MessageType::Game::START_PATHING, eDirection::TO_CLIENT, "StartPathing", &ReadWith }, diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index 0f61fe4c9..6f3d16a03 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -82,6 +82,7 @@ namespace { { SET_FLAG, []() { return std::make_unique(); } }, { HAS_BEEN_COLLECTED, []() { return std::make_unique(); } }, { SET_MISSION_TYPE_STATE, []() { return std::make_unique(); } }, + { SET_MISSION_VIEWED, []() { 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 c824e4b6c..7daaa8c35 100644 --- a/dGame/dGameMessages/MissionMessages.cpp +++ b/dGame/dGameMessages/MissionMessages.cpp @@ -273,6 +273,22 @@ namespace GameMessages { if (missionComponent) missionComponent->SetMissionTypeState(type, subtype, state); } + void SetMissionViewed::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(missionID); + bitStream.Write(viewed); + } + + bool SetMissionViewed::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(missionID)); + VALIDATE_READ(bitStream.Read(viewed)); + return true; + } + + void SetMissionViewed::Handle(Entity& entity, const SystemAddress& sysAddr) { + auto* const missionComponent = entity.GetComponent(); + if (missionComponent) missionComponent->SetMissionViewed(missionID, viewed); + } + 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 8797a8836..7577714af 100644 --- a/dGame/dGameMessages/MissionMessages.h +++ b/dGame/dGameMessages/MissionMessages.h @@ -149,6 +149,18 @@ namespace GameMessages { std::string type{}; }; + // Client -> server. The player looked at a mission in progress (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0); + // the server keeps it and saves it as v on the mission's entry, which the client reads on load. + struct SetMissionViewed : public NetGameMsg { + SetMissionViewed() : NetGameMsg(MessageType::Game::SET_MISSION_VIEWED) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + int32_t missionID{}; + bool viewed{}; + }; + // 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 8f5a6a9ef..6f4c77833 100644 --- a/dGame/dMission/Mission.cpp +++ b/dGame/dMission/Mission.cpp @@ -1,6 +1,9 @@ #include "Mission.h" #include "ChatServerLink.h" +#include +#include +#include #include #include "CDClientManager.h" @@ -37,6 +40,17 @@ namespace { std::set g_TestedMissions = { 773, 774, 775, 776, 777 }; // TODO Figure out why these missions are broken sometimes + + // The client's save data bool: a non-zero number or "true" in any case + bool ReadSaveBool(const tinyxml2::XMLElement& element, const char* const name) { + const char* const value = element.Attribute(name); + if (!value) return false; + if (std::strtol(value, nullptr, 10) != 0) return true; + + std::string text = value; + std::ranges::transform(text, text.begin(), [](const unsigned char c) { return static_cast(std::tolower(c)); }); + return text == "true"; + } } Mission::Mission(MissionComponent* missionComponent, const uint32_t missionId) { @@ -94,6 +108,9 @@ void Mission::LoadFromXmlCur(const tinyxml2::XMLElement& element) { } // End custom XML + // LWOMissionComponent::LoadFromSaveData (0x00d171c0): v is the viewed flag, false when missing + m_Viewed = ReadSaveBool(element, "v"); + auto* task = element.FirstChildElement(); auto index = 0U; @@ -164,6 +181,8 @@ void Mission::UpdateXmlCur(tinyxml2::XMLElement& element) { if (IsComplete()) return; + if (m_Viewed) element.SetAttribute("v", 1); + for (const auto* const task : m_Tasks) { auto* const child = element.InsertNewChildElement("sv"); child->SetAttribute("v", static_cast(task->GetProgress())); diff --git a/dGame/dMission/Mission.h b/dGame/dMission/Mission.h index edd21d093..f3149ad84 100644 --- a/dGame/dMission/Mission.h +++ b/dGame/dMission/Mission.h @@ -246,6 +246,13 @@ public: bool IsFailed() const; + /** + * Whether the player has looked at this mission in progress (LWOMissionComponent::msgSetMissionViewed 0x00bec7a0), + * saved as v on the mission's entry + */ + bool GetViewed() const { return m_Viewed; } + void SetViewed(const bool viewed) { m_Viewed = viewed; } + private: /** * Progresses all the newly accepted tasks for this mission after it has been accepted to reflect the state of the @@ -292,6 +299,11 @@ private: * The unique ID what order this mission was accepted in. */ uint32_t m_UniqueMissionID; + + /** + * Whether the player has looked at this mission in progress + */ + bool m_Viewed{ false }; }; #endif diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index f1307c24f..9375d59da 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -15,6 +15,7 @@ set(DCOMPONENTS_TESTS "CharacterSaveFieldsTests.cpp" "CollectibleComponentTests.cpp" "MissionOfferTests.cpp" + "MissionClientRulesTests.cpp" "ReplicaComponentOrderTests.cpp" "QuickBuildCompleteTests.cpp" "RocketLaunchTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp b/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp new file mode 100644 index 000000000..d2b86896d --- /dev/null +++ b/tests/dGameTests/dComponentsTests/MissionClientRulesTests.cpp @@ -0,0 +1,112 @@ +// Mission rules the 1.10.64 client works by (LWOMissionComponent): the viewed flag, prerequisites, cooldowns and +// turning a mission in. +#include "GameDependencies.h" + +#include "CDClientDatabase.h" +#include "CDClientManager.h" +#include "CDMissionsTable.h" +#include "Entity.h" +#include "GameMessages.h" +#include "Mission.h" +#include "MissionComponent.h" +#include "MissionMessages.h" +#include "eMissionState.h" + +#include "tinyxml2.h" + +#include + +namespace { + // Mission IDs only these tests use (the prerequisite cache is static) + constexpr uint32_t VIEWED = 992001; + constexpr uint32_t NOT_VIEWED = 992002; + constexpr uint32_t FINISHED = 992003; + + CDMissions Row(const uint32_t id, const bool repeatable = false, const std::string& prereq = "", const int64_t cooldown = -1) { + CDMissions row{}; + row.id = id; + row.isMission = true; + row.repeatable = repeatable; + row.prereqMissionID = prereq; + row.cooldownTime = cooldown; + return row; + } +} + +class MissionClientRulesTests : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements + CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);"); + auto& missions = CDClientManager::GetEntriesMutable(); + for (const auto id : { VIEWED, NOT_VIEWED, FINISHED }) missions.push_back(Row(id)); + } + + void TearDown() override { + CDClientManager::GetEntriesMutable().clear(); + TearDownDependencies(); + } + + static void Load(MissionComponent& missions, const std::string& done, const std::string& cur) { + tinyxml2::XMLDocument doc; + const auto xml = R"()" + done + "" + cur + ""; + ASSERT_EQ(doc.Parse(xml.c_str()), tinyxml2::XML_SUCCESS); + missions.LoadFromSaveData(doc); + } + + static std::string Save(MissionComponent& missions) { + tinyxml2::XMLDocument doc; + doc.Parse(R"()"); + missions.WriteSaveData(doc); + tinyxml2::XMLPrinter printer(nullptr, true); + doc.Print(&printer); + return printer.CStr(); + } +}; + +TEST_F(MissionClientRulesTests, ViewedFlagIsSavedAndLoaded) { + Entity player(42, info); + auto* const missions = player.AddComponent(-1); + // Saves from before the flag have no v; the client reads a number or "true" + Load(*missions, R"()", + R"()"); + EXPECT_TRUE(missions->GetMission(VIEWED)->GetViewed()); + EXPECT_FALSE(missions->GetMission(NOT_VIEWED)->GetViewed()); + + // SetMissionViewed (0x00bec7a0) only changes missions in progress + GameMessages::SetMissionViewed message; + message.missionID = NOT_VIEWED; + message.viewed = true; + message.Handle(player, UNASSIGNED_SYSTEM_ADDRESS); + missions->SetMissionViewed(FINISHED, true); + EXPECT_TRUE(missions->GetMission(NOT_VIEWED)->GetViewed()); + EXPECT_FALSE(missions->GetMission(FINISHED)->GetViewed()); + + missions->SetMissionViewed(VIEWED, false); + const auto saved = Save(*missions); + EXPECT_NE(saved.find(R"(id="992002" v="1")"), std::string::npos) << saved; + EXPECT_EQ(saved.find(R"(id="992001" v=)"), std::string::npos) << saved; + const auto done = saved.substr(saved.find(""), saved.find("") - saved.find("")); + EXPECT_EQ(done.find(" v="), std::string::npos) << saved; + + // And back + Entity reloaded(43, info); + auto* const again = reloaded.AddComponent(-1); + Load(*again, "", saved.substr(saved.find("") + 5, saved.find("") - saved.find("") - 5)); + EXPECT_TRUE(again->GetMission(NOT_VIEWED)->GetViewed()); + EXPECT_FALSE(again->GetMission(VIEWED)->GetViewed()); +} + +TEST_F(MissionClientRulesTests, SetMissionViewedWire) { + GameMessages::SetMissionViewed out; + out.missionID = 1234; + out.viewed = true; + RakNet::BitStream stream; + out.Serialize(stream); + GameMessages::SetMissionViewed in; + ASSERT_TRUE(in.Deserialize(stream)); + EXPECT_EQ(in.missionID, 1234); + EXPECT_TRUE(in.viewed); + EXPECT_EQ(stream.GetNumberOfBitsUsed(), 33); +}