feat(stats): send UpdatePlayerStatistic to the player as live did

Live sent UpdatePlayerStatistic (1481) server -> client for every statistic
the server counted (59,933 messages in the captures, none client -> server);
DLU only counted them, so the passport stayed stale until the next login.
CharacterComponent::UpdatePlayerStatistic now tells the player's client what
it counts (not what a client reported), with the amount left out when it is
1, byte for byte as the live samples.

MetersTraveled: whole meters now count (each position update's distance was
cut to a whole number before, so short steps never counted) and go to the
client once 25 have gathered (live's values were nearly all 25-33 while
moving) and the rest, 0 included, when the player is taken down on leaving
the world (live sent one more after TRANSFER_TO_WORLD). DistanceDriven goes
out every 10 seconds of racing (inferred: live sent it every few hundred
packets, about 1300 units at race speed).

Check in game: open the passport's statistics page, walk around, collect a
power-up, complete a mission; the numbers go up without relogging. Walk
25+ meters; Meters Traveled matches what the passport shows after
relogging. Change worlds and relog: the meters were kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 02:11:41 -05:00
parent 5b1be32e06
commit 752f079100
6 changed files with 196 additions and 4 deletions

View File

@@ -33,7 +33,9 @@
#include "ZoneInstanceManager.h"
#include "ClientPackets.h"
#include "MessageType/Game.h"
#include "PlayerMessages.h"
#include <ctime>
#include <cmath>
#include <ranges>
CharacterComponent::CharacterComponent(Entity* parent, const int32_t componentID, Character* character, const SystemAddress& systemAddress) : Component(parent, componentID) {
@@ -619,14 +621,52 @@ void CharacterComponent::TrackRaceCompleted(bool won) {
void CharacterComponent::TrackPositionUpdate(const NiPoint3& newPosition) {
const auto distance = NiPoint3::Distance(newPosition, m_Parent->GetPosition());
if (!std::isfinite(distance)) return;
// Whole meters count at once; the parts of a meter add up
auto& remainder = m_IsRacing ? m_DrivenRemainder : m_MetersRemainder;
remainder += distance;
const auto meters = static_cast<uint64_t>(remainder);
remainder -= static_cast<float>(meters);
if (m_IsRacing) {
UpdatePlayerStatistic(DistanceDriven, static_cast<uint64_t>(distance));
const auto now = std::chrono::steady_clock::now();
if (m_LastDrivenUpdate != std::chrono::steady_clock::time_point{}) m_DrivenSinceSend += std::chrono::duration<float>(now - m_LastDrivenUpdate).count();
m_LastDrivenUpdate = now;
if (meters > 0) UpdatePlayerStatistic(DistanceDriven, meters);
m_UnsentDriven += meters;
if (m_DrivenSinceSend >= DISTANCE_DRIVEN_INTERVAL) {
SendPlayerStatistic(DistanceDriven, m_UnsentDriven);
m_UnsentDriven = 0;
m_DrivenSinceSend = 0.0f;
}
} else {
UpdatePlayerStatistic(MetersTraveled, static_cast<uint64_t>(distance));
if (meters > 0) UpdatePlayerStatistic(MetersTraveled, meters);
m_UnsentMeters += meters;
if (m_UnsentMeters >= METERS_TRAVELED_BATCH) {
SendPlayerStatistic(MetersTraveled, m_UnsentMeters);
m_UnsentMeters = 0;
}
}
}
void CharacterComponent::FlushMovementStatistics() {
if (m_UnsentDriven > 0) SendPlayerStatistic(DistanceDriven, m_UnsentDriven);
SendPlayerStatistic(MetersTraveled, m_UnsentMeters);
m_UnsentDriven = 0;
m_UnsentMeters = 0;
m_DrivenSinceSend = 0.0f;
}
void CharacterComponent::SendPlayerStatistic(StatisticID updateID, uint64_t updateValue) const {
if (m_SystemAddress == UNASSIGNED_SYSTEM_ADDRESS) return;
GameMessages::UpdatePlayerStatistic statistic;
statistic.target = m_Parent->GetObjectID();
statistic.updateID = static_cast<int32_t>(updateID);
statistic.updateValue = static_cast<int64_t>(updateValue);
statistic.SendToClient(m_SystemAddress);
}
void CharacterComponent::HandleZoneStatisticsUpdate(LWOMAPID zoneID, const std::u16string& name, int32_t value) {
auto zoneStatistics = &GetZoneStatisticsForMap(zoneID);
@@ -730,6 +770,9 @@ void CharacterComponent::UpdatePlayerStatistic(StatisticID updateID, uint64_t up
default:
break;
}
// Live told the client at once; the meters go out in batches (TrackPositionUpdate)
if (!fromClient && updateID != MetersTraveled && updateID != DistanceDriven) SendPlayerStatistic(updateID, updateValue);
}
void CharacterComponent::InitializeStatisticsFromString(const std::string& statisticsString) {

View File

@@ -14,6 +14,7 @@
#include "Loot.h"
#include "StatisticID.h"
#include <optional>
#include <chrono>
enum class eGameActivity : uint32_t;
@@ -228,10 +229,24 @@ public:
void TrackRaceCompleted(bool won);
/**
* Tracks an updated position for a player
* Tracks an updated position for a player: MetersTraveled (DistanceDriven while racing). The whole meters count at
* once; the client is told in batches, as live did (MetersTraveled once METERS_TRAVELED_BATCH have gathered,
* DistanceDriven every DISTANCE_DRIVEN_INTERVAL seconds), and the rest when the player leaves the world
* (FlushMovementStatistics).
*/
void TrackPositionUpdate(const NiPoint3& newPosition);
/**
* Tells the client the meters it hasn't been told yet (live sent a last MetersTraveled, 0 included, after the
* player's objects were taken down on leaving the world).
*/
void FlushMovementStatistics();
// How many unsent MetersTraveled make live send them (live's values: nearly all 25 to 33 while moving)
static constexpr uint64_t METERS_TRAVELED_BATCH = 25;
// How often DistanceDriven went out while racing (live: every few hundred packets, about 1300 units at race speed; inferred)
static constexpr float DISTANCE_DRIVEN_INTERVAL = 10.0f;
/**
* Handles a zone statistic update
* @param zoneID the zone that the stat belongs to
@@ -248,6 +263,12 @@ public:
*/
void UpdatePlayerStatistic(StatisticID updateID, uint64_t updateValue = 1, bool fromClient = false);
/**
* Sends UpdatePlayerStatistic to the player: the client adds updateValue to the passport statistic. Live sent it
* for every statistic the server counted (docs/CaptureUnknowns.md).
*/
void SendPlayerStatistic(StatisticID updateID, uint64_t updateValue) const;
/**
* Add a venture vision effect to the player minimap.
*/
@@ -508,6 +529,14 @@ private:
*/
uint64_t m_MetersTraveled;
// Parts of a meter not counted yet, and counted meters the client hasn't been told (TrackPositionUpdate)
float m_MetersRemainder = 0.0f;
uint64_t m_UnsentMeters = 0;
float m_DrivenRemainder = 0.0f;
uint64_t m_UnsentDriven = 0;
float m_DrivenSinceSend = 0.0f; // seconds
std::chrono::steady_clock::time_point m_LastDrivenUpdate{};
/**
* Total amount of times this character was smashed, either by other entities or by going out of bounds
*/