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

@@ -2,6 +2,7 @@
#define __ICHARXML__H__
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
@@ -10,9 +11,32 @@ public:
// Get the character xml for the given character id.
virtual std::string GetCharacterXml(const LWOOBJID charId) = 0;
// Update the character xml for the given character id.
// Overwrite the character xml for the given character id (dashboard edits, restores, maintenance). Bumps the save
// generation, so a world still holding the older version can't save over this one.
virtual void UpdateCharacterXml(const LWOOBJID charId, const std::string_view lxfml) = 0;
/**
* Stale save guard. Every write to a character's xml bumps its save generation: a world loading the character to
* play it, a world saving it and the dashboard writing it. A world keeps the generation it loaded (or last saved)
* and only saves over that one, so a world that lost the character to another world or to the dashboard can't
* overwrite the newer data.
*/
struct CharacterXml {
std::string xml;
uint64_t generation{};
};
// Take over the character for playing: bump its save generation and return the xml with the new generation.
// nullopt when the character has no xml.
virtual std::optional<CharacterXml> ClaimCharacterXml(const LWOOBJID charId) = 0;
// Save if the stored generation is still `generation`: the xml is written, the generation becomes generation + 1
// and true is returned. Otherwise nothing is written and false is returned (someone newer saved it).
virtual bool SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) = 0;
// The stored save generation (0 when the character has no xml)
virtual uint64_t GetCharacterSaveGeneration(const LWOOBJID charId) = 0;
// Insert the character xml for the given character id.
virtual void InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) = 0;