From 73fbfa840ab8c6288dccf0dd2778390c0f051149 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:16:39 -0500 Subject: [PATCH] feat(stats): zone statistics for quickbuilds and achievements reach the client Live sent the player ModifyPlayerZoneStatistic(set false, value 1, the current map) for "QuickBuildsCompleted" (227) after a quickbuild's EnableRebuild and for "AchievementsCompleted" (186) at the end of an achievement's completion. DLU counted both server side (they are saved and shown at the next load) but never told the client, so the zone summary only caught up after a zone change. The client counts enemies, coins and bricks itself and sends those to the server. Check: complete a quickbuild and an achievement, then open the zone summary (passport / map zone stats) without changing zones: both counts went up by one. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/CharacterComponent.cpp | 11 ++++++ dGame/dComponents/CharacterComponent.h | 7 ++++ dGame/dComponents/QuickBuildComponent.cpp | 1 + dGame/dMission/Mission.cpp | 5 +++ .../QuickBuildCompleteTests.cpp | 35 +++++++++++++++++++ 5 files changed, 59 insertions(+) diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index d8d07bb67..6d7eb5fe9 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -13,6 +13,7 @@ #include "HavokVehiclePhysicsComponent.h" #include "GameMessages.h" #include "EffectsMessages.h" +#include "PlayerMessages.h" #include "Item.h" #include "Amf3.h" #include "eGameMasterLevel.h" @@ -550,6 +551,16 @@ void CharacterComponent::TrackMissionCompletion(bool isAchievement) { } } +void CharacterComponent::SendZoneStatisticIncrement(const std::u16string& statName) const { + GameMessages::ModifyPlayerZoneStatistic statistic; + statistic.target = m_Parent->GetObjectID(); + statistic.bSet = false; + statistic.statName = statName; + statistic.statValue = 1; + statistic.zoneID = Game::zoneManager->GetZoneID().GetMapID(); + statistic.SendToClient(m_Parent->GetSystemAddress()); +} + void CharacterComponent::TrackLOTCollection(LOT lot) { const auto statistic = GetPowerUpStatistic(lot); if (statistic) UpdatePlayerStatistic(statistic.value()); diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 05e5b7f4b..78afb19ad 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -194,6 +194,13 @@ public: */ void TrackMissionCompletion(bool isAchievement); + /** + * Tells this player's client one more of a zone statistic in the current zone, as live did for + * "QuickBuildsCompleted" and "AchievementsCompleted" (ModifyPlayerZoneStatistic: not set, value 1, this map). + * The client counts enemies, coins and bricks itself. + */ + void SendZoneStatisticIncrement(const std::u16string& statName) const; + /** * Handles statistics related to collecting heart flags and imagination bricks * @param lot the lot of the object that was collected diff --git a/dGame/dComponents/QuickBuildComponent.cpp b/dGame/dComponents/QuickBuildComponent.cpp index e05811bf1..ab5813c5c 100644 --- a/dGame/dComponents/QuickBuildComponent.cpp +++ b/dGame/dComponents/QuickBuildComponent.cpp @@ -477,6 +477,7 @@ void QuickBuildComponent::CompleteQuickBuild(Entity* const user) { enableRebuild.fDuration = 0.0f; enableRebuild.user = user->GetObjectID(); enableRebuild.Send(UNASSIGNED_SYSTEM_ADDRESS); + characterComponent->SendZoneStatisticIncrement(u"QuickBuildsCompleted"); GameMessages::TerminateInteraction(user->GetObjectID(), eTerminateType::FROM_INTERACTION, m_Parent->GetObjectID()).Send(UNASSIGNED_SYSTEM_ADDRESS); diff --git a/dGame/dMission/Mission.cpp b/dGame/dMission/Mission.cpp index ea853702c..c9e3e2c96 100644 --- a/dGame/dMission/Mission.cpp +++ b/dGame/dMission/Mission.cpp @@ -341,6 +341,11 @@ void Mission::Complete(const bool yieldRewards) { missionComponent->Progress(eMissionTaskType::RACING, info.id, static_cast(eRacingTaskParam::COMPLETE_TRACK_TASKS)); + // Live sent this after the completion and the achievement tasks it progressed (186 in the live captures). + if (characterComponent != nullptr && !info.isMission) { + characterComponent->SendZoneStatisticIncrement(u"AchievementsCompleted"); + } + auto* missionEmailTable = CDClientManager::GetTable(); const auto missionId = GetMissionId(); diff --git a/tests/dGameTests/dComponentsTests/QuickBuildCompleteTests.cpp b/tests/dGameTests/dComponentsTests/QuickBuildCompleteTests.cpp index 7cfad2153..67306007e 100644 --- a/tests/dGameTests/dComponentsTests/QuickBuildCompleteTests.cpp +++ b/tests/dGameTests/dComponentsTests/QuickBuildCompleteTests.cpp @@ -5,10 +5,13 @@ #include "CharacterComponent.h" #include "CDClientDatabase.h" #include "Entity.h" +#include "PlayerMessages.h" +#include "dZoneManager.h" #include "QuickBuildComponent.h" #include "QuickBuildMessages.h" #include "eQuickBuildState.h" +#include #include #include @@ -37,6 +40,7 @@ protected: void SetUp() override { SetUpDependencies(); ConnectEmptyCDClient(); + Game::zoneManager->LoadZone(LWOZONEID(1101, 0, 0)); // the zone of the live samples below auto playerInfo = info; playerInfo.lot = 1; player = std::make_unique(0x1000000000000001LL, playerInfo); @@ -88,3 +92,34 @@ TEST_F(QuickBuildCompleteTests, SuccessHasNoDuration) { EXPECT_EQ(enables[0].fDuration, 0.0f); EXPECT_EQ(enables[0].user, player->GetObjectID()); } + +// Live sends the builder one more QuickBuildsCompleted for this zone after the EnableRebuild. Bytes: a live +// ModifyPlayerZoneStatistic from zone 1101 with the player ID replaced. +TEST_F(QuickBuildCompleteTests, ZoneStatisticFollowsEnableRebuild) { + const auto sent = Build(); + const auto ids = SentGameMessageIds(sent); + const auto enable = std::find(ids.begin(), ids.end(), MessageType::Game::ENABLE_REBUILD); + const auto statistic = std::find(ids.begin(), ids.end(), MessageType::Game::MODIFY_PLAYER_ZONE_STATISTIC); + ASSERT_NE(statistic, ids.end()); + EXPECT_LT(enable, statistic); + + std::vector statistics; + for (const auto& packet : sent) { + if (!SentGameMessages({ packet }).empty()) statistics.push_back(packet); + } + ASSERT_EQ(statistics.size(), 1u); + EXPECT_EQ(statistics[0].sysAddr, ClientAddress()); + EXPECT_FALSE(statistics[0].broadcast); + EXPECT_PACKET_EQ(FromHex( + "53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 16 04 0a 00 00 00 28 80 3a 80 34 80 31 80 35 80 21 00 3a 80 34 80 " + "36 00 32 00 39 80 21 80 37 80 36 80 38 00 36 00 32 80 3a 00 32 80 32 00 40 40 00 00 29 a0 80", 547), FromCapture(statistics[0])); +} + +// The same message for a completed achievement (sent from Mission::Complete). +TEST_F(QuickBuildCompleteTests, AchievementZoneStatisticMatchesLive) { + const auto sent = Capture([&] { player->GetComponent()->SendZoneStatisticIncrement(u"AchievementsCompleted"); }); + ASSERT_EQ(sent.size(), 1u); + EXPECT_PACKET_EQ(FromHex( + "53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 16 04 0a 80 00 00 20 80 31 80 34 00 34 80 32 80 3b 00 32 80 36 80 " + "32 80 37 00 3a 00 39 80 21 80 37 80 36 80 38 00 36 00 32 80 3a 00 32 80 32 00 40 40 00 00 29 a0 80", 563), FromCapture(sent[0])); +}