feat(collectibles): gate collectibles on CollectibleComponent.requirement_mission

CollectibleComponent (79 rows: id, requirement_mission) is only in the
CDClient for the server: the 1.10.64 client has no string for it. DLU
never read it.

What the column holds (1.10.64 CDClient, joined with ComponentsRegistry,
Missions and MissionTasks): for 63 rows it is the mission the collectible
belongs to. Mostly that is the achievement whose collection task
(taskType 3) targets the collectible's LOT (the flags, imagination bricks,
Johnny Thunder collectibles). For a few it is a mission to accept from an
NPC that does not collect the item itself: the four Ninjago dragon relics
(16483-16485) are collected by the hidden achievements 2064-2067 but
their requirement is 2040 (accepted from LOT 13789, "complete 2064-2067").
Other values: -1 and 66666666 (no such mission) on test rows.

So a collectible whose requirement_mission is a mission to accept
(Missions.isMission) now only counts (HasBeenCollected progresses
nothing) while the player has that mission accepted and not handed in
(ACTIVE, READY_TO_COMPLETE or their repeat states). Achievements, missing
missions and rows without one are unchanged. Without this, a player who
had not accepted 2040 could collect the relics early and have 2040
complete as soon as it was accepted. The gate itself is inferred from the
data: the captures show collections but not the live server's check.

The collectible's object report shows the requirement mission.

Check in game: in Ninjago Monastery, before accepting the dragon relic
mission (2040), touch a dragon relic: it does not count; accept 2040 and
collect them: each counts and 2040 completes after the fourth. Flags,
imagination bricks and Johnny Thunder collectibles still count as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 03:36:14 -05:00
parent 8a8fc263e1
commit 01cc349e76
9 changed files with 183 additions and 0 deletions

View File

