fix(currency): SetCurrency names its source the way live did

Live filled SetCurrency's source fields per source type (16,816 decoded
live SetCurrency): pickups carry the position the client picked the
coins up at (the one in its PickupCurrency) and no source; mission,
achievement and activity rewards name the player (object and LOT 1);
vendor buys, sells and buybacks name the vendor and its LOT; trades
carry the trade ID; death, mail and everything else name nothing. No
live SetCurrency sets loot_type. DLU wrote a present source LOT of 0,
no object and a zero position for all of them.

The position is the one the client uses: for pickups it projects it to
the screen as the start of the coin counter animation
(LWOCharacterComponent::SetCurrency), so it started from the world
origin before. PickupCurrency now reads that position (the client
always sends it). The trade ID is an 8-byte object ID in the client;
DLU declared it 4 bytes, which only never broke because it was never
set.

Check: pick up coins and watch the coin counter animate from where the
coins were; buy and sell at a vendor, finish a mission and trade coins
with another player, and the coin total updates each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 02:55:26 -05:00
parent f7bb178c2d
commit 81db3a94ac
11 changed files with 148 additions and 28 deletions

View File

@@ -590,7 +590,38 @@ const NiPoint3& Character::GetRespawnPoint(LWOMAPID map) const {
return pair->second;
}
void Character::SetCoins(int64_t newCoins, eLootSourceType lootSource) {
CoinSource CoinSource::Object(const Entity& source) {
CoinSource coinSource;
coinSource.object = source.GetObjectID();
coinSource.lot = source.GetLOT();
return coinSource;
}
CoinSource CoinSource::Position(const NiPoint3& position) {
CoinSource coinSource;
coinSource.position = position;
return coinSource;
}
CoinSource CoinSource::Trade(const LWOOBJID tradeID) {
CoinSource coinSource;
coinSource.tradeID = tradeID;
return coinSource;
}
GameMessages::SetCurrency Character::MakeSetCurrency(const LWOOBJID player, const int64_t coins, const eLootSourceType coinSource, const CoinSource& source) {
GameMessages::SetCurrency setCurrency;
setCurrency.target = player;
setCurrency.currency = coins;
setCurrency.position = source.position;
setCurrency.sourceLOT = source.lot;
setCurrency.sourceID = source.object;
setCurrency.sourceTradeID = source.tradeID;
setCurrency.sourceType = coinSource;
return setCurrency;
}
void Character::SetCoins(int64_t newCoins, eLootSourceType lootSource, const CoinSource& source) {
if (newCoins < 0) {
newCoins = 0;
}
@@ -600,15 +631,7 @@ void Character::SetCoins(int64_t newCoins, eLootSourceType lootSource) {
m_Coins = newCoins;
auto* entity = Game::entityManager->GetEntity(m_ObjectID);
GameMessages::SetCurrency setCurrency;
setCurrency.target = entity->GetObjectID();
setCurrency.currency = m_Coins;
setCurrency.lootType = 0;
setCurrency.sourceLOT = 0;
setCurrency.sourceID = 0;
setCurrency.sourceTradeID = 0;
setCurrency.sourceType = lootSource;
setCurrency.SendToClient(entity->GetSystemAddress());
MakeSetCurrency(entity->GetObjectID(), m_Coins, lootSource, source).SendToClient(entity->GetSystemAddress());
// Live followed every coin gain with CurrencyCollected (the amount gained); losses had none
auto* characterComponent = entity->GetComponent<CharacterComponent>();

View File

@@ -17,6 +17,26 @@ class Entity;
enum class ePermissionMap : uint64_t;
enum class eGameMasterLevel : uint8_t;
enum class eLootSourceType : uint32_t;
namespace GameMessages {
struct SetCurrency;
}
/**
* Where a coin change came from, as live filled SetCurrency (18,417 live SetCurrency):
* pickups carry the position the client picked the coins up at (the client starts the coin animation there);
* mission, achievement and activity rewards name the player (LOT 1); vendors name the vendor and its LOT;
* trades carry the trade ID. Everything else (death, mail) names nothing.
*/
struct CoinSource {
NiPoint3 position{ NiPoint3Constant::ZERO };
LWOOBJID object{ LWOOBJID_EMPTY };
LOT lot{ LOT_NULL };
LWOOBJID tradeID{ LWOOBJID_EMPTY };
static CoinSource Object(const Entity& source);
static CoinSource Position(const NiPoint3& position);
static CoinSource Trade(LWOOBJID tradeID);
};
/**
* Meta information about a character, like their name and style
@@ -352,7 +372,12 @@ public:
* @param newCoins the amount of coins to update by
* @param coinSource The source of the loot
*/
void SetCoins(int64_t newCoins, eLootSourceType coinSource);
void SetCoins(int64_t newCoins, eLootSourceType coinSource, const CoinSource& source = {});
/**
* The SetCurrency live sent for a coin change.
*/
static GameMessages::SetCurrency MakeSetCurrency(LWOOBJID player, int64_t coins, eLootSourceType coinSource, const CoinSource& source);
/**
* Get the entity this character belongs to

View File

@@ -177,8 +177,8 @@ void Trade::Complete() {
// Now actually do the trade. Nothing is created or destroyed, so the economy ledger records transfers instead.
EconomyLedger::ScopedItemTransfer transfer;
characterA->SetCoins(characterA->GetCoins() - m_CoinsA + m_CoinsB, eLootSourceType::TRADE);
characterB->SetCoins(characterB->GetCoins() - m_CoinsB + m_CoinsA, eLootSourceType::TRADE);
characterA->SetCoins(characterA->GetCoins() - m_CoinsA + m_CoinsB, eLootSourceType::TRADE, CoinSource::Trade(m_TradeId));
characterB->SetCoins(characterB->GetCoins() - m_CoinsB + m_CoinsA, eLootSourceType::TRADE, CoinSource::Trade(m_TradeId));
const auto zone = Game::server ? Game::server->GetZoneID() : 0;
const auto recordCoins = [zone](const uint64_t coins, const LWOOBJID from, const LWOOBJID to) {

View File

@@ -207,7 +207,7 @@ void VendorComponent::Buy(Entity* buyer, LOT lot, uint32_t count) {
inventoryComponent->RemoveItem(itemComp.currencyLOT, altCurrencyCost, eInventoryType::ALL);
}
character->SetCoins(character->GetCoins() - (coinCost), eLootSourceType::VENDOR);
character->SetCoins(character->GetCoins() - (coinCost), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent));
inventoryComponent->AddItem(lot, count, eLootSourceType::VENDOR);
SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_SUCCESS);
}
@@ -278,7 +278,7 @@ void VendorComponent::SellToVendor(Entity& player, const SystemAddress& sysAddr,
MakeRoomInBuyback(*inv, *item, static_cast<uint32_t>(std::max(count, 0)));
inv->MoveItemToInventory(item, eInventoryType::VENDOR_BUYBACK, count, true, false, true);
character->SetCoins(std::floor(character->GetCoins() + (static_cast<uint32_t>(itemComp.baseValue * sellScalar) * count)), eLootSourceType::VENDOR);
character->SetCoins(std::floor(character->GetCoins() + (static_cast<uint32_t>(itemComp.baseValue * sellScalar) * count)), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent));
SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_SUCCESS);
}
@@ -316,7 +316,7 @@ void VendorComponent::BuybackFromVendor(Entity& player, const SystemAddress& sys
//inv->RemoveItem(count, -1, iObjID);
inv->MoveItemToInventory(item, Inventory::FindInventoryTypeForLot(item->GetLot()), count, true, false);
character->SetCoins(character->GetCoins() - cost, eLootSourceType::VENDOR);
character->SetCoins(character->GetCoins() - cost, eLootSourceType::VENDOR, CoinSource::Object(*m_Parent));
//Game::entityManager->SerializeEntity(player); // so inventory updates
SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_SUCCESS);
}

View File

@@ -50,7 +50,7 @@ namespace GameMessages {
bitStream.Write(position.z);
BitStreamUtils::WriteOptional(bitStream, sourceLOT, LOT_NULL);
BitStreamUtils::WriteOptional(bitStream, sourceID, LWOOBJID_EMPTY);
BitStreamUtils::WriteOptional(bitStream, sourceTradeID, 0);
BitStreamUtils::WriteOptional(bitStream, sourceTradeID, LWOOBJID_EMPTY);
BitStreamUtils::WriteOptional(bitStream, sourceType, eLootSourceType::NONE);
}
@@ -62,7 +62,7 @@ namespace GameMessages {
VALIDATE_READ(bitStream.Read(position.z));
VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, sourceLOT, LOT_NULL));
VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, sourceID, LWOOBJID_EMPTY));
VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, sourceTradeID, 0));
VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, sourceTradeID, LWOOBJID_EMPTY));
VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, sourceType, eLootSourceType::NONE));
return true;
}
@@ -115,10 +115,17 @@ namespace GameMessages {
void PickupCurrency::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(currency);
bitStream.Write(position.x);
bitStream.Write(position.y);
bitStream.Write(position.z);
}
bool PickupCurrency::Deserialize(RakNet::BitStream& bitStream) {
return bitStream.Read(currency);
VALIDATE_READ(bitStream.Read(currency));
VALIDATE_READ(bitStream.Read(position.x));
VALIDATE_READ(bitStream.Read(position.y));
VALIDATE_READ(bitStream.Read(position.z));
return true;
}
void PickupCurrency::Handle(Entity& entity, const SystemAddress& sysAddr) {
@@ -126,7 +133,7 @@ namespace GameMessages {
auto* ch = entity.GetCharacter();
if (ch && entity.PickupCoins(currency)) {
ch->SetCoins(ch->GetCoins() + currency, eLootSourceType::PICKUP);
ch->SetCoins(ch->GetCoins() + currency, eLootSourceType::PICKUP, CoinSource::Position(position));
}
}

View File

@@ -53,8 +53,7 @@ namespace GameMessages {
NiPoint3 position{ NiPoint3Constant::ZERO };
LOT sourceLOT{ LOT_NULL }; // optional
LWOOBJID sourceID{ LWOOBJID_EMPTY }; // optional
// optional (default 0). DLU writes it as an int32_t; lu_packets has an object ID (8 bytes). Only ever 0.
int32_t sourceTradeID{ 0 };
LWOOBJID sourceTradeID{ LWOOBJID_EMPTY }; // optional; the client reads an object ID (8 bytes)
eLootSourceType sourceType{ eLootSourceType::NONE }; // optional
};
@@ -109,6 +108,7 @@ namespace GameMessages {
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
uint32_t currency{};
NiPoint3 position{ NiPoint3Constant::ZERO }; // where the client picked the coins up
};
// Client -> server.

View File

@@ -528,7 +528,7 @@ void Mission::YieldRewards() {
if (info.reward_currency_repeatable > 0 || coinsToSend > 0) {
eLootSourceType lootSource = info.isMission ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT;
character->SetCoins(character->GetCoins() + info.reward_currency_repeatable + coinsToSend, lootSource);
character->SetCoins(character->GetCoins() + info.reward_currency_repeatable + coinsToSend, lootSource, CoinSource::Object(*entity));
}
return;
@@ -556,7 +556,7 @@ void Mission::YieldRewards() {
if (info.reward_currency > 0 || coinsToSend > 0) {
eLootSourceType lootSource = info.isMission ? eLootSourceType::MISSION : eLootSourceType::ACHIEVEMENT;
character->SetCoins(character->GetCoins() + info.reward_currency + coinsToSend, lootSource);
character->SetCoins(character->GetCoins() + info.reward_currency + coinsToSend, lootSource, CoinSource::Object(*entity));
}
if (info.reward_maxinventory > 0) {

View File

@@ -570,7 +570,7 @@ void Loot::GiveActivityLoot(Entity* player, const LWOOBJID source, uint32_t acti
auto* character = player->GetCharacter();
character->SetCoins(character->GetCoins() + coins, eLootSourceType::ACTIVITY);
character->SetCoins(character->GetCoins() + coins, eLootSourceType::ACTIVITY, CoinSource::Object(*player));
}
void Loot::DropLoot(Entity* player, const LWOOBJID source, uint32_t matrixIndex, uint32_t minCoins, uint32_t maxCoins) {

View File

@@ -18,6 +18,7 @@ set(DGAMETEST_SOURCES
"UgcManifestTests.cpp"
"SceneGhostingTests.cpp"
"HolidayEventsTests.cpp"
"SetCurrencySourceTests.cpp"
)
add_subdirectory(dComponentsTests)

View File

@@ -0,0 +1,55 @@
#include "GameDependencies.h"
#include "dGameMessagesTests/GameMessageTestUtils.h"
#include "Character.h"
#include "Entity.h"
#include "PlayerMessages.h"
#include "eLootSourceType.h"
#include <gtest/gtest.h>
using namespace GameMessageTestUtils;
// SetCurrency source fields per source type, as live sent them. Each expected packet is a live SetCurrency with the
// player ID replaced by 0x1000000000000001 and any other object or trade ID by 0x0102030405060708.
class SetCurrencySourceTests : public GameDependenciesTest {
protected:
void SetUp() override { SetUpDependencies(); }
void TearDown() override { TearDownDependencies(); }
static constexpr LWOOBJID PLAYER = 0x1000000000000001LL;
static constexpr LWOOBJID OTHER = 0x0102030405060708LL;
};
TEST_F(SetCurrencySourceTests, PickupCarriesThePickupPosition) {
const auto msg = Character::MakeSetCurrency(PLAYER, 253493, eLootSourceType::PICKUP, CoinSource::Position(NiPoint3(-142.26820373535156f, 69.87815856933594f, 391.2568359375f)));
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 35 de 03 00 00 00 00 00 54 a2 07 61 cf 60 c5 a1 70 50 61 a1 88 58 00 00 00", 341), StructPacket(msg));
}
TEST_F(SetCurrencySourceTests, MissionAndAchievementNameThePlayer) {
auto playerInfo = info;
playerInfo.lot = 1;
Entity player(PLAYER, playerInfo);
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 02 25 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 40 00 00 20 20 00 00 00 00 00 02 08 10 00 00 00", 437),
StructPacket(Character::MakeSetCurrency(PLAYER, 206082, eLootSourceType::MISSION, CoinSource::Object(player))));
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 0a ef 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 40 00 00 20 20 00 00 00 00 00 02 08 28 00 00 00", 437),
StructPacket(Character::MakeSetCurrency(PLAYER, 257802, eLootSourceType::ACHIEVEMENT, CoinSource::Object(player))));
}
TEST_F(SetCurrencySourceTests, VendorNamesTheVendor) {
auto vendorInfo = info;
vendorInfo.lot = 8212;
Entity vendor(OTHER, vendorInfo);
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 18 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 45 08 00 00 21 00 e0 c0 a0 80 60 40 28 48 00 00 00", 437),
StructPacket(Character::MakeSetCurrency(PLAYER, 14616, eLootSourceType::VENDOR, CoinSource::Object(vendor))));
}
TEST_F(SetCurrencySourceTests, TradeCarriesAnEightByteTradeId) {
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 34 66 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 10 80 70 60 50 40 30 20 18 30 00 00 00", 405),
StructPacket(Character::MakeSetCurrency(PLAYER, 222772, eLootSourceType::TRADE, CoinSource::Trade(OTHER))));
}
TEST_F(SetCurrencySourceTests, DeathNamesNothing) {
EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 85 00 61 59 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 08 40 00 00 00", 341),
StructPacket(Character::MakeSetCurrency(PLAYER, 22881, eLootSourceType::DELETION, {})));
}

View File

@@ -408,9 +408,9 @@ TEST_F(RemainingMessagesTests, PlayerMessagesMatchLegacy) {
currency.lootType = smallValue;
currency.sourceID = smallValue;
currency.sourceLOT = smallValue;
currency.sourceTradeID = smallValue;
currency.sourceType = source;
ExpectSameSends([&] { LegacyGameMessages::SendSetCurrency(&entity, value, smallValue, smallValue, smallValue, smallValue, true, source); }, [&] { currency.SendToClient(entity.GetSystemAddress()); });
// The legacy sender wrote a set trade ID as 4 bytes; the client reads 8 (see SetCurrencyTradeIdIsAnObjectId).
ExpectSameSends([&] { LegacyGameMessages::SendSetCurrency(&entity, value, smallValue, smallValue, smallValue, 0, true, source); }, [&] { currency.SendToClient(entity.GetSystemAddress()); });
RoundTrip(currency);
}
}
@@ -427,7 +427,16 @@ TEST_F(RemainingMessagesTests, InboundPlayerMessagesMatchLegacy) {
for (const uint32_t amount : { 0u, 1u, 0xffffffffu }) {
GameMessages::PickupCurrency pickup;
pickup.currency = amount;
EXPECT_EQ(ReadWithLegacy<unsigned int>(pickup, LegacyGameMessages::ReadPickupCurrency), amount);
pickup.position = NiPoint3(1.5f, -2.0f, 300.25f);
// The old reader stopped after the amount; the client also sends where it picked the coins up.
RakNet::BitStream wire;
pickup.Serialize(wire);
RakNet::BitStream legacyStream(wire.GetData(), wire.GetNumberOfBytesUsed(), false);
EXPECT_EQ(LegacyGameMessages::ReadPickupCurrency(legacyStream), amount);
const auto copy = RoundTrip(pickup);
EXPECT_EQ(copy.currency, amount);
EXPECT_EQ(copy.position, pickup.position);
EXPECT_EQ(wire.GetNumberOfBitsUsed(), 16u * 8u);
ExpectTruncatedFails(pickup);
}
for (const auto& name : g_WStrings) {