From d41a7c74945d8a89ea27f197c5bbba3d6f57263d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 7 Oct 2026 17:53:08 +0500 Subject: [PATCH] Phase 9.1: regression tests for the checkpoint after a Manager restart An immediate checkpoint after a Host restart is kept; with nothing to report it makes no canonical revision, a real change makes exactly one. Fake-realm test and a live test on a real worldserver (restart, field-by-field and hash comparison, professions as the change). Co-Authored-By: Claude Sonnet 5.5 --- .../src/portable/service/delta_probe.rs | 25 +++++++ .../src/portable/service/live_service.rs | 65 +++++++++++++++++++ crates/coa-core/src/portable/service/mod.rs | 3 + crates/coa-core/src/portable/session/tests.rs | 42 ++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 crates/coa-core/src/portable/service/delta_probe.rs diff --git a/crates/coa-core/src/portable/service/delta_probe.rs b/crates/coa-core/src/portable/service/delta_probe.rs new file mode 100644 index 0000000..02f7169 --- /dev/null +++ b/crates/coa-core/src/portable/service/delta_probe.rs @@ -0,0 +1,25 @@ +//! Field-by-field difference of two characters, for the messages of test failures. + +pub(crate) fn diff(a: &crate::portable::model::PortableCharacter, b: &crate::portable::model::PortableCharacter) -> Vec { + let mut out = Vec::new(); + walk("", &serde_json::to_value(a).unwrap(), &serde_json::to_value(b).unwrap(), &mut out); + out +} + +fn walk(path: &str, a: &serde_json::Value, b: &serde_json::Value, out: &mut Vec) { + use serde_json::Value::{Array, Null, Object}; + match (a, b) { + (Object(x), Object(y)) => { + for k in x.keys().chain(y.keys().filter(|k| !x.contains_key(*k))) { + walk(&format!("{path}/{k}"), x.get(k).unwrap_or(&Null), y.get(k).unwrap_or(&Null), out); + } + } + (Array(x), Array(y)) if x.len() == y.len() => { + for (i, (p, q)) in x.iter().zip(y).enumerate() { + walk(&format!("{path}[{i}]"), p, q, out); + } + } + _ if a != b => out.push(format!("{path}: {} -> {}", a.to_string().chars().take(160).collect::(), b.to_string().chars().take(160).collect::())), + _ => {} + } +} diff --git a/crates/coa-core/src/portable/service/live_service.rs b/crates/coa-core/src/portable/service/live_service.rs index f6d62d3..61a26b9 100644 --- a/crates/coa-core/src/portable/service/live_service.rs +++ b/crates/coa-core/src/portable/service/live_service.rs @@ -190,6 +190,71 @@ fn a_character_made_portable_on_a_running_realm_is_armed_on_it_without_a_restart drop(server); } +#[test] +#[ignore] +fn a_manager_restart_with_nothing_to_report_makes_no_revision_and_a_real_change_makes_exactly_one() { + let (Some(r), Some(sp), Some(jobs)) = (realms(), spec(), job_dir()) else { return }; + reset_b(&r.b); + sql(&r.b, "DELETE FROM acore_characters.mail_items WHERE receiver > 3010; DELETE FROM acore_characters.mail WHERE receiver > 3010;"); + std::fs::create_dir_all(&jobs).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let file = descriptor(dir.path()); + let quick = crate::portable::session::HostConfig { checkpoint_interval_secs: 4, collection_interval_secs: 300 }; + let open = |dir: &Path| { + let mut svc = PortableService::open(&dir.join("portable"), vec![]).unwrap(); + svc.set_host_config(quick.clone()); + svc + }; + let mut svc = open(dir.path()); + svc.add_prepared_realm(&file).unwrap(); + let profile = svc.owner_profile(); + let id = crate::portable::realm::live_projection::fixture_character(&r, svc.owner_mut(), profile); + let id_text = id.to_string(); + let server = Server::start(sp); + svc.tick(); + svc.tick(); + sql(&r.b, "REPLACE INTO acore_auth.realmcharacters (realmid, acctid, numchars) VALUES (1, 7001, 0)"); + let account = name_of_account(&r.b, 7001); + svc.play(&id_text, REALM, Some(&account)).unwrap(); + let guid = svc.host().server_mappings(id).unwrap().into_iter().find(|m| m.server_id == REALM).unwrap().local_guid; + server.ra().run(&format!("botcmd spawnbot {guid}")).unwrap(); + pump(&mut svc, 90, "playing", |s| status(s) == PlayStatus::Playing); + server.ra().run(&format!("botcmd suspend {guid}")).unwrap(); + + let acked = |s: &PortableService| s.host().host_live_sessions(REALM).unwrap().first().map(|x| x.acked_sequence).unwrap_or(0); + let revision = |s: &PortableService| s.owner().character(id).unwrap().revision; + + // whatever the realm does by itself at the first login arrives with the first checkpoints; then the character is quiet + pump(&mut svc, 90, "the first checkpoints", |s| acked(s) >= 1); + let from = acked(&svc); + pump(&mut svc, 90, "two quiet checkpoints", |s| acked(s) >= from + 2); + let settled = svc.owner().load_current(id).unwrap(); + let n = revision(&svc); + + // the Manager restarts; the new Host has no memory of its last checkpoint and checkpoints at once + drop(svc); + let mut svc = open(dir.path()); + let before = acked(&svc); + pump(&mut svc, 60, "the Host to be recognised again", |s| status(s) == PlayStatus::Playing); + pump(&mut svc, 60, "the immediate checkpoint after the restart", |s| acked(s) > before); + assert_eq!(revision(&svc), n, "a restart with nothing to report is not a new revision"); + let after = svc.owner().load_current(id).unwrap(); + assert_eq!(after, settled, "field by field: {:?}", super::delta_probe::diff(&settled, &after)); + assert_eq!(crate::portable::snapshot::content_hash(&after).unwrap(), crate::portable::snapshot::content_hash(&settled).unwrap(), "by hash"); + let again = acked(&svc); + pump(&mut svc, 60, "another quiet checkpoint", |s| acked(s) > again); + assert_eq!(revision(&svc), n); + + // a real change in the game: exactly one revision, however many checkpoints follow + server.ra().run(&format!("botcmd professiontrainer {guid}")).unwrap(); + let mark = acked(&svc); + pump(&mut svc, 90, "the checkpoints after the change", |s| acked(s) >= mark + 3); + assert_eq!(revision(&svc), n + 1, "{:?}", super::delta_probe::diff(&settled, &svc.owner().load_current(id).unwrap())); + let changed = svc.owner().load_current(id).unwrap(); + assert!(changed.build.skills.len() > settled.build.skills.len() || changed.build.spells.len() > settled.build.spells.len(), "the change is the professions"); + server.ra().run(&format!("botcmd despawn {guid}")).unwrap(); +} + fn name_of_account(db: &crate::db::Db, id: u32) -> String { sql(db, &format!("SELECT username FROM acore_auth.account WHERE id = {id}")).trim().to_string() } diff --git a/crates/coa-core/src/portable/service/mod.rs b/crates/coa-core/src/portable/service/mod.rs index f5ad676..61c58c2 100644 --- a/crates/coa-core/src/portable/service/mod.rs +++ b/crates/coa-core/src/portable/service/mod.rs @@ -9,3 +9,6 @@ pub use view::*; #[cfg(test)] mod live_service; + +#[cfg(test)] +mod delta_probe; diff --git a/crates/coa-core/src/portable/session/tests.rs b/crates/coa-core/src/portable/session/tests.rs index 0fd559b..0559f1c 100644 --- a/crates/coa-core/src/portable/session/tests.rs +++ b/crates/coa-core/src/portable/session/tests.rs @@ -296,6 +296,48 @@ fn the_host_manager_can_restart_between_the_request_and_the_read_and_between_che assert_eq!(o.store().load_current(id).unwrap().progression.money, c0.progression.money + 100); } +#[test] +fn the_immediate_checkpoint_after_a_host_restart_makes_no_revision_when_the_character_did_not_change() { + let dir = tempfile::tempdir().unwrap(); + let host_file = dir.path().join("host.db"); + let Setup { mut owner, host, mut realm, id, session, .. } = setup_in(Store::open_in_memory().unwrap(), Store::open_file(&host_file).unwrap()); + drop(host); + let mut o = OwnerService::new(&mut owner); + let settled = { + let mut hs = Store::open_file(&host_file).unwrap(); + let mut h = HostService::new(&mut hs, SERVER, HostConfig::default()); + start(&mut h, &mut o, &mut realm); + realm.play(|m| m.progression.money += 7); + checkpoint(&mut h, &mut o, &mut realm, 100); + assert_eq!(revision(&o, id), 11); + checkpoint(&mut h, &mut o, &mut realm, 200); + assert_eq!(revision(&o, id), 11, "an unchanged character is not a new revision"); + o.store().load_current(id).unwrap() + }; + let hash_before = crate::portable::snapshot::content_hash(&settled).unwrap(); + + let mut hs = Store::open_file(&host_file).unwrap(); + let mut h = HostService::new(&mut hs, SERVER, HostConfig::default()); + h.tick(&mut realm, 210).unwrap(); + h.tick(&mut realm, 211).unwrap(); + let acks = deliver(&mut h, &mut o, &mut realm); + assert_eq!(acks.len(), 1, "the restarted Host checkpoints at once"); + assert_eq!(acks[0].outcome, AckOutcome::Applied); + assert_eq!(revision(&o, id), 11, "the restart alone changed nothing the player can see"); + let after = o.store().load_current(id).unwrap(); + assert_eq!(after, settled, "field by field"); + assert_eq!(crate::portable::snapshot::content_hash(&after).unwrap(), hash_before, "by hash"); + assert_eq!(o.session(session).unwrap().unwrap().0, "open"); + + realm.play(|m| m.progression.money += 3); + h.tick(&mut realm, 300).unwrap(); + h.tick(&mut realm, 301).unwrap(); + let acks = deliver(&mut h, &mut o, &mut realm); + assert_eq!((acks.len(), &acks[0].outcome), (1, &AckOutcome::Applied)); + assert_eq!(revision(&o, id), 12, "a real change makes exactly one revision"); + assert_eq!(o.store().load_current(id).unwrap().progression.money, settled.progression.money + 3); +} + #[test] fn the_owner_manager_can_restart_at_any_point() { let dir = tempfile::tempdir().unwrap();