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 <cur> 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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-02 15:33:35 -05:00
parent 0c298efe4d
commit 529bacd077
10 changed files with 190 additions and 0 deletions

View File

@@ -618,6 +618,11 @@ void MissionComponent::SetMissionTypeState(const std::string& type, const std::s
m_MissionTypeStates[type][subtype] = static_cast<eMissionLockState>(static_cast<int8_t>(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)) {

View File

@@ -195,6 +195,10 @@ public:
using MissionTypeStates = std::map<std::string, std::map<std::string, eMissionLockState>>;
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);

View File

@@ -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<Entry>& Entries() {
{ MessageType::Game::NOTIFY_CLIENT_FLAG_CHANGE, eDirection::TO_CLIENT, "NotifyClientFlagChange", &ReadWith<GameMessages::NotifyClientFlagChange> },
{ MessageType::Game::HAS_BEEN_COLLECTED, eDirection::TO_SERVER, "HasBeenCollected", &ReadWith<GameMessages::HasBeenCollected> },
{ MessageType::Game::SET_MISSION_TYPE_STATE, eDirection::TO_SERVER, "SetMissionTypeState", &ReadWith<GameMessages::SetMissionTypeState> },
{ MessageType::Game::SET_MISSION_VIEWED, eDirection::TO_SERVER, "SetMissionViewed", &ReadWith<GameMessages::SetMissionViewed> },
{ MessageType::Game::NOTIFY_LEVEL_REWARDS, eDirection::TO_CLIENT, "NotifyLevelRewards", &ReadWith<GameMessages::NotifyLevelRewards> },
{ MessageType::Game::TELEPORT, eDirection::TO_CLIENT, "Teleport", &ReadWith<GameMessages::Teleport> },
{ MessageType::Game::START_PATHING, eDirection::TO_CLIENT, "StartPathing", &ReadWith<GameMessages::StartPathing> },

View File

@@ -82,6 +82,7 @@ namespace {
{ SET_FLAG, []() { return std::make_unique<SetFlag>(); } },
{ HAS_BEEN_COLLECTED, []() { return std::make_unique<HasBeenCollected>(); } },
{ SET_MISSION_TYPE_STATE, []() { return std::make_unique<SetMissionTypeState>(); } },
{ SET_MISSION_VIEWED, []() { return std::make_unique<SetMissionViewed>(); } },
// Effects, emotes, cinematics, UI
{ PLAY_EMOTE, []() { return std::make_unique<PlayEmote>(); } },

View File

@@ -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<MissionComponent>();
if (missionComponent) missionComponent->SetMissionViewed(missionID, viewed);
}
void NotifyLevelRewards::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(level);
bitStream.Write(sendingRewards);

View File

@@ -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 <cur> 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) {}

View File

@@ -1,6 +1,9 @@
#include "Mission.h"
#include "ChatServerLink.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <ctime>
#include "CDClientManager.h"
@@ -37,6 +40,17 @@
namespace {
std::set<uint32_t> 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<char>(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<unsigned int>(task->GetProgress()));

View File

@@ -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 <cur> 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

View File

@@ -15,6 +15,7 @@ set(DCOMPONENTS_TESTS
"CharacterSaveFieldsTests.cpp"
"CollectibleComponentTests.cpp"
"MissionOfferTests.cpp"
"MissionClientRulesTests.cpp"
"ReplicaComponentOrderTests.cpp"
"QuickBuildCompleteTests.cpp"
"RocketLaunchTests.cpp"

View File

@@ -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 <gtest/gtest.h>
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<CDMissionsTable>();
for (const auto id : { VIEWED, NOT_VIEWED, FINISHED }) missions.push_back(Row(id));
}
void TearDown() override {
CDClientManager::GetEntriesMutable<CDMissionsTable>().clear();
TearDownDependencies();
}
static void Load(MissionComponent& missions, const std::string& done, const std::string& cur) {
tinyxml2::XMLDocument doc;
const auto xml = R"(<obj v="1"><mis><done>)" + done + "</done><cur>" + cur + "</cur></mis></obj>";
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"(<obj v="1"/>)");
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<MissionComponent>(-1);
// Saves from before the flag have no v; the client reads a number or "true"
Load(*missions, R"(<m id="992003" state="8" cct="1" cts="1000"/>)",
R"(<m id="992001" state="2" v="true"/><m id="992002" state="2"/>)");
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("<done>"), saved.find("<cur>") - saved.find("<done>"));
EXPECT_EQ(done.find(" v="), std::string::npos) << saved;
// And back
Entity reloaded(43, info);
auto* const again = reloaded.AddComponent<MissionComponent>(-1);
Load(*again, "", saved.substr(saved.find("<cur>") + 5, saved.find("</cur>") - saved.find("<cur>") - 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);
}