From 8d1a9efac94374cb958b25691953345671212bca Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Thu, 13 Mar 2025 17:00:30 -0500 Subject: [PATCH] [Zone] Zone State Improvements Part 3 (#4773) * [Zone State] Additional improvements * Return early * Update zone_save_state.cpp * Push * Push * Update zone.cpp * Update zone_save_state.cpp * Equip items that were dynamically added on restore * IsZoneStateValid helper * ZoneStateSpawnsRepository::PurgeInvalidZoneStates * Add Zone:StateSaveClearDays and PurgeOldZoneStates * spawn2 / unique_spawn block when restored from zone state * One time purge * Update zone_state_spawns_repository.h * Update npc.cpp * Update npc.cpp * test * ORDER BY spawn2_id * Stuff * Restored corpses shouldn't trigger events * Fix weird edge case --- common/database/database_update_manifest.cpp | 13 +- .../zone_state_spawns_repository.h | 72 ++++++++- common/ruletypes.h | 1 + common/version.h | 2 +- world/world_boot.cpp | 6 + zone/loot.cpp | 7 +- zone/mob_info.cpp | 3 + zone/npc.cpp | 2 +- zone/quest_parser_collection.cpp | 6 +- zone/questmgr.cpp | 18 +++ zone/spawn2.cpp | 3 + zone/spawn2.h | 3 + zone/zone.cpp | 5 +- zone/zone_save_state.cpp | 142 +++++++++++++----- 14 files changed, 235 insertions(+), 48 deletions(-) diff --git a/common/database/database_update_manifest.cpp b/common/database/database_update_manifest.cpp index c882cdb96..502d035ea 100644 --- a/common/database/database_update_manifest.cpp +++ b/common/database/database_update_manifest.cpp @@ -6980,7 +6980,7 @@ ALTER TABLE data_buckets ADD INDEX idx_bot_expires (bot_id, expires); }, ManifestEntry{ .version = 9313, - .description = "2025_03_11_data_bucket_indexes.sql", + .description = "2025_03_11_zone_state_spawns.sql", .check = "SHOW INDEX FROM zone_state_spawns", .condition = "missing", .match = "idx_zone_instance", @@ -6990,6 +6990,17 @@ ALTER TABLE zone_state_spawns ADD INDEX idx_instance_id (instance_id); )", .content_schema_update = false }, + ManifestEntry{ + .version = 9314, + .description = "2025_03_12_zone_state_spawns_one_time_truncate.sql", + .check = "SELECT * FROM db_version WHERE version >= 9314", + .condition = "empty", + .match = "", + .sql = R"( +TRUNCATE TABLE zone_state_spawns; +)", + .content_schema_update = false + }, // -- template; copy/paste this when you need to create a new entry // ManifestEntry{ // .version = 9228, diff --git a/common/repositories/zone_state_spawns_repository.h b/common/repositories/zone_state_spawns_repository.h index 0166622b0..adb48edec 100644 --- a/common/repositories/zone_state_spawns_repository.h +++ b/common/repositories/zone_state_spawns_repository.h @@ -5,9 +5,77 @@ #include "../strings.h" #include "base/base_zone_state_spawns_repository.h" -class ZoneStateSpawnsRepository: public BaseZoneStateSpawnsRepository { +class ZoneStateSpawnsRepository : public BaseZoneStateSpawnsRepository { public: - // Custom extended repository methods here + static void PurgeInvalidZoneStates(Database &database) + { + std::string query = R"( + SELECT zone_id, instance_id + FROM zone_state_spawns + GROUP BY zone_id, instance_id + HAVING COUNT(*) = SUM( + CASE + WHEN hp = 0 + AND mana = 0 + AND endurance = 0 + AND (loot_data IS NULL OR loot_data = '') + AND (entity_variables IS NULL OR entity_variables = '') + AND (buffs IS NULL OR buffs = '') + THEN 1 ELSE 0 + END + ); + )"; + + auto results = database.QueryDatabase(query); + if (!results.Success()) { + return; + } + + for (auto row: results) { + uint32 zone_id = std::stoul(row[0]); + uint32 instance_id = std::stoul(row[1]); + + int rows = ZoneStateSpawnsRepository::DeleteWhere( + database, + fmt::format( + "`zone_id` = {} AND `instance_id` = {}", + zone_id, + instance_id + ) + ); + + LogInfo( + "Purged invalid zone state data for zone [{}] instance [{}] rows [{}]", + zone_id, + instance_id, + Strings::Commify(rows) + ); + } + } + + static void PurgeOldZoneStates(Database &database) + { + int days = RuleI(Zone, StateSaveClearDays); + + std::string query = fmt::format( + "DELETE FROM zone_state_spawns WHERE created_at < NOW() - INTERVAL {} DAY", + days + ); + + auto results = database.QueryDatabase(query); + if (!results.Success()) { + LogError("Failed to purge old zone state data older than {} days.", days); + return; + } + + if (results.RowsAffected() > 0) { + LogInfo( + "Purged old zone state data older than days [{}] rows [{}]", + days, + Strings::Commify(results.RowsAffected()) + ); + } + } }; diff --git a/common/ruletypes.h b/common/ruletypes.h index 5010bc830..11eba8bee 100644 --- a/common/ruletypes.h +++ b/common/ruletypes.h @@ -376,6 +376,7 @@ RULE_BOOL(Zone, AllowCrossZoneSpellsOnPets, false, "Set to true to allow cross z RULE_BOOL(Zone, ZoneShardQuestMenuOnly, false, "Set to true if you only want quests to show the zone shard menu") RULE_BOOL(Zone, StateSaveEntityVariables, true, "Set to true if you want buffs to be saved on shutdown") RULE_BOOL(Zone, StateSaveBuffs, true, "Set to true if you want buffs to be saved on shutdown") +RULE_INT(Zone, StateSaveClearDays, 7, "Clears state save data older than this many days") RULE_BOOL(Zone, StateSavingOnShutdown, true, "Set to true if you want zones to save state on shutdown (npcs, corpses, loot, entity variables, buffs etc.)") RULE_CATEGORY_END() diff --git a/common/version.h b/common/version.h index 14b18d5a4..e1ec6d348 100644 --- a/common/version.h +++ b/common/version.h @@ -42,7 +42,7 @@ * Manifest: https://github.com/EQEmu/Server/blob/master/utils/sql/db_update_manifest.txt */ -#define CURRENT_BINARY_DATABASE_VERSION 9313 +#define CURRENT_BINARY_DATABASE_VERSION 9314 #define CURRENT_BINARY_BOTS_DATABASE_VERSION 9054 #endif diff --git a/world/world_boot.cpp b/world/world_boot.cpp index 537e3056d..3ae9816f5 100644 --- a/world/world_boot.cpp +++ b/world/world_boot.cpp @@ -27,6 +27,7 @@ #include "../common/zone_store.h" #include "../common/path_manager.h" #include "../common/database/database_update.h" +#include "../common/repositories/zone_state_spawns_repository.h" extern ZSList zoneserver_list; extern WorldConfig Config; @@ -412,6 +413,11 @@ bool WorldBoot::DatabaseLoadRoutines(int argc, char **argv) LogInfo("Cleaning up instance corpses"); database.CleanupInstanceCorpses(); + if (RuleB(Zone, StateSavingOnShutdown)) { + ZoneStateSpawnsRepository::PurgeInvalidZoneStates(database); + ZoneStateSpawnsRepository::PurgeOldZoneStates(database); + } + return true; } diff --git a/zone/loot.cpp b/zone/loot.cpp index 9a61b3eb4..e78c8f519 100644 --- a/zone/loot.cpp +++ b/zone/loot.cpp @@ -23,6 +23,11 @@ void NPC::AddLootTable(uint32 loottable_id, bool is_global) return; } + if (m_resumed_from_zone_suspend) { + LogZoneState("NPC [{}] is resuming from zone suspend, skipping", GetCleanName()); + return; + } + if (!is_global) { m_loot_copper = 0; m_loot_silver = 0; @@ -277,7 +282,7 @@ void NPC::AddLootDrop( ) { if (m_resumed_from_zone_suspend) { - LogZoneState("NPC [{}] is resuming from zone suspend, skipping AddItem", GetCleanName()); + LogZoneState("NPC [{}] is resuming from zone suspend, skipping", GetCleanName()); return; } diff --git a/zone/mob_info.cpp b/zone/mob_info.cpp index 1e9de2dd9..a896f8f01 100644 --- a/zone/mob_info.cpp +++ b/zone/mob_info.cpp @@ -626,6 +626,9 @@ inline void NPCCommandsMenu(Client* client, NPC* npc) if (npc->GetLoottableID() > 0) { menu_commands += "[" + Saylink::Silent("#npcloot show", "Loot") + "] "; + if (npc) { + menu_commands += fmt::format(" Item(s) ({}) ", npc->GetLootItems().size()); + } } if (npc->IsProximitySet()) { diff --git a/zone/npc.cpp b/zone/npc.cpp index 26103537b..d3c31ce29 100644 --- a/zone/npc.cpp +++ b/zone/npc.cpp @@ -134,7 +134,7 @@ NPC::NPC(const NPCType *npc_type_data, Spawn2 *in_respawn, const glm::vec4 &posi swarm_timer(100), m_corpse_queue_timer(1000), m_corpse_queue_shutoff_timer(30000), - m_resumed_from_zone_suspend_shutoff_timer(30000), + m_resumed_from_zone_suspend_shutoff_timer(10000), classattack_timer(1000), monkattack_timer(1000), knightattack_timer(1000), diff --git a/zone/quest_parser_collection.cpp b/zone/quest_parser_collection.cpp index 9ab597324..c4754cfea 100644 --- a/zone/quest_parser_collection.cpp +++ b/zone/quest_parser_collection.cpp @@ -435,10 +435,8 @@ int QuestParserCollection::EventNPC( std::vector* extra_pointers ) { - if (npc->IsResumedFromZoneSuspend()) { - if (event_id == EVENT_DEATH_COMPLETE || event_id == EVENT_DEATH) { - return 0; - } + if (npc->IsResumedFromZoneSuspend() && npc->IsQueuedForCorpse()) { + return 0; } const int local_return = EventNPCLocal(event_id, npc, init, data, extra_data, extra_pointers); diff --git a/zone/questmgr.cpp b/zone/questmgr.cpp index dba3295a7..085557421 100644 --- a/zone/questmgr.cpp +++ b/zone/questmgr.cpp @@ -208,6 +208,15 @@ void QuestManager::write(const char *file, const char *str) { } Mob* QuestManager::spawn2(int npc_id, int grid, int unused, const glm::vec4& position) { + QuestManagerCurrentQuestVars(); + if (owner && owner->IsNPC()) { + auto n = owner->CastToNPC(); + if (n->IsResumedFromZoneSuspend()) { + LogZoneState("NPC [{}] is resuming from zone suspend, skipping quest call", n->GetCleanName()); + return nullptr; + } + } + const NPCType* t = 0; if (t = content_db.LoadNPCTypesData(npc_id)) { auto npc = new NPC(t, nullptr, position, GravityBehavior::Water); @@ -228,6 +237,15 @@ Mob* QuestManager::spawn2(int npc_id, int grid, int unused, const glm::vec4& pos } Mob* QuestManager::unique_spawn(int npc_type, int grid, int unused, const glm::vec4& position) { + QuestManagerCurrentQuestVars(); + if (owner && owner->IsNPC()) { + auto n = owner->CastToNPC(); + if (n->IsResumedFromZoneSuspend()) { + LogZoneState("NPC [{}] is resuming from zone suspend, skipping quest call", n->GetCleanName()); + return nullptr; + } + } + Mob *other = entity_list.GetMobByNpcTypeID(npc_type); if(other != nullptr) { return other; diff --git a/zone/spawn2.cpp b/zone/spawn2.cpp index 86e7f2647..66a6dd37a 100644 --- a/zone/spawn2.cpp +++ b/zone/spawn2.cpp @@ -277,6 +277,9 @@ bool Spawn2::Process() { npcthis = npc; + npc->SetResumedFromZoneSuspend(m_resumed_from_zone_suspend); + m_resumed_from_zone_suspend = false; + npc->AddLootTable(); if (npc->DropsGlobalLoot()) { npc->CheckGlobalLootTables(); diff --git a/zone/spawn2.h b/zone/spawn2.h index f39184757..820e700d2 100644 --- a/zone/spawn2.h +++ b/zone/spawn2.h @@ -75,6 +75,8 @@ public: int16 GetConditionMinValue() const { return condition_min_value; } int16 GetAnimation () { return anim; } inline NPC *GetNPC() const { return npcthis; } + inline bool IsResumedFromZoneSuspend() const { return m_resumed_from_zone_suspend; } + inline void SetResumedFromZoneSuspend(bool resumed) { m_resumed_from_zone_suspend = resumed; } protected: friend class Zone; @@ -101,6 +103,7 @@ private: EmuAppearance anim; bool IsDespawned; uint32 killcount; + bool m_resumed_from_zone_suspend = false; }; class SpawnCondition { diff --git a/zone/zone.cpp b/zone/zone.cpp index 8e0cfef7e..0dfcc0301 100644 --- a/zone/zone.cpp +++ b/zone/zone.cpp @@ -887,7 +887,10 @@ void Zone::Shutdown(bool quiet) c.second->WorldKick(); } - if (RuleB(Zone, StateSavingOnShutdown)) { + bool does_zone_have_entities = + zone && zone->IsLoaded() && + (!entity_list.GetNPCList().empty() || !entity_list.GetCorpseList().empty()); + if (RuleB(Zone, StateSavingOnShutdown) && does_zone_have_entities) { SaveZoneState(); } diff --git a/zone/zone_save_state.cpp b/zone/zone_save_state.cpp index 729c4b3d3..82916d372 100644 --- a/zone/zone_save_state.cpp +++ b/zone/zone_save_state.cpp @@ -45,10 +45,40 @@ struct LootStateData { } }; +// 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 +inline bool IsZoneStateValid(std::vector &spawns) +{ + return std::any_of( + spawns.begin(), spawns.end(), [](const auto &s) { + return !( + s.hp == 0 && + s.mana == 0 && + s.endurance == 0 && + s.loot_data.empty() && + s.entity_variables.empty() && + s.buffs.empty() + ); + } + ); +} + inline void LoadLootStateData(Zone *zone, NPC *npc, const std::string &loot_data) { LootStateData l{}; + // in the event that should never happen, we roll loot from the NPC's table + if (loot_data.empty()) { + LogZoneState("No loot state data found for NPC [{}], re-rolling", npc->GetNPCTypeID()); + npc->ClearLootItems(); + npc->AddLootTable(); + if (npc->DropsGlobalLoot()) { + npc->CheckGlobalLootTables(); + } + + return; + } + if (!Strings::IsValidJson(loot_data)) { LogZoneState("Invalid JSON data for NPC [{}]", npc->GetNPCTypeID()); return; @@ -66,6 +96,11 @@ inline void LoadLootStateData(Zone *zone, NPC *npc, const std::string &loot_data return; } + // reset + npc->RemoveLootCash(); + npc->ClearLootItems(); + + // add loot npc->AddLootCash(l.copper, l.silver, l.gold, l.platinum); for (auto &e: l.entries) { @@ -76,7 +111,7 @@ inline void LoadLootStateData(Zone *zone, NPC *npc, const std::string &loot_data // dynamically added via AddItem if (e.lootdrop_id == 0) { - npc->AddItem(e.item_id, e.charges); + npc->AddItem(e.item_id, e.charges, true); continue; } @@ -175,6 +210,10 @@ inline void LoadNPCEntityVariables(NPC *n, const std::string &entity_variables) return; } + if (entity_variables.empty()) { + return; + } + if (!Strings::IsValidJson(entity_variables)) { LogZoneState("Invalid JSON data for NPC [{}]", n->GetNPCTypeID()); return; @@ -204,6 +243,10 @@ inline void LoadNPCBuffs(NPC *n, const std::string &buffs) return; } + if (buffs.empty()) { + return; + } + if (!Strings::IsValidJson(buffs)) { LogZoneState("Invalid JSON data for NPC [{}]", n->GetNPCTypeID()); return; @@ -236,6 +279,10 @@ inline std::vector GetLootdropIds(const std::vectorAssignWaypoints(s.grid, s.current_waypoint); } + n->SetResumedFromZoneSuspend(false); LoadLootStateData(zone, n, s.loot_data); + n->SetResumedFromZoneSuspend(true); LoadNPCEntityVariables(n, s.entity_variables); LoadNPCBuffs(n, s.buffs); @@ -348,7 +397,7 @@ bool Zone::LoadZoneState( auto spawn_states = ZoneStateSpawnsRepository::GetWhere( database, fmt::format( - "zone_id = {} AND instance_id = {}", + "zone_id = {} AND instance_id = {} ORDER BY spawn2_id", zoneid, zone->GetInstanceID() ) @@ -356,6 +405,16 @@ bool Zone::LoadZoneState( LogInfo("Loading zone state spawns for zone [{}] spawns [{}]", GetShortName(), spawn_states.size()); + if (spawn_states.empty()) { + return false; + } + + if (!IsZoneStateValid(spawn_states)) { + LogZoneState("Invalid zone state data for zone [{}]", GetShortName()); + ClearZoneState(zoneid, zone->GetInstanceID()); + return false; + } + std::vector lootdrop_ids = GetLootdropIds(spawn_states); zone->LoadLootDrops(lootdrop_ids); @@ -409,16 +468,14 @@ bool Zone::LoadZoneState( if (spawn_time_left == 0) { new_spawn->SetCurrentNPCID(s.npc_id); + new_spawn->SetResumedFromZoneSuspend(true); } spawn2_list.Insert(new_spawn); new_spawn->Process(); auto n = new_spawn->GetNPC(); if (n) { - n->ClearLootItems(); - if (s.grid > 0) { - n->AssignWaypoints(s.grid, s.current_waypoint); - } + LoadNPCState(zone, n, s); } } @@ -441,6 +498,13 @@ bool Zone::LoadZoneState( GravityBehavior::Water ); + npc->SetResumedFromZoneSuspend(true); + + // tag as corpse before we add to entity list to prevent quest triggers + if (s.is_corpse) { + npc->SetQueuedToCorpse(); + } + entity_list.AddNPC(npc, true, true); LoadNPCState(zone, npc, s); @@ -476,44 +540,43 @@ inline void SaveNPCState(NPC *n, ZoneStateSpawnsRepository::ZoneStateSpawns &s) variables[k] = n->GetEntityVariable(k); } - try { - std::ostringstream os; - { - cereal::JSONOutputArchiveSingleLine archive(os); - archive(variables); + if (!variables.empty()) { + try { + std::ostringstream os; + { + cereal::JSONOutputArchiveSingleLine archive(os); + archive(variables); + } + s.entity_variables = os.str(); + } + catch (const std::exception &e) { + LogZoneState("Failed to serialize entity variables for NPC [{}] [{}]", n->GetNPCTypeID(), e.what()); } - s.entity_variables = os.str(); - } - catch (const std::exception &e) { - LogZoneState("Failed to serialize entity variables for NPC [{}] [{}]", n->GetNPCTypeID(), e.what()); - return; } // buffs auto buffs = n->GetBuffs(); - if (!buffs) { - return; - } - - std::vector valid_buffs; - - for (int index = 0; index < n->GetMaxBuffSlots(); index++) { - if (buffs[index].spellid != 0 && buffs[index].spellid != 65535) { - valid_buffs.push_back(buffs[index]); + if (buffs) { + std::vector valid_buffs; + for (int index = 0; index < n->GetMaxBuffSlots(); index++) { + if (buffs[index].spellid != 0 && buffs[index].spellid != 65535) { + valid_buffs.push_back(buffs[index]); + } } - } - try { - std::ostringstream os = std::ostringstream(); - { - cereal::JSONOutputArchiveSingleLine archive(os); - archive(cereal::make_nvp("buffs", valid_buffs)); + if (!valid_buffs.empty()) { + try { + std::ostringstream os = std::ostringstream(); + { + cereal::JSONOutputArchiveSingleLine archive(os); + archive(cereal::make_nvp("buffs", valid_buffs)); + } + s.buffs = os.str(); + } + catch (const std::exception &e) { + LogZoneState("Failed to serialize buffs for NPC [{}] [{}]", n->GetNPCTypeID(), e.what()); + } } - s.buffs = os.str(); - } - catch (const std::exception &e) { - LogZoneState("Failed to serialize buffs for NPC [{}] [{}]", n->GetNPCTypeID(), e.what()); - return; } // rest @@ -568,7 +631,7 @@ void Zone::SaveZoneState() iterator.Advance(); } - // npcs that are not in the spawn2 list + // npc's that are not in the spawn2 list for (auto &n: entity_list.GetNPCList()) { // everything below here is dynamically spawned bool ignore_npcs = @@ -636,6 +699,11 @@ void Zone::SaveZoneState() ) ); + if (!IsZoneStateValid(spawns)) { + LogInfo("No valid zone state data to save"); + return; + } + ZoneStateSpawnsRepository::InsertMany(database, spawns); LogInfo("Saved [{}] zone state spawns", Strings::Commify(spawns.size()));