diff --git a/crates/coa-core/examples/portable_smoke.rs b/crates/coa-core/examples/portable_smoke.rs new file mode 100644 index 0000000..b60b59d --- /dev/null +++ b/crates/coa-core/examples/portable_smoke.rs @@ -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 --port --store [--user root] [--realm coa] [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 export an offline character into the store (revision 1) +//! import create the character on a STOPPED realm +//! begin-session freeze B0: run it after the realm's first load/save, before play +//! reconcile [--close] bring what was played back into the store +//! update bring the realm's own character to the newest revision, in place +//! recover finish or abort interrupted imports and updates +//! show 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::>().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 = std::env::args().skip(1).collect(); + let mut take = |name: &str, default: Option<&str>| -> Result { + 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 { 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::>().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(()) +} diff --git a/crates/coa-core/src/portable/realm/live_roundtrip.rs b/crates/coa-core/src/portable/realm/live_roundtrip.rs new file mode 100644 index 0000000..59b7e3e --- /dev/null +++ b/crates/coa-core/src/portable/realm/live_roundtrip.rs @@ -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 ` 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 { + std::env::var("COA_PORTABLE_RT_DIR").ok().map(PathBuf::from) +} + +fn joined(dir: &PathBuf) -> Vec { + 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 { + 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 { + 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 = 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 { + let containers: std::collections::HashSet = 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 { + 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) { + 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::>().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 = 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 = 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 = 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::>().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 { 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 { + let mut v: Vec = 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 { 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 = 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::>().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::>().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"); +} diff --git a/crates/coa-core/src/portable/realm/mod.rs b/crates/coa-core/src/portable/realm/mod.rs index 3c049b7..2ceeff2 100644 --- a/crates/coa-core/src/portable/realm/mod.rs +++ b/crates/coa-core/src/portable/realm/mod.rs @@ -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; diff --git a/crates/coa-core/src/portable/store/tests.rs b/crates/coa-core/src/portable/store/tests.rs index 692b8d4..2df0ca6 100644 --- a/crates/coa-core/src/portable/store/tests.rs +++ b/crates/coa-core/src/portable/store/tests.rs @@ -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); +} diff --git a/docs/PORTABLE_RECONCILE.md b/docs/PORTABLE_RECONCILE.md new file mode 100644 index 0000000..624ec8c --- /dev/null +++ b/docs/PORTABLE_RECONCILE.md @@ -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 = ` plus `coa.portable.alloc = ` 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 ` -> note the printed character id. Stop both realms. +3. `import `; start realm 2; log in with the real client; look at the character; log out; stop it. +4. `begin-session `; 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 --close`; read the printed changes; `update ` (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 ` back: it must be the same local + character on realm 2 at the same place.