[Zone] Zone State Automated Testing and Improvements (#4808)

* [Zone] Zone State Automated Testing and Improvements

* Spawn condition

* Update zone.cpp

* Remove redundant logic

* Update zone_state.cpp

* TestZLocationDrift

* Protect NPC resumed NPC's from being able to die
This commit is contained in:
Chris Miles 2025-03-30 01:45:28 -05:00 committed by GitHub
parent c8a7066d0e
commit b9cfdea76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1312 additions and 168 deletions

View File

@ -196,3 +196,25 @@ const uint32 Timer::SetCurrentTime()
return current_time;
}
const uint32 Timer::RollForward(uint32 seconds)
{
struct timeval read_time{};
uint32 this_time;
gettimeofday(&read_time, nullptr);
this_time = read_time.tv_sec * 1000 + read_time.tv_usec / 1000;
if (last_time == 0) {
current_time = 0;
}
else {
current_time += this_time - last_time;
}
last_time = this_time;
// Roll forward the specified number of seconds (converted to milliseconds)
current_time += seconds * 1000;
return current_time;
}

View File

@ -51,6 +51,7 @@ public:
inline uint32 GetDuration() { return(timer_time); }
static const uint32 SetCurrentTime();
static const uint32 RollForward(uint32 seconds);
static const uint32 GetCurrentTime();
static const uint32 GetTimeSeconds();

View File

@ -57,6 +57,7 @@ echo "# Running NPC hand-in tests"
./bin/zone tests:npc-handins 2>&1 | tee test_output.log
./bin/zone tests:npc-handins-multiquest 2>&1 | tee -a test_output.log
./bin/zone tests:databuckets 2>&1 | tee -a test_output.log
./bin/zone tests:zone-state 2>&1 | tee -a test_output.log
if grep -E -q "QueryErr|Error|FAILED" test_output.log; then
echo "Error found in test output! Failing build."

View File

@ -173,6 +173,7 @@ SET(zone_sources
zone_event_scheduler.cpp
zone_npc_factions.cpp
zone_reload.cpp
zone_save_state.cpp
zoning.cpp
)
@ -292,6 +293,7 @@ SET(zone_headers
zonedump.h
zone_cli.h
zone_reload.h
zone_save_state.h
zone_cli.cpp)
ADD_EXECUTABLE(zone ${zone_sources} ${zone_headers})

View File

