portable: Phase 4 - real-server round trip stages, smoke harness, reconcile docs

Live stages drive real worldservers on both realms (A -> B -> play -> reconcile -> A -> B) with
independent expectations; portable_smoke example for the manual real-client test;
docs/PORTABLE_RECONCILE.md; reset script for the disposable realms.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-06 10:41:23 +05:00
1 parent 9210be98b5
commit 7f15ffe69f
5 files changed
+632

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
//! Command-line harness for the portable-characters round trip, for manual smoke tests against a **disposable** realm database.
//! It performs exactly the operations the Manager performs (`coa_core::portable::realm`), one per invocation, with a Manager
//! store kept in a directory of your choice. It is not a user interface and not part of the shipped Manager.
//!
//! ```text
//! portable_smoke --tools <mysql bin dir> --port <port> --store <dir> [--user root] [--realm coa] <command> [args]
//! the database password is read from the environment variable COA_DB_PASSWORD
//!
//! commands
//! list characters of the realm and why they cannot be exported
//! make-portable <server-id> <guid> export an offline character into the store (revision 1)
//! import <character-id> <server-id> <account> create the character on a STOPPED realm
//! begin-session <character-id> <server-id> freeze B0: run it after the realm's first load/save, before play
//! reconcile <character-id> <server-id> [--close] bring what was played back into the store
//! update <character-id> <server-id> bring the realm's own character to the newest revision, in place
//! recover <server-id> finish or abort interrupted imports and updates
//! show <character-id> revisions and realm bindings
//! ```
use std::path::PathBuf;
use coa_core::db::Db;
use coa_core::portable::realm::{self, ImportOptions};
use coa_core::portable::{CharacterId, Store};
use coa_core::realms::Mode;
fn usage() -> ! {
eprintln!("{}", include_str!("portable_smoke.rs").lines().take_while(|l| l.starts_with("//!")).map(|l| l.trim_start_matches("//!").trim_start_matches(' ')).collect::<Vec<_>>().join("\n"));
std::process::exit(2)
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut take = |name: &str, default: Option<&str>| -> Result<String, String> {
if let Some(i) = args.iter().position(|a| a == name) {
if i + 1 >= args.len() {
return Err(format!("{name} needs a value"));
}
let v = args.remove(i + 1);
args.remove(i);
return Ok(v);
}
default.map(str::to_string).ok_or_else(|| format!("{name} is required"))
};
let tools = PathBuf::from(take("--tools", None)?);
let port: u16 = take("--port", None)?.parse().map_err(|_| "--port is not a number".to_string())?;
let store_dir = PathBuf::from(take("--store", None)?);
let user = take("--user", Some("root"))?;
let realm_mode = match take("--realm", Some("coa"))?.as_str() {
"coa" => Mode::Coa,
other => return Err(format!("--realm {other}: only coa is supported (Wildcard portable transfer is not supported)")),
};
let password = std::env::var("COA_DB_PASSWORD").map_err(|_| "set COA_DB_PASSWORD".to_string())?;
// `with_tools` takes a 'static user name; this is a short-lived process
let user: &'static str = Box::leak(user.into_boxed_str());
let db = Db::with_tools(tools, port, user, &password, realm_mode);
let mut store = Store::open(&store_dir).map_err(|e| e.to_string())?;
let opts = ImportOptions { game_server_users: vec![user.to_string(), "acore".to_string()], ..ImportOptions::default() };
let id = |s: &str| -> Result<CharacterId, String> { s.parse().map_err(|_| format!("{s:?} is not a character id")) };
let command = if args.is_empty() { usage() } else { args.remove(0) };
match (command.as_str(), args.as_slice()) {
("list", []) => {
for c in realm::inspect_characters(&db).map_err(|e| e.to_string())? {
println!("{}\t{}\tlevel {}\trace {} class {}\t{}", c.local_guid, c.name, c.level, c.race, c.class, if c.eligible() { "ok".to_string() } else { c.blockers.iter().map(|b| b.to_string()).collect::<Vec<_>>().join("; ") });
}
}
("make-portable", [server, guid]) => {
let profile = store.default_profile().map_err(|e| e.to_string())?;
let made = realm::make_portable(&db, &mut store, profile, server, guid.parse().map_err(|_| "guid")?).map_err(|e| e.to_string())?;
println!("portable character {} (revision {}) {}", made.character_id, made.revision, made.warnings.join("; "));
}
("import", [character, server, account]) => {
let o = realm::import_character(&db, &mut store, id(character)?, server, account.parse().map_err(|_| "account")?, &opts).map_err(|e| e.to_string())?;
println!("imported as local character {} \"{}\" ({} items, {} pets, renamed: {})", o.local_guid, o.final_name, o.items, o.pets, o.renamed);
}
("begin-session", [character, server]) => {
let s = realm::begin_session(&db, &mut store, id(character)?, server).map_err(|e| e.to_string())?;
println!("baseline captured at canonical revision {}: {} item(s) held back by the realm, {} of its own", s.c0_revision, s.items_filtered, s.items_realm_local);
}
("reconcile", [character, server, rest @ ..]) => {
let close = rest.iter().any(|a| a == "--close");
let o = realm::reconcile_session(&db, &mut store, id(character)?, server, close, Some("portable_smoke")).map_err(|e| e.to_string())?;
println!("canonical revision {}{}", o.revision, if o.new_revision { " (new)" } else { " (unchanged)" });
for c in o.changes {
println!(" {c}");
}
}
("update", [character, server]) => {
let o = realm::update_realm_character(&db, &mut store, id(character)?, server, &opts).map_err(|e| e.to_string())?;
println!("{} (revision {} -> {}): {:?}", if o.updated { "updated in place" } else { "already up to date" }, o.from_revision, o.to_revision, o.counts);
}
("recover", [server]) => {
for r in realm::recover_imports(&db, &mut store, server, &opts).map_err(|e| e.to_string())? {
println!("{}: {:?}", r.import_id, r.resolution);
}
}
("show", [character]) => {
let c = id(character)?;
let r = store.character(c).map_err(|e| e.to_string())?;
println!("{} {} level {} revision {}", r.character_id, r.name, r.level, r.revision);
for m in store.server_mappings(c).map_err(|e| e.to_string())? {
println!(" on {}: local guid {} at revision {} ({:?})", m.server_id, m.local_guid, m.last_revision, m.state);
}
}
_ => usage(),
}
Ok(())
}
@@ -0,0 +1,346 @@
//! The complete A -> B -> play -> reconcile -> A -> B round trip against **real worldservers**, in stages.
//!
//! Each stage is an `#[ignore]`d test that runs with the realms' worldservers STOPPED; between stages a real worldserver of
//! the realm that is being worked on loads every character (`botcmd spawnbot <guid>` runs the real `Player::LoadFromDB`),
//! saves them (`saveall`) and shuts down. The driver is `tools/live_roundtrip.ps1`; this file holds the stages:
//!
//! ```text
//! rt1_join A fixture characters -> portable -> imported into B
//! [ server B ] real load + save of the arrivals = the realm's own normalisation (B0)
//! rt2_baseline_and_play capture B0, then play on B (SQL, valid real ids)
//! [ server B ] real load + save + a real `character level` = B1
//! rt3_reconcile reconcile B1 into canonical, update the ORIGINAL characters of A in place
//! [ server A ] real load + save of the updated originals
//! rt4_check_a A still loads and holds what it was given; play on A; reconcile; update B in place
//! [ server B ] real load + save of the updated characters
//! rt5_check_b final report
//! ```
//!
//! `COA_PORTABLE_RT_DIR` names the directory with the Manager store and the state files of the run.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::db::Db;
use crate::portable::ids::{CharacterId, PortableItemId};
use crate::portable::model::*;
use crate::portable::store::Store;
use super::live_import::{expected_after_a_trip, first, json_diff, make, number, opts, realms, reset_b, Realms, ACCOUNT, B};
use super::*;
const A: &str = "realm-a";
/// What the original characters of realm A are called in the store: the make_portable() binding.
const CHARACTERS: [u32; 3] = [1002, 1004, 1005];
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Joined {
a_guid: u32,
id: String,
b_guid: u32,
}
fn dir() -> Option<PathBuf> {
std::env::var("COA_PORTABLE_RT_DIR").ok().map(PathBuf::from)
}
fn joined(dir: &PathBuf) -> Vec<Joined> {
serde_json::from_slice(&std::fs::read(dir.join("joined.json")).expect("joined.json: run rt1 first")).unwrap()
}
fn id_of(j: &Joined) -> CharacterId {
j.id.parse().unwrap()
}
fn sql(db: &Db, text: &str) {
db.query(text).unwrap_or_else(|e| panic!("{e}\n{text}"));
}
fn spells_of(db: &Db, guid: u32) -> Vec<u32> {
db.query(&format!("SELECT spell FROM acore_characters.character_spell WHERE guid = {guid} ORDER BY spell")).unwrap().lines().filter_map(|l| l.trim().parse().ok()).collect()
}
fn world_local(db: &Db, guid: u32) -> Vec<String> {
let one = |sql: String| db.query(&sql).unwrap().trim().to_string();
vec![
one(format!("SELECT CONCAT_WS('|', guid, account, map, zone, position_x, position_y, position_z, orientation, instance_id) FROM acore_characters.characters WHERE guid = {guid}")),
one(format!("SELECT CONCAT_WS('|', mapId, zoneId, posX, posY, posZ) FROM acore_characters.character_homebind WHERE guid = {guid}")),
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', spell, item, time) ORDER BY spell) FROM acore_characters.character_spell_cooldown WHERE guid = {guid}")),
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', quest, time) ORDER BY quest) FROM acore_characters.character_queststatus_daily WHERE guid = {guid}")),
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', instance, permanent) ORDER BY instance) FROM acore_characters.character_instance WHERE guid = {guid}")),
]
}
fn free_slot(db: &Db, guid: u32) -> u8 {
let used: Vec<u64> = db.query(&format!("SELECT slot FROM acore_characters.character_inventory WHERE guid = {guid} AND bag = 0")).unwrap().lines().filter_map(|l| l.trim().parse().ok()).collect();
(23u8..=38).chain(39..=66).find(|s| !used.contains(&(*s as u64))).expect("a free slot")
}
fn realm_gives(db: &Db, guid: u32, entry: u64) -> u32 {
let item = number(db, "SELECT MAX(guid) + 1 FROM acore_characters.item_instance") as u32;
let slot = free_slot(db, guid);
let enchants = "0 ".repeat(36);
sql(
db,
&format!(
"INSERT INTO acore_characters.item_instance (guid, itemEntry, owner_guid, count, enchantments) VALUES ({item}, {entry}, {guid}, 1, '{enchants}');
INSERT INTO acore_characters.character_inventory (guid, bag, slot, item) VALUES ({guid}, 0, {slot}, {item});"
),
);
item
}
fn plain(m: &PortableCharacter) -> Vec<PortableItemId> {
let containers: std::collections::HashSet<PortableItemId> = m.items.iter().filter_map(|i| i.container).collect();
m.items.iter().filter(|i| i.container.is_none() && !containers.contains(&i.id) && i.slot >= 23).map(|i| i.id).collect()
}
fn local_item(store: &Store, id: CharacterId, server: &str, item: PortableItemId) -> Option<u32> {
store.item_mappings(id, server).unwrap().into_iter().find(|m| m.portable_item_id == item && m.active).map(|m| m.local_item_guid)
}
fn read_server(r: &Realms, store: &Store, id: CharacterId, server: &str) -> Exported {
let db = if server == B { &r.b } else { &r.a };
let guid = store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == server).unwrap().local_guid;
export_character_with_pets(db, guid, Some(id), &store.active_item_lookup(id, server).unwrap(), &store.active_pet_lookup(id, server).unwrap()).unwrap_or_else(|e| panic!("export of {guid} from {server}: {e}"))
}
fn local_guid(store: &Store, id: CharacterId, server: &str) -> u32 {
store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == server).unwrap().local_guid
}
fn say(dir: &PathBuf, line: impl AsRef<str>) {
use std::io::Write;
println!("{}", line.as_ref());
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("report.txt")).unwrap();
writeln!(f, "{}", line.as_ref()).unwrap();
}
#[test]
#[ignore]
fn rt1_join() {
let (Some(r), Some(dir)) = (realms(), dir()) else { return };
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
reset_b(&r.b);
let mut store = Store::open(&dir).unwrap();
let profile = store.default_profile().unwrap();
let mut out = Vec::new();
for a_guid in CHARACTERS {
let id = make(&r, &mut store, profile, a_guid);
let o = import_character(&r.b, &mut store, id, B, ACCOUNT, &opts()).unwrap_or_else(|e| panic!("import of {a_guid}: {e}"));
say(&dir, format!("rt1: A {a_guid} -> portable {id} -> B {} (\"{}\", {} items, {} pets)", o.local_guid, o.final_name, o.items, o.pets));
out.push(Joined { a_guid, id: id.to_string(), b_guid: o.local_guid });
}
std::fs::write(dir.join("joined.json"), serde_json::to_vec(&out).unwrap()).unwrap();
}
#[test]
#[ignore]
fn rt2_baseline_and_play() {
let (Some(r), Some(dir)) = (realms(), dir()) else { return };
let mut store = Store::open(&dir).unwrap();
let joined = joined(&dir);
for j in &joined {
let id = id_of(j);
let start = begin_session(&r.b, &mut store, id, B).unwrap_or_else(|e| panic!("baseline of {}: {e}", j.b_guid));
say(
&dir,
format!(
"rt2: B0 of {} captured: {} item(s) held back by the realm, {} of its own, {} pet(s) held back, {} of its own; warnings: {}",
j.b_guid,
start.items_filtered,
start.items_realm_local,
start.pets_filtered,
start.pets_realm_local,
start.warnings.len()
),
);
let c0 = store.synced_model(id, B).unwrap().unwrap();
let b0 = store.open_baseline(id, B).unwrap().unwrap().b0;
// what the real server did to the arrival, as the three-way model sees it
let mut diffs = Vec::new();
json_diff("", &serde_json::to_value(&c0).unwrap(), &serde_json::to_value(&b0).unwrap(), &mut diffs);
say(&dir, format!("rt2: the realm's own normalisation of {}: {} difference(s) between C0 and B0{}", j.b_guid, diffs.len(), if diffs.is_empty() { String::new() } else { format!("\n {}", diffs.iter().take(40).cloned().collect::<Vec<_>>().join("\n ")) }));
}
// ---- play on B: valid real ids only -----------------------------------------------------------------------------------------
let g = |i: usize| joined[i].b_guid;
// 1002: money, a sold item, loot, a spell the character did not have, a reputation point
{
let id = id_of(&joined[0]);
let c0 = store.synced_model(id, B).unwrap().unwrap();
let candidates: Vec<PortableItemId> = plain(&c0).into_iter().filter(|i| local_item(&store, id, B, *i).is_some()).collect();
let sold = local_item(&store, id, B, candidates[2]).unwrap();
sql(&r.b, &format!("DELETE FROM acore_characters.character_inventory WHERE guid = {0} AND item = {1}; DELETE FROM acore_characters.item_instance WHERE guid = {1} AND owner_guid = {0};", g(0), sold));
let loot_entry = c0.items.iter().find(|i| i.id == candidates[4]).unwrap().entry.id();
realm_gives(&r.b, g(0), loot_entry);
let extra = spells_of(&r.a, 1004).into_iter().find(|s| !spells_of(&r.b, g(0)).contains(s)).unwrap();
sql(&r.b, &format!("UPDATE acore_characters.characters SET money = money + 777000, totalKills = totalKills + 12 WHERE guid = {0}; INSERT INTO acore_characters.character_spell (guid, spell, specMask) VALUES ({0}, {1}, 255);", g(0), extra));
sql(&r.b, &format!("UPDATE acore_characters.character_reputation SET standing = standing + 100 WHERE guid = {} LIMIT 1", g(0)));
say(&dir, format!("rt2: played on B {}: +777000 money, +12 kills, sold item {sold}, looted entry {loot_entry}, learned spell {extra}, +100 reputation", g(0)));
}
// 1004: a carried build/spec setting changes and honor accumulates
{
let row = first(&r.b, &format!("SELECT CONCAT(source, '|', data) FROM acore_characters.character_settings WHERE guid = {} AND source LIKE 'core.ascension_build.%' LIMIT 1", g(1)));
let (source, data) = row.split_once('|').expect("a carried build setting");
let mut words: Vec<u64> = data.split_whitespace().map(|w| w.parse().unwrap()).collect();
*words.last_mut().unwrap() += 1;
let text: String = words.iter().map(|w| format!("{w} ")).collect();
sql(&r.b, &format!("UPDATE acore_characters.character_settings SET data = '{text}' WHERE guid = {} AND source = '{source}'", g(1)));
sql(&r.b, &format!("UPDATE acore_characters.characters SET totalHonorPoints = totalHonorPoints + 321, money = money + 5 WHERE guid = {}", g(1)));
say(&dir, format!("rt2: played on B {}: setting {source} changed, +321 honor, +5 money", g(1)));
}
// 1005: pets
{
let numbers: Vec<u32> = store.pet_mappings(id_of(&joined[2]), B).unwrap().iter().map(|m| m.local_pet_number).collect();
sql(&r.b, &format!("UPDATE acore_characters.character_pet SET level = level + 1, exp = 4321, name = 'Played' WHERE id = {}", numbers[0]));
sql(&r.b, &format!("DELETE FROM acore_characters.character_pet WHERE id = {0}; DELETE FROM acore_characters.pet_spell WHERE guid = {0};", numbers[1]));
sql(&r.b, &format!("UPDATE acore_characters.characters SET money = money + 99 WHERE guid = {}", g(2)));
say(&dir, format!("rt2: played on B {}: pet {} levelled and renamed, pet {} abandoned, +99 money", g(2), numbers[0], numbers[1]));
}
}
#[test]
#[ignore]
fn rt3_reconcile_and_update_a() {
let (Some(r), Some(dir)) = (realms(), dir()) else { return };
let mut store = Store::open(&dir).unwrap();
let joined = joined(&dir);
sql(&r.a, "UPDATE acore_characters.characters SET online = 0");
for (i, j) in joined.iter().enumerate() {
let id = id_of(j);
let c0 = store.synced_model(id, B).unwrap().unwrap();
let b0 = store.open_baseline(id, B).unwrap().unwrap().b0;
let b1 = read_server(&r, &store, id, B).model;
let out = reconcile_session(&r.b, &mut store, id, B, false, Some("rt3 checkpoint")).unwrap_or_else(|e| panic!("reconcile of {}: {e}", j.b_guid));
let canonical = store.load_current(id).unwrap();
say(&dir, format!("rt3: reconciled {} -> canonical revision {} ({} change(s) applied, {} left alone)\n {}", j.b_guid, out.revision, out.changes.len(), out.left_alone.len(), out.changes.iter().take(30).cloned().collect::<Vec<_>>().join("\n ")));
// independent expectations from the three snapshots (not from the merge engine)
let level_expected = if b1.progression.level != b0.progression.level { b1.progression.level } else { c0.progression.level };
assert_eq!(canonical.progression.level, level_expected, "{} level", j.b_guid);
if b1.progression.level == b0.progression.level {
let money = (c0.progression.money as i64 + b1.progression.money as i64 - b0.progression.money as i64).clamp(0, u32::MAX as i64) as u32;
assert_eq!(canonical.progression.money, money, "{} money: C0 + (B1 - B0), once", j.b_guid);
}
// items the realm showed at B0 have stable portable ids; items first seen in B1 get their ids when they are exported, so
// those are compared by entry
let ids = |m: &PortableCharacter| -> std::collections::BTreeSet<PortableItemId> { m.items.iter().map(|i| i.id).collect() };
let (c, b0i, b1i) = (ids(&c0), ids(&b0), ids(&b1));
let lost: std::collections::BTreeSet<_> = b0i.difference(&b1i).filter(|i| c.contains(i)).copied().collect();
let canonical_ids = ids(&canonical);
let carried: std::collections::BTreeSet<_> = canonical_ids.iter().filter(|i| c.contains(i)).copied().collect();
assert_eq!(carried, c.difference(&lost).copied().collect(), "{}: C0 items = kept, minus what the player lost; what the realm filtered at B0 stays", j.b_guid);
let entries = |m: &PortableCharacter, keep: &dyn Fn(&PortableItemId) -> bool| -> Vec<u64> {
let mut v: Vec<u64> = m.items.iter().filter(|i| keep(&i.id)).map(|i| i.entry.id()).collect();
v.sort_unstable();
v
};
assert_eq!(entries(&canonical, &|i| !c.contains(i)), entries(&b1, &|i| !b0i.contains(i)), "{}: what is new in the canonical character is exactly what the player gained", j.b_guid);
let spell_set = |m: &PortableCharacter| -> std::collections::BTreeSet<u32> { m.build.spells.iter().map(|(s, _)| *s).collect() };
let (cs, b0s, b1s) = (spell_set(&c0), spell_set(&b0), spell_set(&b1));
let expected_spells: std::collections::BTreeSet<u32> = cs.iter().chain(b1s.difference(&b0s)).copied().filter(|s| !(b0s.contains(s) && !b1s.contains(s))).collect();
assert_eq!(spell_set(&canonical), expected_spells, "{}: spells = C0 + learned - unlearned; the realm's own default spells are not progress", j.b_guid);
// a repeated checkpoint does not count anything twice
let again = reconcile_session(&r.b, &mut store, id, B, false, None).unwrap();
assert_eq!(again.revision, out.revision, "{}: the same B1 makes no new revision", j.b_guid);
// close the session
let closed = reconcile_session(&r.b, &mut store, id, B, true, None).unwrap();
assert_eq!(closed.revision, out.revision);
assert!(store.open_baseline(id, B).unwrap().is_none());
// ---- update the ORIGINAL character of realm A in place ----------------------------------------------------------------
let a_before = world_local(&r.a, j.a_guid);
let chars_before = number(&r.a, "SELECT COUNT(*) FROM acore_characters.characters");
let upd = update_realm_character(&r.a, &mut store, id, A, &opts()).unwrap_or_else(|e| panic!("update of A {}: {e}", j.a_guid));
assert!(upd.updated, "{}", j.a_guid);
say(&dir, format!("rt3: updated A {} in place to revision {}: {:?}", j.a_guid, upd.to_revision, upd.counts));
assert_eq!(world_local(&r.a, j.a_guid), a_before, "A {}: position, homebind and local data are untouched", j.a_guid);
assert_eq!(number(&r.a, "SELECT COUNT(*) FROM acore_characters.characters"), chars_before);
assert_eq!(local_guid(&store, id, A), j.a_guid);
let back = read_server(&r, &store, id, A).model;
let mut diffs = Vec::new();
json_diff("", &serde_json::to_value(&canonical).unwrap(), &serde_json::to_value(&back).unwrap(), &mut diffs);
assert!(diffs.is_empty(), "A {} holds the canonical character: {diffs:#?}", j.a_guid);
let _ = i;
}
say(&dir, "rt3: done");
}
#[test]
#[ignore]
fn rt4_check_a_then_play_on_a_and_update_b() {
let (Some(r), Some(dir)) = (realms(), dir()) else { return };
let mut store = Store::open(&dir).unwrap();
let joined = joined(&dir);
for j in &joined {
let id = id_of(j);
let canonical = store.load_current(id).unwrap();
let online = number(&r.a, &format!("SELECT online FROM acore_characters.characters WHERE guid = {}", j.a_guid));
let back = export_character_with_pets(&r.a, j.a_guid, Some(id), &store.active_item_lookup(id, A).unwrap(), &store.active_pet_lookup(id, A).unwrap());
match back {
Err(e) => say(&dir, format!("rt4: A {} (online {online}) cannot be exported after the real server's save: {e}", j.a_guid)),
Ok(e) => {
let mut diffs = Vec::new();
json_diff("", &serde_json::to_value(&canonical).unwrap(), &serde_json::to_value(&e.model).unwrap(), &mut diffs);
say(&dir, format!("rt4: A {} after a REAL load + save: {} difference(s) from canonical revision {}{}", j.a_guid, diffs.len(), store.character(id).unwrap().revision, if diffs.is_empty() { String::new() } else { format!("\n {}", diffs.iter().take(40).cloned().collect::<Vec<_>>().join("\n ")) }));
assert_eq!(e.model.progression.money, canonical.progression.money, "A {} money survived the real load", j.a_guid);
assert_eq!(e.model.progression.level, canonical.progression.level);
}
}
}
// play on A: a session of its own on the original characters
for j in &joined {
let id = id_of(j);
let s = begin_session(&r.a, &mut store, id, A).unwrap_or_else(|e| panic!("baseline on A {}: {e}", j.a_guid));
say(&dir, format!("rt4: baseline on A {}: {} filtered, {} realm-local item(s)", j.a_guid, s.items_filtered, s.items_realm_local));
sql(&r.a, &format!("UPDATE acore_characters.characters SET money = money + 1000 WHERE guid = {}", j.a_guid));
}
for j in &joined {
let id = id_of(j);
let before = store.load_current(id).unwrap();
let out = reconcile_session(&r.a, &mut store, id, A, true, Some("rt4 session on A")).unwrap();
let after = store.load_current(id).unwrap();
assert_eq!(after.progression.money, before.progression.money + 1000, "A {}: +1000 once", j.a_guid);
say(&dir, format!("rt4: reconciled A {} -> canonical revision {}", j.a_guid, out.revision));
}
// back to B: the SAME local characters, updated in place
sql(&r.b, "UPDATE acore_characters.characters SET online = 0");
for j in &joined {
let id = id_of(j);
let local_before = world_local(&r.b, j.b_guid);
let chars_before = number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters");
let held = number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = {}", j.b_guid));
let upd = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap_or_else(|e| panic!("update of B {}: {e}", j.b_guid));
assert!(upd.updated);
say(&dir, format!("rt4: updated B {} IN PLACE (revision {} -> {}): {:?}", j.b_guid, upd.from_revision, upd.to_revision, upd.counts));
assert_eq!(world_local(&r.b, j.b_guid), local_before, "B {}: the realm's own placement survived the update", j.b_guid);
assert_eq!(number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters"), chars_before, "never deleted or re-created");
assert_eq!(local_guid(&store, id, B), j.b_guid);
let _ = held;
let canonical = store.load_current(id).unwrap();
let back = read_server(&r, &store, id, B).model;
assert_eq!(back.progression.money, canonical.progression.money, "B {} money", j.b_guid);
}
}
#[test]
#[ignore]
fn rt5_check_b() {
let (Some(r), Some(dir)) = (realms(), dir()) else { return };
let store = Store::open(&dir).unwrap();
for j in &joined(&dir) {
let id = id_of(j);
let canonical = store.load_current(id).unwrap();
let e = read_server(&r, &store, id, B);
let mut diffs = Vec::new();
json_diff("", &serde_json::to_value(expected_after_a_trip(&canonical, &e.model)).unwrap(), &serde_json::to_value(&e.model).unwrap(), &mut diffs);
say(&dir, format!("rt5: B {} after the update and a REAL load + save: {} difference(s) from canonical revision {}{}", j.b_guid, diffs.len(), store.character(id).unwrap().revision, if diffs.is_empty() { String::new() } else { format!("\n {}", diffs.iter().take(40).cloned().collect::<Vec<_>>().join("\n ")) }));
assert_eq!(e.model.progression.money, canonical.progression.money);
assert!(store.open_imports(B).unwrap().is_empty() && store.open_imports(A).unwrap().is_empty());
}
say(&dir, "rt5: done");
}
@@ -196,6 +196,8 @@ mod live;
#[cfg(test)]
mod live_import;
#[cfg(test)]
mod live_roundtrip;
#[cfg(test)]
mod live_update;
#[cfg(test)]
mod tests;
@@ -965,3 +965,30 @@ fn an_update_is_journaled_like_an_import_and_changes_the_mappings_only_when_fini
let r = store.finish_import(second.import_id, ImportAllocation { local_guid: 4040, item_base: 0, pet_base: 0 });
assert!(r.is_err() && store.import_entry(second.import_id).unwrap().state == ImportState::Prepared);
}
#[test]
fn baselines_mappings_and_the_synced_snapshot_survive_reopening_the_file() {
let dir = std::env::temp_dir().join(format!("coa-portable-reopen-{}", uuid::Uuid::new_v4()));
let (id, c0, b0) = {
let mut store = Store::open(&dir).unwrap();
let profile = store.default_profile().unwrap();
let id = store.create_character(profile, fixtures::geared_level_eighty(), "realm-a").unwrap();
let items = plan_of_current(&store, id);
let pets = planned_pets_of(&store, id);
let ticket = store.begin_import(id, "realm-b", 1, &items, &pets).unwrap();
store.finish_import(ticket.import_id, alloc(1013)).unwrap();
let c0 = store.load_current(id).unwrap();
let (b0, _, _) = b0_of(&c0);
store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) }).unwrap();
(id, c0, b0)
};
let mut store = Store::open(&dir).unwrap();
let baseline = store.open_baseline(id, "realm-b").unwrap().expect("the open session survives a restart");
assert_eq!((baseline.c0, baseline.b0), (c0.clone(), b0));
assert_eq!(store.synced_model(id, "realm-b").unwrap().unwrap(), c0);
assert!(store.item_mappings(id, "realm-b").unwrap().iter().any(|m| m.presence == Presence::Filtered));
assert_eq!(store.pet_mappings(id, "realm-b").unwrap().len(), c0.pets.len());
assert!(store.close_baseline(id, "realm-b").unwrap());
drop(store);
let _ = std::fs::remove_dir_all(&dir);
}
+141
View File
@@ -0,0 +1,141 @@
# Round trips: reconciling a session and updating a realm character in place (Phase 4)
Code: `crates/coa-core/src/portable/{merge.rs, realm/{reconcile,update}.rs, store/{sync,journal}.rs}`,
migration `migrations/004_reconciliation.sql`. CoA only. **Wildcard portable transfer is not supported**: every Wildcard
setting stays quarantined until a separate key-by-key audit, and CoA <-> Wildcard transfers are refused.
Realms are working copies; the Manager's canonical store owns the character. A realm changes a character on its own the
moment it loads it (default spells, honor resets, items it cannot place, ...). Phase 4 makes sure that **only what the
player did** travels back, and that **only the portable subset** is written into a realm that has the character already.
## The three-way model
```text
C0 canonical when the character joined the realm (= the snapshot the realm was last synced with)
B0 the realm's own first load/save of it (normalisation done, nothing played yet)
B1 the realm now / at the checkpoint
canonical' = merge3(target = C0, base = B0, ours = B1) only B0 -> B1 is progress
realm' = merge3(target = realm now, base = synced snapshot, ours = canonical') in place
```
`merge3(target, base, ours)` applies **what changed between `base` and `ours`** onto `target`. Differences that exist between
`target` and `base` already (the realm's normalisation, or in the other direction the realm's own additions) are *left
exactly as they are*: automatic destination normalisation can never overwrite canonical state.
| kind | rule |
|---|---|
| scalars | `ours` wins where `base != ours` |
| accumulators (money, honor totals, kills, arena points, xp inside a level) | the **delta** `ours - base` is added, clamped |
| bit sets (currencies, titles, explored zones, taxi nodes) | bits gained are added, bits lost removed |
| keyed collections (spells, skills, reputation, quests, action buttons, carried settings) | keys added / removed / changed in `ours` are applied |
| items, pets | matched by **portable id**, see below |
| stock talents, extensions (quarantined settings) | never touched |
A **session** reconcile is lenient (`ours` wins); an **in-place update** is strict: if the realm and the canonical character both
moved the same thing away from the synced snapshot, and disagree, the update is refused with the list of conflicts and
**nothing is written**. Accumulators never conflict (both deltas are added).
### Realm-filtered state is not deletion
Per realm and per item/pet the store keeps a mapping with a **presence**:
| presence | meaning | at reconcile |
|---|---|---|
| `present` | canonical owns it, the realm shows it | absent in B1 and present in B0 = the player got rid of it: removed, mapping retired |
| `filtered` | canonical owns it, the realm does **not** show it (held back / mailed before B0) | **kept**; the mapping is *not* retired; if it shows up again it is taken |
| `realm_local` | the realm shows it, canonical does not own it (starter items, realm grants) | never merged, never written back |
So an item the realm moved out of the inventory before `B0` stays canonical, and an item the player destroyed after `B0` does
not. Item identity is `entry + random property` (the crafter is excluded); pet identity is `entry + pet type + summoning
spell`. A recycled local guid / pet number never inherits an old portable id (`guid_reused`).
## What is persisted
* `item_mapping.presence`, `pet_mapping` (stable per-realm pet ids with the same lifecycle as item mappings);
* `character_server_mapping.synced_hash/synced_payload`: the canonical snapshot a realm was last brought to;
* `realm_baseline`: `C0` and `B0` of the open session (one open session per character and realm), the session's `head_revision`;
* `import_journal` kind `update`, with the items/pets the update adds and retires.
## Session flow
1. `begin_session` (**after the realm's first load/save and before the character is played**): exports the realm's character
as `B0`, classifies filtered / realm-local items and pets, persists `C0 + B0`. The join flow must own that ordering: a
baseline taken later would count the progress made so far as the realm's own normalisation.
2. The player plays; realm offline or the character offline.
3. `reconcile_session(close = false)` = checkpoint, `(close = true)` = end of session. Every call applies the **whole**
`B0 -> B1` delta to `C0` (not to the previous checkpoint), so repeating it never counts progress twice. A session that
changed nothing makes no new revision. The canonical character must still be the session's own last output; if another
realm moved it in between the call is refused (`StaleRevision`) and nothing is committed.
## In-place update
`update_realm_character`: reads the realm's character (`A_now`), merges `(A_now, synced, canonical, strict)`, preflights
(realm stopped, character exists, new item/creature entries known to the realm), journals, runs one MySQL transaction, records
the mappings and the new synced snapshot in one local transaction.
* the **same local character** is updated: guid, account, position, homebind, auras, cooldowns, instance saves and every row
the model does not carry are never touched; nothing is deleted or re-created;
* only the **columns and rows that differ** are written: the script is a delta (`UPDATE` of changed columns, `DELETE` +
`INSERT` of changed keyed rows, items/pets addressed by their mapped local ids; new items/pets get ids allocated inside the
transaction above everything that names an id);
* moved items leave their inventory places before anything takes them (a swap cannot collide);
* items the realm filtered are not resurrected; items/pets the realm added stay;
* race, class and gender cannot change in place (refused);
* the transaction asserts its own result (item/inventory/pet counts relative to the start, no new orphan inventory rows) and
fails whole, leaving the realm untouched;
* a session open on that realm blocks the update (`SessionOpen`); a running realm and an online character block it too.
### Crash recovery
Like an import, an update is journaled `prepared` before the realm transaction and carries a marker row
`coa.portable.import = <nonce> <revision>` plus `coa.portable.alloc = <item_base> <pet_base>` written **inside** the realm
transaction. `recover_imports` finds the marker: present = committed (mappings, synced snapshot and journal are finished from
the allocation row, after verifying the new items/pets exist), absent = never committed (aborted). Inconsistencies are flagged
`needs_attention`, never guessed.
## What was verified
Unit (`merge/tests.rs`, `store/tests.rs`, `realm/update_tests.rs`): normalisation alone changes nothing; only the session delta is
applied; repeating is idempotent; money delta; level-up; bit sets; filtered vs deleted items; reappearing items; slot
collisions; pet identity; extensions untouched; 300 rounds of random normalisation; persistence of baselines, presence, pet
mappings and the update journal; the generated update script (only changed columns, never position/homebind/`characters`
insert or delete, hex-only hostile text, deletes before inserts).
Live (`realm/live_update.rs`, two disposable MySQL servers): in-place update keeps all local state and touches only the tables
the change is about; an injected failure rolls everything back; lost answer after commit is recovered, a never-run update is
aborted; running realm / online character / open session / divergence are refused with nothing written; filtered items
stay canonical while deleted ones go, checkpoints never count twice, the realm's own items survive updates; pets keep ids
across sessions and recycled numbers do not inherit them.
Real worldservers (`realm/live_roundtrip.rs`, driven stage by stage): CoA characters from the schema fixture
A -> portable -> B, real load/save (`botcmd spawnbot` + `saveall`), `B0`, play (SQL with real ids plus a real `character level`
GM command), real load/save, reconcile, update of the original characters of A in place, real load/save on A, play on A,
reconcile, update of B in place, real load/save on B. Independent expectations were computed from the three snapshots
(not from the merge engine) and asserted for level, money, items and spells; local state before/after every in-place update
was compared.
Findings from the real servers: a real CoA worldserver changed a freshly imported fixture character substantially on its own
(55 of 60 items held back, synthetic spells deleted, default spells/skills/reputation/taxi added, honor and title reset);
none of that reached the canonical character. The fixture's synthetic `character_talent` rows make a real worldserver
assert (`Player::_LoadTalents`) for that one character on its **source** realm; this is a property of the fixture, not of the
update (the importer and updater never write `character_talent`). Items a realm grants **after** `B0` (a class kit
re-granted at a level change, loot) are indistinguishable from earned items and are carried; only what existed at `B0` is
normalisation.
## Manual real-client smoke test (required before the gate is final)
`cargo run -p coa-core --example portable_smoke -- ...` (see the header of `examples/portable_smoke.rs`) performs one
operation per call against any database reachable through the MySQL client tools, with a Manager store in a directory of
your choice (`COA_DB_PASSWORD` in the environment). Use **copies**, never the live realm:
1. Make a disposable copy of the realm database (or of the whole install) that contains a real CoA character; run a second
disposable realm (an empty install works, it needs a game account).
2. `list` -> `make-portable <realm-1> <guid>` -> note the printed character id. Stop both realms.
3. `import <id> <realm-2> <account>`; start realm 2; log in with the real client; look at the character; log out; stop it.
4. `begin-session <id> <realm-2>`; start realm 2; play for a few minutes (earn money, change gear, drop or sell an item, pick
one up, learn something, maybe level); log out; stop it.
5. `reconcile <id> <realm-2> --close`; read the printed changes; `update <id> <realm-1>` (stopped realm); start realm 1 and
log in with the real client: the character must be the original one with what was played on realm 2.
6. Optionally play on realm 1 and `begin-session`/`reconcile`/`update <id> <realm-2>` back: it must be the same local
character on realm 2 at the same place.