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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 03:16:39 -05:00
parent 08053a1b01
commit 73fbfa840a
5 changed files with 59 additions and 0 deletions

View File

@@ -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());

View File

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

View File

@@ -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);

View File

@@ -341,6 +341,11 @@ void Mission::Complete(const bool yieldRewards) {
missionComponent->Progress(eMissionTaskType::RACING, info.id, static_cast<LWOOBJID>(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<CDMissionEmailTable>();
const auto missionId = GetMissionId();

View File

@@ -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 <algorithm>
#include <memory>
#include <gtest/gtest.h>
@@ -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<Entity>(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<CapturedPacket> statistics;
for (const auto& packet : sent) {
if (!SentGameMessages<GameMessages::ModifyPlayerZoneStatistic>({ 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<CharacterComponent>()->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]));
}