@@ -22,6 +22,7 @@
#include "CDObjectsTable.h"
#include "CDPhysicsComponentTable.h"
#include "CDRebuildComponentTable.h"
#include "CDCollectibleComponentTable.h"
#include "CDScriptComponentTable.h"
#include "CDSkillBehaviorTable.h"
#include "CDZoneTableTable.h"
@@ -100,6 +101,7 @@ DEFINE_TABLE_STORAGE(CDModularBuildComponentTable);
DEFINE_TABLE_STORAGE(CDRailActivatorComponentTable);
DEFINE_TABLE_STORAGE(CDRarityTableTable);
DEFINE_TABLE_STORAGE(CDRebuildComponentTable);
DEFINE_TABLE_STORAGE(CDCollectibleComponentTable);
DEFINE_TABLE_STORAGE(CDRewardCodesTable);
DEFINE_TABLE_STORAGE(CDRewardsTable);
DEFINE_TABLE_STORAGE(CDScriptComponentTable);
@@ -118,6 +120,7 @@ void CDClientManager::LoadValuesFromDatabase() {
CDBehaviorParameterTable::Instance().LoadValuesFromDatabase();
CDBehaviorTemplateTable::Instance().LoadValuesFromDatabase();
CDBrickIDTableTable::Instance().LoadValuesFromDatabase();
CDCollectibleComponentTable::Instance().LoadValuesFromDatabase();
CDCLIENT_DONT_CACHE_TABLE(CDComponentsRegistryTable::Instance().LoadValuesFromDatabase());
CDCurrencyTableTable::Instance().LoadValuesFromDatabase();
CDDeletionRestrictionsTable::Instance().LoadValuesFromDatabase();

View File

@@ -0,0 +1,20 @@
#include "CDCollectibleComponentTable.h"
void CDCollectibleComponentTable::LoadValuesFromDatabase() {
auto& entries = GetEntriesMutable();
auto tableData = CDClientDatabase::ExecuteQuery("SELECT * FROM CollectibleComponent");
while (!tableData.eof()) {
CDCollectibleComponent entry;
entry.id = tableData.getIntField("id", -1);
entry.requirementMission = tableData.getIntField("requirement_mission", -1);
entries.insert_or_assign(entry.id, entry);
tableData.nextRow();
}
tableData.finalize();
}
const CDCollectibleComponent* CDCollectibleComponentTable::GetByID(const int32_t id) const {
const auto& entries = GetEntries();
const auto it = entries.find(id);
return it != entries.end() ? &it->second : nullptr;
}

View File

@@ -0,0 +1,25 @@
#pragma once
#include "CDTable.h"
#include <cstdint>
#include <map>
/**
* CollectibleComponent: read only by the server (the 1.10.64 client has no string for the table).
* requirement_mission is the mission a collectible belongs to, usually the achievement its collection task is in; for
* some it is a mission that must be accepted first (e.g. 2040 for the Ninjago dragon relics, which the hidden
* achievements 2064-2067 collect). -1 when the row has none.
*/
struct CDCollectibleComponent {
int32_t id{};
int32_t requirementMission{ -1 };
};
class CDCollectibleComponentTable : public CDTable<CDCollectibleComponentTable, std::map<int32_t, CDCollectibleComponent>> {
public:
void LoadValuesFromDatabase();
// The row for a component ID, or nothing
[[nodiscard]] const CDCollectibleComponent* GetByID(int32_t id) const;
};

View File

@@ -4,6 +4,7 @@ set(DDATABASE_CDCLIENTDATABASE_CDCLIENTTABLES_SOURCES "CDActivitiesTable.cpp"
"CDBehaviorParameterTable.cpp"
"CDBehaviorTemplateTable.cpp"
"CDBrickIDTableTable.cpp"
"CDCollectibleComponentTable.cpp"
"CDComponentsRegistryTable.cpp"
"CDCurrencyTableTable.cpp"
"CDDestructibleComponentTable.cpp"

View File

@@ -3,10 +3,36 @@
#include "MissionComponent.h"
#include "dServer.h"
#include "Amf3.h"
#include "CDClientManager.h"
#include "CDCollectibleComponentTable.h"
#include "CDMissionsTable.h"
#include "eMissionState.h"
CollectibleComponent::CollectibleComponent(Entity* parentEntity, const int32_t componentID, const int32_t collectibleId) :
Component(parentEntity, componentID), m_CollectibleId(collectibleId) {
RegisterMsg(&CollectibleComponent::MsgGetObjectReportInfo);
const auto* const row = CDClientManager::GetTable<CDCollectibleComponentTable>()->GetByID(componentID);
if (row) m_RequirementMission = row->requirementMission;
}
bool CollectibleComponent::CountsFor(const Entity& player) const {
if (m_RequirementMission <= 0) return true;
bool found = false;
const auto& mission = CDClientManager::GetTable<CDMissionsTable>()->GetByMissionID(m_RequirementMission, found);
// Not a mission (rows with 66666666), or an achievement: nothing to accept first
if (!found || !mission.isMission) return true;
const auto* const missionComponent = player.GetComponent<MissionComponent>();
if (!missionComponent) return false;
switch (missionComponent->GetMissionState(m_RequirementMission)) {
case eMissionState::ACTIVE:
case eMissionState::READY_TO_COMPLETE:
case eMissionState::COMPLETE_ACTIVE:
case eMissionState::COMPLETE_READY_TO_COMPLETE:
return true;
default:
return false;
}
}
void CollectibleComponent::Serialize(RakNet::BitStream& outBitStream, bool isConstruction) {
@@ -20,6 +46,7 @@ bool CollectibleComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReportI
cmptType.PushDebug<AMFIntValue>("Component ID") = GetComponentID();
cmptType.PushDebug<AMFIntValue>("Collectible ID") = GetCollectibleId();
cmptType.PushDebug<AMFIntValue>("Requirement mission") = m_RequirementMission;
cmptType.PushDebug<AMFIntValue>("Mission Tracking ID (for save data)") = collectibleID;
auto* localCharEntity = Game::entityManager->GetEntity(reportMsg.clientID);

View File

@@ -13,8 +13,20 @@ public:
void Serialize(RakNet::BitStream& outBitStream, bool isConstruction) override;
bool MsgGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo);
/**
* Whether a player's collecting it counts. The server-only CollectibleComponent.requirement_mission names the
* mission the collectible belongs to: when that is a mission to accept (Missions.isMission, not an achievement,
* which is always open), the collectible only counts while the player has it accepted and not handed in.
*/
bool CountsFor(const Entity& player) const;
int32_t GetRequirementMission() const { return m_RequirementMission; }
private:
int16_t m_CollectibleId = 0;
// CollectibleComponent.requirement_mission, -1 for none
int32_t m_RequirementMission = -1;
};
#endif //!__COLLECTIBLECOMPONENT__H__

View File

@@ -14,6 +14,7 @@
#include "Game.h"
#include "Logger.h"
#include "Mission.h"
#include "CollectibleComponent.h"
#include "MissionComponent.h"
#include "MissionOfferComponent.h"
#include "EffectsMessages.h"
@@ -242,6 +243,10 @@ namespace GameMessages {
Entity* player = Game::entityManager->GetEntity(playerID);
if (!player || entity.GetCollectibleID() == 0) return;
// Collectibles of a mission to accept only count while the player has it
const auto* const collectible = entity.GetComponent<CollectibleComponent>();
if (collectible && !collectible->CountsFor(*player)) return;
MissionComponent* missionComponent = static_cast<MissionComponent*>(player->GetComponent(eReplicaComponentType::MISSION));
if (missionComponent) {
missionComponent->Progress(eMissionTaskType::COLLECTION, entity.GetLOT(), entity.GetObjectID());

View File

@@ -11,6 +11,7 @@ set(DCOMPONENTS_TESTS
"SimplePhysicsComponentTests.cpp"
"SavingTests.cpp"
"CharacterSaveFieldsTests.cpp"
"CollectibleComponentTests.cpp"
"ReplicaComponentOrderTests.cpp"
)

View File

@@ -0,0 +1,89 @@
// CollectibleComponent.requirement_mission (a server-only table): collectibles of a mission to accept only count while
// the player has that mission.
#include "GameDependencies.h"
#include "GameMessages.h"
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "CDCollectibleComponentTable.h"
#include "CDMissionsTable.h"
#include "CollectibleComponent.h"
#include "Entity.h"
#include "MissionComponent.h"
#include "tinyxml2.h"
#include <gtest/gtest.h>
class CollectibleComponentTests : public GameDependenciesTest {
protected:
// Rows as in the 1.10.64 CDClient: 78 is the Ice Dragon Relic (requirement 2040, a mission to accept), 12 the Pirate
// Flag (requirement 12, an achievement), 45 a test item (66666666, no such mission), 1 none
void SetUp() override {
SetUpDependencies();
CDClientDatabase::Connect(":memory:"); // MissionComponent counts the achievements
CDClientDatabase::ExecuteDML("CREATE TABLE Missions (id INTEGER, isMission INTEGER);");
auto& collectibles = CDClientManager::GetEntriesMutable<CDCollectibleComponentTable>();
collectibles[78] = { 78, 2040 };
collectibles[12] = { 12, 12 };
collectibles[45] = { 45, 66666666 };
collectibles[1] = { 1, -1 };
auto& missions = CDClientManager::GetEntriesMutable<CDMissionsTable>();
CDMissions relics{};
relics.id = 2040;
relics.isMission = true;
missions.push_back(relics);
CDMissions flags{};
flags.id = 12;
flags.isMission = false;
missions.push_back(flags);
}
void TearDown() override {
CDClientManager::GetEntriesMutable<CDCollectibleComponentTable>().clear();
CDClientManager::GetEntriesMutable<CDMissionsTable>().clear();
TearDownDependencies();
}
static void GiveMission(MissionComponent& missions, const char* state) {
tinyxml2::XMLDocument doc;
const std::string xml = std::string(R"(<obj v="1"><mis><done/><cur><m id="2040" state=")") + state + R"("/></cur></mis></obj>)";
ASSERT_EQ(doc.Parse(xml.c_str()), tinyxml2::XML_SUCCESS);
missions.LoadFromXml(doc);
}
};
TEST_F(CollectibleComponentTests, RequirementMissionIsRead) {
Entity object(40, info);
EXPECT_EQ(object.AddComponent<CollectibleComponent>(78, 5)->GetRequirementMission(), 2040);
Entity other(41, info);
EXPECT_EQ(other.AddComponent<CollectibleComponent>(999, 5)->GetRequirementMission(), -1); // no row
}
TEST_F(CollectibleComponentTests, AchievementsAndMissingMissionsAlwaysCount) {
Entity player(42, info);
player.AddComponent<MissionComponent>(-1);
for (const int32_t component : { 12, 45, 1, 999 }) {
Entity object(43, info);
EXPECT_TRUE(object.AddComponent<CollectibleComponent>(component, 5)->CountsFor(player)) << component;
}
}
TEST_F(CollectibleComponentTests, MissionCollectiblesCountOnlyWhileTheMissionIsAccepted) {
Entity relic(44, info);
const auto* const collectible = relic.AddComponent<CollectibleComponent>(78, 5);
Entity player(45, info);
auto* const missions = player.AddComponent<MissionComponent>(-1);
EXPECT_FALSE(collectible->CountsFor(player)); // never offered
GiveMission(*missions, "2"); // ACTIVE
EXPECT_TRUE(collectible->CountsFor(player));
GiveMission(*missions, "10"); // COMPLETE_ACTIVE (a repeat)
EXPECT_TRUE(collectible->CountsFor(player));
GiveMission(*missions, "1"); // AVAILABLE: offered, not accepted
EXPECT_FALSE(collectible->CountsFor(player));
Entity noMissions(46, info);
EXPECT_FALSE(collectible->CountsFor(noMissions));
}