mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 20:11:02 +00:00
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:
1 parent
9210be98b5
commit
7f15ffe69f
5 files changed
+632
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user