@ -2507,6 +2507,12 @@ bool NPC::Death(Mob* killer_mob, int64 damage, uint16 spell, EQ::skills::SkillTy
return false;
}
if (m_resumed_from_zone_suspend && !IsQueuedForCorpse()) {
LogInfo("NPC [{}] is resumed from zone suspend, cannot kill until zone resume is complete.", GetCleanName());
SetHP(0);
return false;
}
if (IsMultiQuestEnabled()) {
for (auto &i: m_hand_in.items) {
if (i.is_multiquest_item && i.item->GetItem()->NoDrop != 0) {
@ -2627,7 +2633,7 @@ bool NPC::Death(Mob* killer_mob, int64 damage, uint16 spell, EQ::skills::SkillTy
bool pet_owner_is_client = give_exp->IsPet() && owner->IsClient();
bool pet_owner_is_bot = give_exp->IsPet() && owner->IsBot();
bool owner_is_client = owner->IsClient();
bool is_in_same_group_or_raid = (
pet_owner_is_client ||
(pet_owner_is_bot && owner->IsInGroupOrRaid(ulimate_owner)) ||

View File

@ -0,0 +1,57 @@
#include "../../zone.h"
inline void RunTest(const std::string &test_name, const std::string &expected, const std::string &actual)
{
if (expected == actual) {
std::cout << "[✅] " << test_name << " PASSED\n";
} else {
std::cerr << "[❌] " << test_name << " FAILED\n";
std::cerr << " 📌 Expected: " << expected << "\n";
std::cerr << " ❌ Got: " << actual << "\n";
std::exit(1);
}
}
inline void RunTest(const std::string &test_name, bool expected, bool actual)
{
if (expected == actual) {
std::cout << "[✅] " << test_name << " PASSED\n";
}
else {
std::cerr << "[❌] " << test_name << " FAILED\n";
std::cerr << " 📌 Expected: " << (expected ? "true" : "false") << "\n";
std::cerr << " ❌ Got: " << (actual ? "true" : "false") << "\n";
std::exit(1);
}
}
inline void RunTest(const std::string &test_name, int expected, int actual)
{
if (expected == actual) {
std::cout << "[✅] " << test_name << " PASSED\n";
}
else {
std::cerr << "[❌] " << test_name << " FAILED\n";
std::cerr << " 📌 Expected: " << expected << "\n";
std::cerr << " ❌ Got: " << actual << "\n";
std::exit(1);
}
}
extern Zone *zone;
inline void SetupZone(std::string zone_short_name, uint32 instance_id = 0) {
LogSys.SilenceConsoleLogging();
LogSys.log_settings[Logs::ZoneState].log_to_console = std::getenv("DEBUG") ? 3 : 0;
LogSys.log_settings[Logs::Info].log_to_console = std::getenv("DEBUG") ? 3 : 0;
LogSys.log_settings[Logs::Spawns].log_to_console = std::getenv("DEBUG") ? 3 : 0;
// boot shell zone for testing
Zone::Bootup(ZoneID(zone_short_name), 0, false);
zone->StopShutdownTimer();
entity_list.Process();
entity_list.MobProcess();
LogSys.EnableConsoleLogging();
}

View File

@ -1,25 +1,13 @@
#include "../../common/http/httplib.h"
#include "../../common/eqemu_logsys.h"
#include "../../common/platform.h"
#include "../zone.h"
#include "../client.h"
#include "../../zone.h"
#include "../../client.h"
#include "../../common/net/eqstream.h"
extern Zone *zone;
void RunTest(const std::string &test_name, const std::string &expected, const std::string &actual)
{
if (expected == actual) {
std::cout << "[✅] " << test_name << " PASSED\n";
} else {
std::cerr << "[❌] " << test_name << " FAILED\n";
std::cerr << " 📌 Expected: " << expected << "\n";
std::cerr << " ❌ Got: " << actual << "\n";
std::exit(1);
}
}
void ZoneCLI::DataBuckets(int argc, char **argv, argh::parser &cmd, std::string &description)
void ZoneCLI::TestDataBuckets(int argc, char **argv, argh::parser &cmd, std::string &description)
{
if (cmd[{"-h", "--help"}]) {
return;

View File

@ -1,8 +1,8 @@
#include "../../common/http/httplib.h"
#include "../../common/eqemu_logsys.h"
#include "../../common/platform.h"
#include "../zone.h"
#include "../client.h"
#include "../../zone.h"
#include "../../client.h"
#include "../../common/net/eqstream.h"
#include "../../common/json/json.hpp"
@ -36,19 +36,6 @@ struct TestCase {
bool handin_check_result;
};
void RunTest(const std::string &test_name, bool expected, bool actual)
{
if (expected == actual) {
std::cout << "[✅] " << test_name << " PASSED\n";
}
else {
std::cerr << "[❌] " << test_name << " FAILED\n";
std::cerr << " 📌 Expected: " << (expected ? "true" : "false") << "\n";
std::cerr << " ❌ Got: " << (actual ? "true" : "false") << "\n";
std::exit(1);
}
}
void RunSerializedTest(const std::string &test_name, const std::string &expected, const std::string &actual)
{
if (expected == actual) {
@ -75,7 +62,7 @@ std::string SerializeHandin(const std::map<std::string, uint32> &items, const Ha
return j.dump();
}
void ZoneCLI::NpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description)
void ZoneCLI::TestNpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description)
{
if (cmd[{"-h", "--help"}]) {
return;

View File

@ -1,13 +1,13 @@
#include "../../common/http/httplib.h"
#include "../../common/eqemu_logsys.h"
#include "../../common/platform.h"
#include "../zone.h"
#include "../client.h"
#include "../../zone.h"
#include "../../client.h"
#include "../../common/net/eqstream.h"
extern Zone *zone;
void ZoneCLI::NpcHandinsMultiQuest(int argc, char **argv, argh::parser &cmd, std::string &description)
void ZoneCLI::TestNpcHandinsMultiQuest(int argc, char **argv, argh::parser &cmd, std::string &description)
{
if (cmd[{"-h", "--help"}]) {
return;

File diff suppressed because it is too large Load Diff

View File

@ -5991,3 +5991,14 @@ void EntityList::SendMerchantInventory(Mob* m, int32 slot_id, bool is_delete)
return;
}
void EntityList::RestoreCorpse(NPC *npc, uint32_t decay_time)
{
uint16 corpse_id = npc->GetID();
npc->Death(npc, npc->GetHP() + 1, SPELL_UNKNOWN, EQ::skills::SkillHandtoHand);
auto c = entity_list.GetCorpseByID(corpse_id);
if (c) {
c->UnLock();
c->SetDecayTimer(decay_time);
}
}

View File

@ -580,6 +580,7 @@ public:
void SendMerchantEnd(Mob* merchant);
void SendMerchantInventory(Mob* m, int32 slot_id = -1, bool is_delete = false);
void RestoreCorpse(NPC* npc, uint32_t decay_time);
protected:
friend class Zone;

View File

@ -1125,7 +1125,7 @@ public:
virtual void SetAttackTimer();
inline void SetInvul(bool invul) { invulnerable=invul; }
inline bool GetInvul(void) { return invulnerable; }
inline bool GetInvul() { return invulnerable; }
void SetExtraHaste(int haste, bool need_to_save = true);
inline int GetExtraHaste() { return extra_haste; }
virtual int GetHaste();

View File

@ -132,8 +132,6 @@ NPC::NPC(const NPCType *npc_type_data, Spawn2 *in_respawn, const glm::vec4 &posi
),
attacked_timer(CombatEventTimer_expire),
swarm_timer(100),
m_corpse_queue_timer(1000),
m_corpse_queue_shutoff_timer(30000),
m_resumed_from_zone_suspend_shutoff_timer(10000),
classattack_timer(1000),
monkattack_timer(1000),
@ -624,28 +622,6 @@ bool NPC::Process()
// zone state corpse creation timer
if (RuleB(Zone, StateSavingOnShutdown)) {
// creates a corpse if the NPC is queued for corpse creation
if (m_corpse_queue_timer.Check()) {
if (IsQueuedForCorpse()) {
auto decay_timer = m_corpse_decay_time;
uint16 corpse_id = GetID();
Death(this, GetHP() + 1, SPELL_UNKNOWN, EQ::skills::SkillHandtoHand);
auto c = entity_list.GetCorpseByID(corpse_id);
if (c) {
c->UnLock();
c->SetDecayTimer(decay_timer);
}
}
m_corpse_queue_timer.Disable();
m_corpse_queue_shutoff_timer.Disable();
}
// shuts off the corpse queue timer if it is still running
if (m_corpse_queue_shutoff_timer.Check()) {
m_corpse_queue_timer.Disable();
m_corpse_queue_shutoff_timer.Disable();
}
// shuts off the temporary spawn protected state of the NPC
if (m_resumed_from_zone_suspend_shutoff_timer.Check()) {
m_resumed_from_zone_suspend_shutoff_timer.Disable();
@ -654,17 +630,6 @@ bool NPC::Process()
}
if (tic_timer.Check()) {
if (RuleB(Zone, StateSavingOnShutdown) && IsQueuedForCorpse()) {
auto decay_timer = m_corpse_decay_time;
uint16 corpse_id = GetID();
Death(this, GetHP() + 1, SPELL_UNKNOWN, EQ::skills::SkillHandtoHand);
auto c = entity_list.GetCorpseByID(corpse_id);
if (c) {
c->UnLock();
c->SetDecayTimer(decay_timer);
}
}
if (parse->HasQuestSub(GetNPCTypeID(), EVENT_TICK)) {
parse->EventNPC(EVENT_TICK, this, nullptr, "", 0);
}

View File

@ -603,10 +603,9 @@ public:
// zone state save
inline void SetQueuedToCorpse() { m_queued_for_corpse = true; }
inline bool IsQueuedForCorpse() { return m_queued_for_corpse; }
inline uint32_t SetCorpseDecayTime(uint32_t decay_time) { return m_corpse_decay_time = decay_time; }
inline bool IsQueuedForCorpse() const { return m_queued_for_corpse; }
inline void SetResumedFromZoneSuspend(bool state = true) { m_resumed_from_zone_suspend = state; }
inline bool IsResumedFromZoneSuspend() { return m_resumed_from_zone_suspend; }
inline bool IsResumedFromZoneSuspend() const { return m_resumed_from_zone_suspend; }
inline void LoadBuffsFromState(std::vector<Buffs_Struct> in_buffs) {
int i = 0;
@ -658,15 +657,12 @@ protected:
LootItems m_loot_items;
// zone state
bool m_resumed_from_zone_suspend = false;
bool m_queued_for_corpse = false; // this is to check for corpse creation on zone state restore
uint32_t m_corpse_decay_time = 0; // decay time set on zone state restore
Timer m_corpse_queue_timer = {}; // this is to check for corpse creation on zone state restore
Timer m_corpse_queue_shutoff_timer = {};
bool m_resumed_from_zone_suspend = false;
bool m_queued_for_corpse = false; // this is to check for corpse creation on zone state restore
// this is a 30-second timer that protects a NPC from having double assignment of loot
// this is a timer that protects a NPC from having double assignment of loot
// this is to prevent a player from killing a NPC and then zoning out and back in to get loot again
// if loot was to be assigned via script again, this protects double assignment for 30 seconds
// if loot was to be assigned via script again, this protects double assignment for a short time
Timer m_resumed_from_zone_suspend_shutoff_timer = {};
std::list<NpcFactionEntriesRepository::NpcFactionEntries> faction_list;

View File

@ -191,16 +191,20 @@ bool Spawn2::Process() {
return false;
}
uint16 condition_value=1;
uint16 condition_value = 1;
if (condition_id > 0) {
condition_value = zone->spawn_conditions.GetCondition(zone->GetShortName(), zone->GetInstanceID(), condition_id);
condition_value = zone->spawn_conditions.GetCondition(
zone->GetShortName(),
zone->GetInstanceID(),
condition_id
);
}
//have the spawn group pick an NPC for us
uint32 npcid = 0;
if (RuleB(Zone, StateSavingOnShutdown) && currentnpcid && currentnpcid > 0) {
npcid = currentnpcid;
if (m_resumed_npc_id > 0) {
npcid = m_resumed_npc_id;
m_resumed_npc_id = 0;
} else {
npcid = spawn_group->GetNPCType(condition_value);
}

View File

@ -78,6 +78,7 @@ public:
inline bool IsResumedFromZoneSuspend() const { return m_resumed_from_zone_suspend; }
inline void SetResumedFromZoneSuspend(bool resumed) { m_resumed_from_zone_suspend = resumed; }
inline void SetEntityVariables(std::map<std::string, std::string> vars) { m_entity_variables = vars; }
inline void SetResumedNPCID(uint32 npc_id) { m_resumed_npc_id = npc_id; }
protected:
friend class Zone;
@ -105,6 +106,7 @@ private:
bool IsDespawned;
uint32 killcount;
bool m_resumed_from_zone_suspend = false;
uint32 m_resumed_npc_id = 0;
std::map<std::string, std::string> m_entity_variables = {};
};

View File

@ -887,10 +887,7 @@ void Zone::Shutdown(bool quiet)
c.second->WorldKick();
}
bool does_zone_have_entities =
zone && zone->IsLoaded() &&
(!entity_list.GetNPCList().empty() || !entity_list.GetCorpseList().empty());
if (RuleB(Zone, StateSavingOnShutdown) && does_zone_have_entities) {
if (RuleB(Zone, StateSavingOnShutdown) && zone && zone->IsLoaded()) {
SaveZoneState();
}
@ -1537,7 +1534,6 @@ bool Zone::Process() {
spawn_conditions.Process();
if (spawn2_timer.Check()) {
LinkedListIterator<Spawn2 *> iterator(spawn2_list);
EQ::InventoryProfile::CleanDirty();
@ -3295,5 +3291,4 @@ void Zone::ReloadMaps()
pathing = IPathfinder::Load(map_name);
}
#include "zone_save_state.cpp"
#include "zone_loot.cpp"

View File

@ -29,17 +29,23 @@ void ZoneCLI::CommandHandler(int argc, char **argv)
auto function_map = EQEmuCommand::function_map;
// Register commands
function_map["benchmark:databuckets"] = &ZoneCLI::BenchmarkDatabuckets;
function_map["sidecar:serve-http"] = &ZoneCLI::SidecarServeHttp;
function_map["tests:databuckets"] = &ZoneCLI::DataBuckets;
function_map["tests:npc-handins"] = &ZoneCLI::NpcHandins;
function_map["tests:npc-handins-multiquest"] = &ZoneCLI::NpcHandinsMultiQuest;
function_map["benchmark:databuckets"] = &ZoneCLI::BenchmarkDatabuckets;
function_map["sidecar:serve-http"] = &ZoneCLI::SidecarServeHttp;
function_map["tests:databuckets"] = &ZoneCLI::TestDataBuckets;
function_map["tests:npc-handins"] = &ZoneCLI::TestNpcHandins;
function_map["tests:npc-handins-multiquest"] = &ZoneCLI::TestNpcHandinsMultiQuest;
function_map["tests:zone-state"] = &ZoneCLI::TestZoneState;
EQEmuCommand::HandleMenu(function_map, cmd, argc, argv);
}
#include "cli/databuckets.cpp"
// cli
#include "cli/benchmark_databuckets.cpp"
#include "cli/sidecar_serve_http.cpp"
#include "cli/npc_handins.cpp"
#include "cli/npc_handins_multiquest.cpp"
// tests
#include "cli/tests/_test_util.cpp"
#include "cli/tests/databuckets.cpp"
#include "cli/tests/npc_handins.cpp"
#include "cli/tests/npc_handins_multiquest.cpp"
#include "cli/tests/zone_state.cpp"

View File

@ -1,6 +1,7 @@
#ifndef EQEMU_ZONE_CLI_H
#define EQEMU_ZONE_CLI_H
#include <iostream>
#include "../common/cli/argh.h"
class ZoneCLI {
@ -11,10 +12,10 @@ public:
static bool RanConsoleCommand(int argc, char **argv);
static bool RanSidecarCommand(int argc, char **argv);
static bool RanTestCommand(int argc, char **argv);
static void DataBuckets(int argc, char **argv, argh::parser &cmd, std::string &description);
static void NpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description);
static void NpcHandinsMultiQuest(int argc, char **argv, argh::parser &cmd, std::string &description);
static void TestDataBuckets(int argc, char **argv, argh::parser &cmd, std::string &description);
static void TestNpcHandins(int argc, char **argv, argh::parser &cmd, std::string &description);
static void TestNpcHandinsMultiQuest(int argc, char **argv, argh::parser &cmd, std::string &description);
static void TestZoneState(int argc, char **argv, argh::parser &cmd, std::string &description);
};
#endif //EQEMU_ZONE_CLI_H

View File

@ -368,18 +368,22 @@ void Zone::LoadLootDrops(const std::vector<uint32> in_lootdrop_ids)
m_lootdrops.emplace_back(e);
// add lootdrop entries
for (const auto &f: lootdrop_entries) {
if (e.id == f.lootdrop_id) {
// add lootdrop entries
for (const auto &h: lootdrop_entries) {
if (e.id == h.lootdrop_id) {
// check if lootdrop entry already exists in memory
has_entry = false;
for (const auto &g: m_lootdrop_entries) {
if (f.lootdrop_id == g.lootdrop_id && f.item_id == g.item_id && f.multiplier == g.multiplier) {
for (const auto &i: m_lootdrop_entries) {
if (h.lootdrop_id == i.lootdrop_id && h.item_id == i.item_id) {
has_entry = true;
break;
}
}
if (!has_entry) {
m_lootdrop_entries.emplace_back(h);
}
}
}
}

View File

@ -4,46 +4,7 @@
#include "npc.h"
#include "corpse.h"
#include "zone.h"
#include "../common/repositories/zone_state_spawns_repository.h"
#include "../common/repositories/spawn2_disabled_repository.h"
struct LootEntryStateData {
uint32 item_id;
uint32_t lootdrop_id;
uint16 charges = 0; // used in dynamically added loot (AddItem)
// cereal
template<class Archive>
void serialize(Archive &ar)
{
ar(
CEREAL_NVP(item_id),
CEREAL_NVP(lootdrop_id),
CEREAL_NVP(charges)
);
}
};
struct LootStateData {
uint32 copper = 0;
uint32 silver = 0;
uint32 gold = 0;
uint32 platinum = 0;
std::vector<LootEntryStateData> entries = {};
// cereal
template<class Archive>
void serialize(Archive &ar)
{
ar(
CEREAL_NVP(copper),
CEREAL_NVP(silver),
CEREAL_NVP(gold),
CEREAL_NVP(platinum),
CEREAL_NVP(entries)
);
}
};
#include "zone_save_state.h"
// IsZoneStateValid checks if the zone state is valid
// if these fields are all empty or zero value for an entire zone state, it's considered invalid
@ -359,7 +320,7 @@ inline void LoadNPCState(Zone *zone, NPC *n, ZoneStateSpawnsRepository::ZoneStat
auto decay_time = s.decay_in_seconds * 1000;
if (decay_time > 0) {
n->SetQueuedToCorpse();
n->SetCorpseDecayTime(decay_time);
entity_list.RestoreCorpse(n, decay_time);
}
else {
n->Depop();
@ -454,6 +415,7 @@ bool Zone::LoadZoneState(
zone->Process();
// load zone variables first
int count = 0;
for (auto &s: spawn_states) {
if (s.is_zone) {
LoadZoneVariables(zone, s.entity_variables);
@ -502,11 +464,13 @@ bool Zone::LoadZoneState(
);
if (spawn_time_left == 0) {
new_spawn->SetCurrentNPCID(s.npc_id);
new_spawn->SetResumedNPCID(s.npc_id);
new_spawn->SetResumedFromZoneSuspend(true);
new_spawn->SetEntityVariables(GetVariablesDeserialized(s.entity_variables));
}
count++;
spawn2_list.Insert(new_spawn);
new_spawn->Process();
auto n = new_spawn->GetNPC();
@ -548,24 +512,6 @@ bool Zone::LoadZoneState(
LoadNPCState(zone, npc, s);
}
// any NPC that is spawned by the spawn system
for (auto &e: entity_list.GetNPCList()) {
auto npc = e.second;
if (npc->GetSpawnGroupId() == 0) {
continue;
}
for (auto &s: spawn_states) {
bool is_same_npc =
s.npc_id == npc->GetNPCTypeID() &&
s.spawn2_id == npc->GetSpawnPointID() &&
s.spawngroup_id == npc->GetSpawnGroupId();
if (is_same_npc) {
LoadNPCState(zone, npc, s);
}
}
}
return !spawn_states.empty();
}
@ -638,6 +584,7 @@ void Zone::SaveZoneState()
std::vector<ZoneStateSpawnsRepository::ZoneStateSpawns> spawns = {};
LinkedListIterator<Spawn2 *> iterator(spawn2_list);
iterator.Reset();
int count = 0;
while (iterator.MoreElements()) {
Spawn2 *sp = iterator.GetData();
auto s = ZoneStateSpawnsRepository::NewEntity();
@ -667,6 +614,7 @@ void Zone::SaveZoneState()
spawns.emplace_back(s);
iterator.Advance();
count++;
}
// npc's that are not in the spawn2 list
@ -742,6 +690,11 @@ void Zone::SaveZoneState()
return;
}
if (spawns.empty()) {
LogInfo("No zone state data to save");
return;
}
ZoneStateSpawnsRepository::InsertMany(database, spawns);
LogInfo("Saved [{}] zone state spawns", Strings::Commify(spawns.size()));

48
zone/zone_save_state.h Normal file
View File

@ -0,0 +1,48 @@
#pragma once
#include <string>
#include <cereal/archives/json.hpp>
#include <cereal/types/map.hpp>
#include "npc.h"
#include "corpse.h"
#include "zone.h"
#include "../common/repositories/zone_state_spawns_repository.h"
#include "../common/repositories/spawn2_disabled_repository.h"
struct LootEntryStateData {
uint32 item_id = 0;
uint32_t lootdrop_id = 0;
uint16 charges = 0; // used in dynamically added loot (AddItem)
// cereal
template<class Archive>
void serialize(Archive &ar)
{
ar(
CEREAL_NVP(item_id),
CEREAL_NVP(lootdrop_id),
CEREAL_NVP(charges)
);
}
};
struct LootStateData {
uint32 copper = 0;
uint32 silver = 0;
uint32 gold = 0;
uint32 platinum = 0;
std::vector<LootEntryStateData> entries = {};
// cereal
template<class Archive>
void serialize(Archive &ar)
{
ar(
CEREAL_NVP(copper),
CEREAL_NVP(silver),
CEREAL_NVP(gold),
CEREAL_NVP(platinum),
CEREAL_NVP(entries)
);
}
};