feat: refuse stale character saves with a save generation

Every character gets a save generation in charxml. A world bumps it when it loads the character for
play, and every save from that world only goes through while the stored generation is still the
one it loaded or last saved (and moves it on). Dashboard edits, restores and maintenance writes bump
it too. A world that lost the character to another world (a disconnect noticed late, a zone
transfer, an instance migration) or to a dashboard edit can no longer overwrite the newer data: the
save is refused, logged and audited as stale_save_refused, the world stops saving that character
and a player still connected to it is disconnected with the save failure reason so they reload.

Fixes #639

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 19:10:16 -05:00
parent 17689cd663
commit 341ce89a6a
14 changed files with 280 additions and 6 deletions

View File

@@ -26,6 +26,7 @@
#include "ePlayerFlag.h"
#include "CDPlayerFlagsTable.h"
#include "EconomyLedger.h"
#include "eServerDisconnectIdentifiers.h"
Character::Character(LWOOBJID id, User* parentUser) {
//First load the name, etc:
@@ -52,8 +53,11 @@ void Character::UpdateInfoFromDatabase() {
m_PermissionMap = charInfo->permissionMap;
}
//Load the xmlData now:
m_XMLData = Database::Get()->GetCharacterXml(m_ID);
// Load the xmlData now. Loading takes the character over: saves from any server that loaded it before are refused.
auto saved = Database::Get()->ClaimCharacterXml(m_ID);
m_XMLData = saved ? std::move(saved->xml) : "";
m_SaveGeneration = saved ? saved->generation : 0;
m_SaveRefused = false;
if (m_XMLData.empty()) {
LOG("Character %s (%llu) has no xml data!", m_Name.c_str(), m_ID);
return;
@@ -365,10 +369,36 @@ void Character::WriteToDatabase() {
m_XMLData = printer.CStr();
//Finally, save to db:
Database::Get()->UpdateCharacterXml(m_ID, m_XMLData);
if (m_SaveRefused) {
LOG("Not saving character %llu:%s: newer data was saved elsewhere since it was loaded here", m_ID, m_Name.c_str());
return;
}
if (!Database::Get()->SaveCharacterXml(m_ID, m_XMLData, m_SaveGeneration)) {
OnStaleSave();
return;
}
m_SaveGeneration++;
DashboardNotify::Changed("characters", m_ID);
}
void Character::OnStaleSave() {
m_SaveRefused = true;
const auto stored = Database::Get()->GetCharacterSaveGeneration(m_ID);
LOG("Refused a stale save of character %llu:%s: this server has save generation %llu, the database %llu (another world "
"or the dashboard saved it since). The newer data is kept.", m_ID, m_Name.c_str(), m_SaveGeneration, stored);
const auto accountId = m_ParentUser ? m_ParentUser->GetAccountID() : 0;
const auto zone = Game::server ? Game::server->GetZoneID() : 0;
const auto instance = Game::server ? Game::server->GetInstanceID() : 0;
Database::Get()->InsertAuditLog(0, "World server", "stale_save_refused",
m_Name + ": a save from zone " + std::to_string(zone) + " instance " + std::to_string(instance) + " (generation " + std::to_string(m_SaveGeneration) +
") was refused because a newer one (generation " + std::to_string(stored) + ") is stored; the newer data was kept", accountId, m_ID);
// If the player is still connected here, what they do next would be lost too: send them out so they load the
// newer data. The disconnect is handled later, like any other.
if (m_ParentUser && Game::server && Game::server->IsConnected(m_ParentUser->GetSystemAddress())) {
Game::server->Disconnect(m_ParentUser->GetSystemAddress(), eServerDisconnectIdentifiers::SAVE_FAILURE);
}
}
void Character::SetPlayerFlag(const uint32_t flagId, const bool value) {
// If the flag is already set, we don't have to recalculate it
if (GetPlayerFlag(flagId) == value) return;

View File

@@ -31,12 +31,23 @@ public:
*/
void WriteToDatabase();
void SaveXMLToDatabase();
/**
* A save was refused by the stale save guard: log and audit it, save nothing more from here and send the player
* out if they are still connected here, so they load the newer data.
*/
void OnStaleSave();
void UpdateFromDatabase();
void SaveXmlRespawnCheckpoints();
void LoadXmlRespawnCheckpoints();
const std::string& GetXMLData() const { return m_XMLData; }
// The stale save guard's generation (see ICharXml::CharacterXml) and whether a save was refused because of it
uint64_t GetSaveGeneration() const { return m_SaveGeneration; }
void SetSaveGeneration(uint64_t generation) { m_SaveGeneration = generation; m_SaveRefused = false; }
bool IsSaveRefused() const { return m_SaveRefused; }
const tinyxml2::XMLDocument& GetXMLDoc() const { return m_Doc; }
void _setXmlDoc(tinyxml2::XMLDocument& doc) { doc.DeepCopy(&m_Doc); }
@@ -615,6 +626,18 @@ private:
*/
std::string m_XMLData;
/**
* The save generation this server loaded or last saved (see ICharXml::CharacterXml). Saves only go through while
* the database still has it.
*/
uint64_t m_SaveGeneration{};
/**
* A save was refused because someone newer (another world or the dashboard) saved this character since it was
* loaded here: no more saves from this server.
*/
bool m_SaveRefused{};
/**
* The last zone visited by the character that was not an instance zone
*/