diff --git a/dGame/EntityManager.cpp b/dGame/EntityManager.cpp index 501937546..cfd8c1604 100644 --- a/dGame/EntityManager.cpp +++ b/dGame/EntityManager.cpp @@ -25,6 +25,7 @@ #include "eReplicaPacketType.h" #include "PlayerManager.h" #include "GhostComponent.h" +#include "CharacterComponent.h" #include // Configure which zones have ghosting disabled, mostly small worlds. @@ -452,6 +453,12 @@ void EntityManager::DestructEntity(Entity* entity, const SystemAddress& sysAddr) Game::server->Send(stream, sysAddr, sysAddr == UNASSIGNED_SYSTEM_ADDRESS); + // A player taken down for everyone is leaving the world: live then sent the meters its client hadn't been told + if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS && entity->IsPlayer()) { + auto* characterComponent = entity->GetComponent(); + if (characterComponent) characterComponent->FlushMovementStatistics(); + } + // Players still loading were to get it once loaded: only the ones it was taken down for (all of them, or the one // player; taking it down for one player, e.g. out of their ghosting range, leaves the others' queue alone) for (auto* player : PlayerManager::GetAllPlayers()) { diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index 80ca4a0ab..9bda59a74 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -33,7 +33,9 @@ #include "ZoneInstanceManager.h" #include "ClientPackets.h" #include "MessageType/Game.h" +#include "PlayerMessages.h" #include +#include #include 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(remainder); + remainder -= static_cast(meters); if (m_IsRacing) { - UpdatePlayerStatistic(DistanceDriven, static_cast(distance)); + const auto now = std::chrono::steady_clock::now(); + if (m_LastDrivenUpdate != std::chrono::steady_clock::time_point{}) m_DrivenSinceSend += std::chrono::duration(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(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(updateID); + statistic.updateValue = static_cast(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) { diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 4633e8cb2..601d1d104 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -14,6 +14,7 @@ #include "Loot.h" #include "StatisticID.h" #include +#include 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 */ diff --git a/dGame/dGameMessages/PlayerMessages.h b/dGame/dGameMessages/PlayerMessages.h index b8c5fb1ea..8e0fd77ac 100644 --- a/dGame/dGameMessages/PlayerMessages.h +++ b/dGame/dGameMessages/PlayerMessages.h @@ -100,7 +100,8 @@ namespace GameMessages { LWOMAPID zoneID{ LWOMAPID_INVALID }; // optional }; - // Client -> server here; live sent it server -> client (live captures hold no client -> server copy). The client's CharacterComponent adds updateValue + // Server -> client (CharacterComponent::SendPlayerStatistic), as live sent it; live captures hold no client -> server + // copy, but one is still counted when it comes. The client's CharacterComponent adds updateValue // to the passport statistic and, for every statistic but MetersTraveled, updates the passport UI. updateID is a // StatisticID. See docs/CaptureUnknowns.md. struct UpdatePlayerStatistic : public NetGameMsg { diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 0688440d2..02960de08 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -11,6 +11,7 @@ set(DGAMETEST_SOURCES "CollisionFilterTests.cpp" "ItemSetPassiveAbilityTests.cpp" "PowerUpStatisticTests.cpp" + "PlayerStatisticTests.cpp" "BrickByBrickTests.cpp" "PropertyBuildersTests.cpp" "ModularBuildTableTests.cpp" diff --git a/tests/dGameTests/PlayerStatisticTests.cpp b/tests/dGameTests/PlayerStatisticTests.cpp new file mode 100644 index 000000000..5aacca5fb --- /dev/null +++ b/tests/dGameTests/PlayerStatisticTests.cpp @@ -0,0 +1,111 @@ +#include + +#include "GameDependencies.h" +#include "Character.h" +#include "CharacterComponent.h" +#include "Entity.h" +#include "PlayerMessages.h" +#include "dGameMessagesTests/GameMessageTestUtils.h" + +// Live sent UpdatePlayerStatistic (1481) server -> client for the statistics the server counted (docs/CaptureUnknowns.md). +// The packets below are from 2011/2012 live captures of the player 0x100000008eea7d5e. + +using namespace GameMessageTestUtils; + +class PlayerStatisticTest : public GameDependenciesTest { +protected: + static constexpr LWOOBJID PLAYER = 0x100000008eea7d5eLL; + std::unique_ptr entity; + std::unique_ptr character; + CharacterComponent* characterComponent = nullptr; + + void SetUp() override { + SetUpDependencies(); + entity = std::make_unique(PLAYER, info); + character = std::make_unique(1, nullptr); + entity->SetCharacter(character.get()); + characterComponent = entity->AddComponent(-1, character.get(), ClientAddress()); + characterComponent->InitializeStatisticsFromString(""); // every statistic 0 + } + + void TearDown() override { + entity->SetCharacter(nullptr); + entity.reset(); + character.reset(); + TearDownDependencies(); + } + + // The UpdatePlayerStatistic packets sent, each checked to go to the player's client only + static std::vector Statistics(const std::vector& packets) { + std::vector statistics; + for (const auto& packet : packets) { + RakNet::BitStream bitStream(const_cast(packet.bytes.data()), packet.bytes.size(), false); + LWOOBJID target{}; + MessageType::Game msgId{}; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, target, msgId) || msgId != MessageType::Game::UPDATE_PLAYER_STATISTIC) continue; + EXPECT_EQ(packet.sysAddr, ClientAddress()); + EXPECT_FALSE(packet.broadcast); + auto& statistic = statistics.emplace_back(); + EXPECT_TRUE(statistic.Deserialize(bitStream)); + statistic.target = target; + } + return statistics; + } +}; + +TEST_F(PlayerStatisticTest, SendsWhatTheServerCountsAsLiveDid) { + // CurrencyCollected 1 (the amount is left out, the client's default) and 500 + auto packets = Capture([&] { characterComponent->UpdatePlayerStatistic(CurrencyCollected); }); + ASSERT_EQ(packets.size(), 1); + EXPECT_EQ(FromHex("5305000c000000005e7dea8e00000010c9050100000000").bytes, packets[0].bytes); // captures are padded to whole bytes + EXPECT_EQ(packets[0].sysAddr, ClientAddress()); + EXPECT_FALSE(packets[0].broadcast); + + packets = Capture([&] { characterComponent->UpdatePlayerStatistic(CurrencyCollected, 500); }); + ASSERT_EQ(packets.size(), 1); + EXPECT_EQ(FromHex("5305000c000000005e7dea8e00000010c90501000000fa0080000000000000").bytes, packets[0].bytes); // captures are padded to whole bytes + EXPECT_EQ(characterComponent->StatisticsToString().substr(0, 4), "501;"); +} + +TEST_F(PlayerStatisticTest, WhatTheClientReportsIsCountedNotEchoed) { + const auto packets = Capture([&] { characterComponent->UpdatePlayerStatistic(PetsTamed, 1, true); }); + EXPECT_TRUE(packets.empty()); + EXPECT_EQ(characterComponent->StatisticsToString().substr(0, 16), "0;0;0;0;0;0;0;1;"); +} + +TEST_F(PlayerStatisticTest, MetersTraveledGoOutInBatches) { + // The entity has no physics, so every update is measured from the origin + auto packets = Capture([&] { + characterComponent->TrackPositionUpdate(NiPoint3(10.0f, 0.0f, 0.0f)); + characterComponent->TrackPositionUpdate(NiPoint3(0.0f, 0.0f, 10.5f)); + }); + EXPECT_TRUE(Statistics(packets).empty()); // 20 counted, not sent yet + + packets = Capture([&] { characterComponent->TrackPositionUpdate(NiPoint3(0.0f, 9.0f, 0.0f)); }); + auto statistics = Statistics(packets); + ASSERT_EQ(statistics.size(), 1); + EXPECT_EQ(statistics[0].target, PLAYER); + EXPECT_EQ(statistics[0].updateID, static_cast(MetersTraveled)); + EXPECT_EQ(statistics[0].updateValue, 29); // 10 + 10 + 9, the half meter waits + EXPECT_EQ(FromHex("5305000c000000005e7dea8e00000010c9050c0000008e8000000000000000").bytes, packets[0].bytes); // captures are padded to whole bytes + + // The half meters add up; what's left goes out when the player leaves the world + packets = Capture([&] { + characterComponent->TrackPositionUpdate(NiPoint3(0.5f, 0.0f, 0.0f)); + characterComponent->TrackPositionUpdate(NiPoint3(3.0f, 0.0f, 0.0f)); + characterComponent->FlushMovementStatistics(); + }); + statistics = Statistics(packets); + ASSERT_EQ(statistics.size(), 1); + EXPECT_EQ(statistics[0].updateID, static_cast(MetersTraveled)); + EXPECT_EQ(statistics[0].updateValue, 4); + + // Every meter was counted once + const auto split = GeneralUtils::SplitString(characterComponent->StatisticsToString(), ';'); + EXPECT_EQ(split[11], "33"); + + // Live sent the last one even when nothing was left + statistics = Statistics(Capture([&] { characterComponent->FlushMovementStatistics(); })); + ASSERT_EQ(statistics.size(), 1); + EXPECT_EQ(statistics[0].updateValue, 0); +}