[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
This commit is contained in:
Chris Miles 2025-03-13 17:00:30 -05:00 committed by GitHub
parent f6b18fb003
commit 8d1a9efac9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 235 additions and 48 deletions

View File

@ -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,

View File

@ -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())
);
}
}
};

View File

@ -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()

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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()) {

View File

@ -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),

View File

@ -435,10 +435,8 @@ int QuestParserCollection::EventNPC(
std::vector<std::any>* 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);

View File

@ -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;

View File

@ -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();

View File

@ -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 {

View File

@ -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();
}

View File

@ -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<ZoneStateSpawnsRepository::ZoneStateSpawns> &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<uint32_t> GetLootdropIds(const std::vector<ZoneStateSpawnsRep
continue;
}
if (!Strings::IsValidJson(s.loot_data)) {
continue;
}
LootStateData l{};
try {
std::stringstream ss;
@ -273,7 +320,9 @@ inline void LoadNPCState(Zone *zone, NPC *n, ZoneStateSpawnsRepository::ZoneStat
n->AssignWaypoints(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<uint32_t> 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<Buffs_Struct> 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<Buffs_Struct> 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()));