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;

View File

@@ -75,6 +75,9 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::optional<ICharXml::CharacterXml> ClaimCharacterXml(const LWOOBJID charId) override;
bool SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) override;
uint64_t GetCharacterSaveGeneration(const LWOOBJID charId) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;

View File

@@ -11,7 +11,26 @@ std::string MySQLDatabase::GetCharacterXml(const LWOOBJID charId) {
}
void MySQLDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string_view lxfml) {
ExecuteUpdate("UPDATE charxml SET xml_data = ? WHERE id = ?;", lxfml, charId);
ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = save_generation + 1 WHERE id = ?;", lxfml, charId);
}
std::optional<ICharXml::CharacterXml> MySQLDatabase::ClaimCharacterXml(const LWOOBJID charId) {
ExecuteUpdate("UPDATE charxml SET save_generation = save_generation + 1 WHERE id = ?;", charId);
// Read both from one row, so a write in between still leaves a matching pair
auto result = ExecuteSelect("SELECT xml_data, save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
if (!result->next()) return std::nullopt;
return CharacterXml{ result->getString("xml_data").c_str(), static_cast<uint64_t>(result->getInt64("save_generation")) };
}
bool MySQLDatabase::SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) {
// The generation always changes, so a matching row is always counted as affected
return ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = ? WHERE id = ? AND save_generation = ?;",
lxfml, static_cast<int64_t>(generation + 1), charId, static_cast<int64_t>(generation)) > 0;
}
uint64_t MySQLDatabase::GetCharacterSaveGeneration(const LWOOBJID charId) {
auto result = ExecuteSelect("SELECT save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
return result->next() ? static_cast<uint64_t>(result->getInt64("save_generation")) : 0;
}
void MySQLDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {

View File

@@ -59,6 +59,9 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::optional<ICharXml::CharacterXml> ClaimCharacterXml(const LWOOBJID charId) override;
bool SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) override;
uint64_t GetCharacterSaveGeneration(const LWOOBJID charId) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;

View File

@@ -11,7 +11,25 @@ std::string SQLiteDatabase::GetCharacterXml(const LWOOBJID charId) {
}
void SQLiteDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string_view lxfml) {
ExecuteUpdate("UPDATE charxml SET xml_data = ? WHERE id = ?;", lxfml, charId);
ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = save_generation + 1 WHERE id = ?;", lxfml, charId);
}
std::optional<ICharXml::CharacterXml> SQLiteDatabase::ClaimCharacterXml(const LWOOBJID charId) {
ExecuteUpdate("UPDATE charxml SET save_generation = save_generation + 1 WHERE id = ?;", charId);
// Read both from one row, so a write in between still leaves a matching pair
auto [_, result] = ExecuteSelect("SELECT xml_data, save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
if (result.eof()) return std::nullopt;
return CharacterXml{ result.getStringField("xml_data"), static_cast<uint64_t>(result.getInt64Field("save_generation")) };
}
bool SQLiteDatabase::SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) {
return ExecuteUpdate("UPDATE charxml SET xml_data = ?, save_generation = ? WHERE id = ? AND save_generation = ?;",
lxfml, static_cast<int64_t>(generation + 1), charId, static_cast<int64_t>(generation)) > 0;
}
uint64_t SQLiteDatabase::GetCharacterSaveGeneration(const LWOOBJID charId) {
auto [_, result] = ExecuteSelect("SELECT save_generation FROM charxml WHERE id = ? LIMIT 1;", charId);
return result.eof() ? 0 : static_cast<uint64_t>(result.getInt64Field("save_generation"));
}
void SQLiteDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {

View File

@@ -38,6 +38,9 @@ class TestSQLDatabase : public GameDatabase {
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::optional<ICharXml::CharacterXml> ClaimCharacterXml(const LWOOBJID charId) override { return std::nullopt; };
bool SaveCharacterXml(const LWOOBJID charId, const std::string_view lxfml, const uint64_t generation) override { return true; };
uint64_t GetCharacterSaveGeneration(const LWOOBJID charId) override { return 0; };
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;
void SetCharacterName(const LWOOBJID characterId, const std::string_view name) override;