From ffb1df3fcedac52b4f4e7d05072332138aaa6a3e 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 11:11:11 +0500 Subject: [PATCH] Phase 8 (part 2): live gates, smoke tool, pin at login, same policy on import and update Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/examples/portable_smoke.rs | 37 ++- crates/coa-core/src/portable/merge.rs | 7 + .../coa-core/src/portable/projection/tests.rs | 24 +- .../src/portable/realm/live_projection.rs | 199 ++++++++++++ crates/coa-core/src/portable/realm/mod.rs | 2 + crates/coa-core/src/portable/realm/plan.rs | 2 +- crates/coa-core/src/portable/realm/profile.rs | 6 +- crates/coa-core/src/portable/realm/project.rs | 24 +- .../coa-core/src/portable/realm/reconcile.rs | 4 +- .../src/portable/session/live_projection.rs | 297 ++++++++++++++++++ .../src/portable/session/live_session.rs | 60 ++-- crates/coa-core/src/portable/session/mod.rs | 2 + .../src/portable/session/projection_tests.rs | 27 +- crates/coa-core/src/portable/store/owner.rs | 2 +- 14 files changed, 646 insertions(+), 47 deletions(-) create mode 100644 crates/coa-core/src/portable/realm/live_projection.rs create mode 100644 crates/coa-core/src/portable/session/live_projection.rs diff --git a/crates/coa-core/examples/portable_smoke.rs b/crates/coa-core/examples/portable_smoke.rs index f07b5a9..73a4ad2 100644 --- a/crates/coa-core/examples/portable_smoke.rs +++ b/crates/coa-core/examples/portable_smoke.rs @@ -28,6 +28,9 @@ //! reevaluate look again at what was held back when the realm's content profile changed (realm stopped) //! collection-add ... (owner store) add ids to a profile collection by hand (a test aid) //! collection-show [] the Owner's collections (revision, count, hash) and, with an account, the realm's +//! project ask the realm's running core what a level-cap projection of the character holds (needs --ra-port +//! and --job-dir); --out writes the decision, which a later `import`/`update` of a STOPPED realm can be given with +//! --projection (it answers only for the exact state of the character it was made for) //! //! --collection-interval how often host-run looks at the account collections (default 300; a session start and the final //! checkpoint always look) @@ -102,7 +105,16 @@ fn run() -> Result<(), String> { "" => None, dir => Some(std::sync::Arc::new(coa_core::portable::realm::knowledge::RealmKnowledge::from_data_dir(std::path::Path::new(dir)).map_err(|e| e.to_string())?)), }; - let opts = ImportOptions { game_server_users: vec![user.to_string(), "acore".to_string()], knowledge: knowledge.clone(), ..ImportOptions::default() }; + let projection_file = take("--projection", Some(""))?; + let out_file = take("--out", Some(""))?; + let supplied = match projection_file.as_str() { + "" => None, + file => { + let hold: coa_core::portable::projection::ProjectionHold = serde_json::from_slice(&std::fs::read(file).map_err(|e| e.to_string())?).map_err(|e| format!("{file}: {e}"))?; + Some(coa_core::portable::projection::Oracle(std::sync::Arc::new(coa_core::portable::projection::SuppliedDecision(hold)))) + } + }; + let opts = ImportOptions { game_server_users: vec![user.to_string(), "acore".to_string()], knowledge: knowledge.clone(), projection: supplied, ..ImportOptions::default() }; let host_dir = PathBuf::from(take("--host-store", Some(&format!("{}-host", store_dir.display())))?); let ra_port: Option = take("--ra-port", Some("0"))?.parse().ok().filter(|p| *p != 0); let ra_user = take("--ra-user", Some("local"))?; @@ -256,12 +268,33 @@ fn run() -> Result<(), String> { _ if store.server_mappings(model.character_id).map_err(|e| e.to_string())?.iter().any(|m| &m.server_id == server) => coa_core::portable::compat::Operation::Update, _ => coa_core::portable::compat::Operation::OfflineImport, }; - let report = coa_core::portable::compat::evaluate(&coa_core::portable::compat::Inputs { operation, model: &model, capabilities: &caps, knowledge: opts.knowledge.as_deref(), collections: &[], extensions: opts.extensions.as_deref() }); + let report = coa_core::portable::compat::evaluate(&coa_core::portable::compat::Inputs { operation, model: &model, capabilities: &caps, knowledge: opts.knowledge.as_deref(), collections: &[], extensions: opts.extensions.as_deref(), projection_decider: opts.projection.is_some() || matches!(operation, coa_core::portable::compat::Operation::OnlineImport) }); println!("{} on {server} (content profile {}): {:?}", report.operation, &caps.content_profile_hash[..16], report.verdict()); for o in &report.outcomes { println!(" {o}"); } } + ("project", [character, server]) => { + let port = ra_port.ok_or("give --ra-port: only a running core can say what a projection holds")?; + if job_dir.is_empty() { + return Err("give --job-dir (the core's PortableImport.JobDir)".into()); + } + let opts = profile_for(&db, &mut store, server)?; + let canonical = store.load_current(id(character)?).map_err(|e| e.to_string())?; + let mut ra = coa_core::ra::Ra::connect_to(port, &ra_user, &std::env::var("COA_RA_PASSWORD").map_err(|_| "set COA_RA_PASSWORD".to_string())?).map_err(|e| e.to_string())?; + let progression = opts.capabilities.as_ref().and_then(|c| c.progression.clone()); + match realm::project::decide_with_core(&mut ra, std::path::Path::new(&job_dir), &canonical).map_err(|e| e.to_string())? { + coa_core::portable::projection::Decision::Native => println!("not projected: level {} is within the realm's cap {}", canonical.progression.level, progression.map_or("?".to_string(), |p| p.max_player_level.to_string())), + coa_core::portable::projection::Decision::Projected(hold) => { + println!("projected level {} -> {} (signature {})", hold.canonical_level, hold.projected_level, &hold.progression_signature[..16]); + println!(" held: {} item(s), {} ability(ies), {} button(s), {} build record(s) edited, {} blocked", hold.held_items.len(), hold.held_spells.len(), hold.held_actions.len(), hold.settings.iter().filter(|s| !s.entries.is_empty() || !s.buttons.is_empty()).count(), hold.blocked_settings.len()); + if !out_file.is_empty() { + std::fs::write(&out_file, serde_json::to_vec_pretty(&hold).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + println!(" decision written to {out_file}"); + } + } + } + } ("reevaluate", [character, server]) => { let opts = profile_for(&db, &mut store, server)?; let r = realm::reevaluate_realm_character(&db, &mut store, id(character)?, server, &opts).map_err(|e| e.to_string())?; diff --git a/crates/coa-core/src/portable/merge.rs b/crates/coa-core/src/portable/merge.rs index fccf4c6..6b01812 100644 --- a/crates/coa-core/src/portable/merge.rs +++ b/crates/coa-core/src/portable/merge.rs @@ -45,6 +45,9 @@ use super::projection::settings::{classify, Record}; pub struct MergeProjection { /// The canonical level and xp stay as they are (a session reconciling a working copy into its canonical character). pub freeze_progression: bool, + /// The realm's level and xp are those of a working copy (the character was projected there): when the new view moves them, the new view + /// wins over whatever the player did to the working copy's level, instead of being a conflict. + pub adopt_progression: bool, /// Build records the core could not take apart: the realm's value is ignored. pub blocked: BTreeSet, } @@ -317,6 +320,10 @@ pub fn merge3_with(target: &PortableCharacter, base: &PortableCharacter, ours: & if b.level != o.level || b.xp != o.xp { cx.left_alone.push(format!("progression: the realm's level {} xp {} is the working copy's own and never reaches the canonical character", o.level, o.xp)); } + } else if projection.is_some_and(|p| p.adopt_progression) && (b.level != o.level || b.xp != o.xp) { + cx.changes.push(format!("progression: level {} xp {} -> level {} xp {} (the realm's working copy takes the new view's)", t.level, t.xp, o.level, o.xp)); + t.level = o.level; + t.xp = o.xp; } else { cx.scalar("progression.level", &mut t.level, &b.level, &o.level); if leveled { diff --git a/crates/coa-core/src/portable/projection/tests.rs b/crates/coa-core/src/portable/projection/tests.rs index b0b3db3..d3fd1dc 100644 --- a/crates/coa-core/src/portable/projection/tests.rs +++ b/crates/coa-core/src/portable/projection/tests.rs @@ -64,7 +64,7 @@ fn realm_b0(p: &PortableCharacter) -> PortableCharacter { } fn session(c0: &PortableCharacter, b0: &PortableCharacter, b1: &PortableCharacter, blocked: &[&str]) -> PortableCharacter { - let projection = MergeProjection { freeze_progression: true, blocked: blocked.iter().map(|s| s.to_string()).collect() }; + let projection = MergeProjection { freeze_progression: true, adopt_progression: false, blocked: blocked.iter().map(|s| s.to_string()).collect() }; merge3_with(c0, b0, b1, Mode::Lenient, Some(&projection)).unwrap().model } @@ -214,3 +214,25 @@ fn a_context_must_be_consistent() { assert_eq!(serde_json::from_str::(&json).unwrap(), ctx); assert!(serde_json::from_str::(&json.replace("\"canonical_level\":80", "\"canonical_level\":80,\"x\":1")).is_err()); } + +#[test] +fn an_update_from_a_working_copy_takes_the_new_views_level_and_never_reports_the_players_own_as_a_conflict() { + let c = canonical(); + let hold = hold_for(&c); + let old_view = apply(&c, &hold); + // the realm's own character: the player moved the working copy's level and xp + let mut realm = old_view.clone(); + realm.progression.level = 59; + realm.progression.xp = 1234; + let strict = |projection: Option<&MergeProjection>| merge3_with(&realm, &old_view, &c, Mode::Strict, projection).unwrap(); + + assert!(!strict(None).conflicts.is_empty(), "as a plain character the realm's level and the new level would conflict"); + let projection = MergeProjection { freeze_progression: false, adopt_progression: true, blocked: Default::default() }; + let merged = strict(Some(&projection)); + assert!(merged.conflicts.is_empty(), "{:?}", merged.conflicts); + assert_eq!((merged.model.progression.level, merged.model.progression.xp), (80, c.progression.xp), "the new view's level wins"); + + // an update that does not move the view leaves the player's own level alone + let same = merge3_with(&realm, &old_view, &old_view, Mode::Strict, Some(&projection)).unwrap(); + assert_eq!((same.model.progression.level, same.model.progression.xp), (59, 1234)); +} diff --git a/crates/coa-core/src/portable/realm/live_projection.rs b/crates/coa-core/src/portable/realm/live_projection.rs new file mode 100644 index 0000000..7d4027b --- /dev/null +++ b/crates/coa-core/src/portable/realm/live_projection.rs @@ -0,0 +1,199 @@ +//! Phase 8 against the two real disposable MySQL servers of `live_import.rs`, no worldserver: a level-80 character is imported into a realm +//! whose cap is 60 (the decision of the core is supplied: the real core is exercised by `session::live_projection`), played there offline, +//! reconciled, updated for another cap and brought back to a realm that holds all of it. +//! +//! ```text +//! COA_PORTABLE_LIVE="|13998|" COA_PORTABLE_LIVE_B="|13997|" cargo test -p coa-core portable::realm::live_projection -- --ignored --test-threads=1 +//! ``` + +use crate::db::Db; +use crate::portable::capabilities::RealmCapabilities; +use crate::portable::ids::{CharacterId, ContentId, PortableItemId, ProfileId}; +use crate::portable::model::*; +use crate::portable::projection::{subject_of, Oracle, ProjectionHold, SuppliedDecision, POLICY_VERSION, PROTOCOL}; +use crate::portable::store::Store; + +use super::live_import::{fresh_store, make, number, opts, realms, reset_b, Realms, ACCOUNT, B}; +use super::project::testing::caps; +use super::*; + +/// Items of the custom content that keep their authored level 80 on a cap-60 realm. +pub(crate) const HELMET: u64 = 2_061_035; +pub(crate) const LEGS: u64 = 2_069_385; +/// Abilities of a ranger that need more than level 60 (every table that grants them says so). +pub(crate) const HIGH_SPELLS: [u32; 3] = [501_722, 501_723, 501_733]; + +pub(crate) fn item(like: &PortableCharacter, entry: u64, slot: u8) -> PortableItem { + let mut i = like.items.iter().find(|i| i.container.is_none()).expect("an item to copy").clone(); + i.id = PortableItemId::new(); + i.container = None; + i.slot = slot; + i.entry = ContentId::new("coa", "item", entry).unwrap(); + i.count = 1; + i.enchantments.clear(); + i.gift = None; + i.text = None; + i +} + +/// The fixture's level-80 ranger with a helmet that only a level-80 can wear (worn), legwraps of the same kind in the backpack, and +/// abilities that exist only above level 60. Returns the id of the canonical character. +pub(crate) fn fixture_character(r: &Realms, owner: &mut Store, profile: ProfileId) -> CharacterId { + let id = make(r, owner, profile, 1002); + let mut m = owner.load_current(id).unwrap(); + m.items.retain(|i| !(i.container.is_none() && (i.slot == 0 || i.slot == 25))); + let (helmet, legs) = (item(&m, HELMET, 0), item(&m, LEGS, 25)); + m.items.extend([helmet, legs]); + m.build.spells.extend(HIGH_SPELLS.map(|s| (s, 255))); + let revision = owner.character(id).unwrap().revision; + owner.commit_snapshot(id, revision, m.normalized(), "realm-a", Some("test content above level 60")).unwrap(); + id +} + +/// What a core at `cap` would hold of this character: the helmet, and the abilities of the list. +fn hold_at(c: &PortableCharacter, cap: u32, signature: &str, spells: &[u32]) -> ProjectionHold { + let helmet = c.items.iter().find(|i| i.entry.id() == HELMET).map(|i| i.id); + ProjectionHold { + protocol: PROTOCOL, + policy_version: POLICY_VERSION, + progression_signature: signature.into(), + max_player_level: cap, + canonical_level: c.progression.level as u32, + projected_level: cap, + held_items: if cap < 80 { helmet.into_iter().collect() } else { vec![] }, + held_spells: spells.to_vec(), + held_actions: vec![], + settings: vec![], + blocked_settings: vec![], + subject: subject_of(c).unwrap(), + } +} + +fn options(caps: RealmCapabilities, decision: Option) -> ImportOptions { + let mut o = opts(); + o.capabilities = Some(std::sync::Arc::new(caps)); + o.projection = decision.map(|h| Oracle(std::sync::Arc::new(SuppliedDecision(h)))); + o +} + +fn sql(db: &Db, text: &str) { + db.query(text).unwrap_or_else(|e| panic!("{e}\n{text}")); +} + +fn spells(db: &Db, guid: u32) -> std::collections::BTreeSet { + db.query(&format!("SELECT spell FROM acore_characters.character_spell WHERE guid = {guid}")).unwrap().lines().filter_map(|l| l.trim().parse().ok()).collect() +} + +fn has_item(db: &Db, guid: u32, entry: u64) -> bool { + number(db, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = {guid} AND itemEntry = {entry}")) > 0 +} + +fn level(db: &Db, guid: u32) -> u64 { + number(db, &format!("SELECT level FROM acore_characters.characters WHERE guid = {guid}")) +} + +fn pin(db: &Db, guid: u32) -> String { + db.query(&format!("SELECT data FROM acore_characters.character_settings WHERE guid = {guid} AND source = 'coa.portable.pin'")).unwrap().trim().to_string() +} + +fn words(c: &RealmCapabilities) -> String { + c.progression.as_ref().unwrap().pin_words().iter().map(|w| w.to_string()).collect::>().join(" ") +} + +const SIG60: &str = "11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa"; +const SIG70: &str = "22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb"; +const SIG80: &str = "33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc"; + +#[test] +#[ignore] +fn a_projected_character_is_played_reconciled_and_brought_back_to_a_realm_that_holds_all_of_it() { + let Some(r) = realms() else { return }; + reset_b(&r.b); + let (mut store, profile) = fresh_store(); + let id = fixture_character(&r, &mut store, profile); + let c0 = store.load_current(id).unwrap(); + assert_eq!(c0.progression.level, 80); + + // ---- import: the realm's cap is 60 ---- + let caps60 = caps(60, SIG60); + let o60 = options(caps60.clone(), Some(hold_at(&c0, 60, SIG60, &HIGH_SPELLS))); + let outcome = import_character(&r.b, &mut store, id, B, ACCOUNT, &o60).unwrap(); + let guid = outcome.local_guid; + assert_eq!((level(&r.b, guid), number(&r.b, &format!("SELECT xp FROM acore_characters.characters WHERE guid = {guid}"))), (60, 0)); + assert!(!has_item(&r.b, guid, HELMET), "the helmet that only a level-80 can wear never reached the realm"); + assert!(has_item(&r.b, guid, LEGS), "the same kind of item in the backpack did: the realm loads it and refuses to wear it by itself"); + assert!(HIGH_SPELLS.iter().all(|s| !spells(&r.b, guid).contains(s))); + assert_eq!(pin(&r.b, guid), words(&caps60)); + let ctx = store.projection_context(id, B).unwrap().expect("projected"); + assert_eq!((ctx.canonical_level, ctx.projected_level, ctx.progression_signature.as_str()), (80, 60, SIG60)); + assert_eq!(store.character_pin(id, B).unwrap().unwrap().progression_signature, SIG60); + assert_eq!(store.character(id).unwrap().revision, store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == B).unwrap().last_revision); + + // a projected import without a decision is refused before anything is written + let before = number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters"); + let other = make(&r, &mut store, profile, 1004); + let mut m = store.load_current(other).unwrap(); + m.progression.level = 80; + let revision = store.character(other).unwrap().revision; + store.commit_snapshot(other, revision, m, "realm-a", None).unwrap(); + let refused = import_character(&r.b, &mut store, other, B, ACCOUNT, &options(caps60.clone(), None)).unwrap_err(); + assert!(matches!(refused, crate::portable::PortableError::ProjectionNeedsRunningCore { character_level: 80, cap: 60 } | crate::portable::PortableError::Incompatible { .. }), "{refused}"); + assert_eq!(number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters"), before, "nothing was written"); + + // ---- play offline: the realm's own level and xp move, gold, an item and reputation are earned ---- + begin_session(&r.b, &mut store, id, B).unwrap(); + sql(&r.b, &format!("UPDATE acore_characters.characters SET level = 59, xp = 1234, money = money + 7777 WHERE guid = {guid}")); + let earned = number(&r.b, "SELECT MAX(guid) + 1 FROM acore_characters.item_instance") as u32; + sql(&r.b, &format!( + "INSERT INTO acore_characters.item_instance (guid, itemEntry, owner_guid, count, enchantments) VALUES ({earned}, 70001, {guid}, 1, '{}'); + INSERT INTO acore_characters.character_inventory (guid, bag, slot, item) VALUES ({guid}, 0, 60, {earned});", + "0 ".repeat(36) + )); + let rewarded = reconcile_session(&r.b, &mut store, id, B, false, Some("offline play")).unwrap(); + assert!(rewarded.new_revision); + let c1 = store.load_current(id).unwrap(); + assert_eq!((c1.progression.level, c1.progression.xp), (80, c0.progression.xp), "the canonical level and xp are not the realm's"); + assert_eq!(c1.progression.money, c0.progression.money + 7777); + assert!(c1.items.iter().any(|i| i.entry.id() == 70001), "the gain arrived"); + assert!(c1.items.iter().any(|i| i.entry.id() == HELMET), "the held helmet is canonical"); + assert!(HIGH_SPELLS.iter().all(|s| c1.build.spells.iter().any(|(k, _)| k == s)), "the held abilities are canonical"); + // the same checkpoint again counts nothing twice + let again = reconcile_session(&r.b, &mut store, id, B, false, Some("again")).unwrap(); + assert!(!again.new_revision, "{:?}", again.changes); + reconcile_session(&r.b, &mut store, id, B, true, Some("close")).unwrap(); + assert_eq!(store.projection_context(id, B).unwrap().unwrap().canonical_revision, store.character(id).unwrap().revision, "the context follows the revision"); + + // ---- back to a realm that holds all of it: the same realm, now with a cap of 80 (a native profile) ---- + let caps80 = caps(80, SIG80); + let up = update_realm_character(&r.b, &mut store, id, B, &options(caps80.clone(), None)).unwrap(); + assert!(up.updated); + assert_eq!(level(&r.b, guid), 80); + assert!(has_item(&r.b, guid, HELMET), "the held helmet is restored"); + assert!(HIGH_SPELLS.iter().all(|s| spells(&r.b, guid).contains(s)), "the held abilities are restored"); + assert_eq!(pin(&r.b, guid), words(&caps80)); + assert!(store.projection_context(id, B).unwrap().is_none() && !store.character_pin(id, B).unwrap().unwrap().projected); + let c2 = store.load_current(id).unwrap(); + assert_eq!(c2, c1, "restoring the realm changed nothing canonical"); + + // ---- and down again (cap 70, then 60): what the realm cannot hold is taken out of the realm, never out of the canonical character ---- + let hold70 = hold_at(&c2, 70, SIG70, &HIGH_SPELLS[2..]); + let down = update_realm_character(&r.b, &mut store, id, B, &options(caps(70, SIG70), Some(hold70))).unwrap(); + assert!(down.updated); + assert_eq!(level(&r.b, guid), 70); + assert!(!has_item(&r.b, guid, HELMET), "still above the cap: out of the realm"); + let on_realm = spells(&r.b, guid); + assert!(on_realm.contains(&HIGH_SPELLS[0]) && on_realm.contains(&HIGH_SPELLS[1]) && !on_realm.contains(&HIGH_SPELLS[2]), "what a cap of 70 allows is on the realm, what it does not is not"); + assert_eq!(store.load_current(id).unwrap(), c2, "the canonical character is untouched by a realm that holds less"); + let ctx = store.projection_context(id, B).unwrap().unwrap(); + assert_eq!((ctx.projected_level, ctx.progression_signature.as_str()), (70, SIG70)); + + // the update of a realm that is already where it should be writes nothing; a changed signature alone is enough to write + let same = update_realm_character(&r.b, &mut store, id, B, &options(caps(70, SIG70), Some(hold_at(&c2, 70, SIG70, &HIGH_SPELLS[2..])))).unwrap(); + assert!(!same.updated, "nothing moved: nothing is written"); + let moved = update_realm_character(&r.b, &mut store, id, B, &options(caps(60, SIG60), Some(hold_at(&c2, 60, SIG60, &HIGH_SPELLS)))).unwrap(); + assert!(moved.updated); + assert_eq!(level(&r.b, guid), 60); + assert!(HIGH_SPELLS.iter().all(|s| !spells(&r.b, guid).contains(s))); + assert_eq!(store.load_current(id).unwrap(), c2); + assert_eq!(pin(&r.b, guid), words(&caps(60, SIG60))); +} diff --git a/crates/coa-core/src/portable/realm/mod.rs b/crates/coa-core/src/portable/realm/mod.rs index d8351bd..f0b670d 100644 --- a/crates/coa-core/src/portable/realm/mod.rs +++ b/crates/coa-core/src/portable/realm/mod.rs @@ -214,6 +214,8 @@ mod live_roundtrip; #[cfg(test)] mod live_profile; #[cfg(test)] +pub(crate) mod live_projection; +#[cfg(test)] mod live_update; #[cfg(test)] mod live_wardrobe; diff --git a/crates/coa-core/src/portable/realm/plan.rs b/crates/coa-core/src/portable/realm/plan.rs index bc1d019..cd50102 100644 --- a/crates/coa-core/src/portable/realm/plan.rs +++ b/crates/coa-core/src/portable/realm/plan.rs @@ -598,7 +598,7 @@ pub fn build_plan(model: &PortableCharacter, ctx: &PlanContext<'_>) -> Result, core: Opt extensions, client_catalog: catalog, }; - let caps = RealmCapabilities::new(core.map(|c| c.core.clone()), content)?; - match progression { - Some(p) => caps.with_progression(p), - None => Ok(caps), - } + RealmCapabilities::build(core.map(|c| c.core.clone()), content, progression) } /// Probe a running or stopped realm: the schema from the database, the core from RA when a console is given. diff --git a/crates/coa-core/src/portable/realm/project.rs b/crates/coa-core/src/portable/realm/project.rs index d0a942b..aa6bacc 100644 --- a/crates/coa-core/src/portable/realm/project.rs +++ b/crates/coa-core/src/portable/realm/project.rs @@ -148,15 +148,12 @@ pub fn stored_view(state: &PortableCharacter, context: Option<&ProjectionContext } } +/// A realm profile for the tests of this and the sessions' modules: the progression of a core with this cap and signature. #[cfg(test)] -mod tests { - use super::*; - use crate::portable::capabilities::*; - use crate::portable::fixtures::geared_level_eighty; - use crate::portable::model::Ruleset; - use crate::portable::projection::{ProjectionHold, SuppliedDecision, PROTOCOL}; - - const SIG: &str = "bd299b9ef5863c6bf006e88a3617fac57c514b5889ea8367f87879aa1ad6d76d"; +pub(crate) mod testing { + use super::super::super::capabilities::*; + use super::super::super::model::Ruleset; + use super::super::super::projection::PROTOCOL; pub(crate) fn caps(cap: u32, signature: &str) -> RealmCapabilities { let content = ContentProfile { @@ -172,6 +169,17 @@ mod tests { }; RealmCapabilities::build(None, content, Some(Progression { max_player_level: cap, projection_protocol: PROTOCOL, projection_policy_version: 1, progression_signature: signature.into(), scaling_enabled: true })).unwrap() } +} + +#[cfg(test)] +mod tests { + use super::testing::caps; + use super::*; + use crate::portable::capabilities::*; + use crate::portable::fixtures::geared_level_eighty; + use crate::portable::projection::{ProjectionHold, SuppliedDecision, PROTOCOL}; + + const SIG: &str = "bd299b9ef5863c6bf006e88a3617fac57c514b5889ea8367f87879aa1ad6d76d"; fn hold(c: &PortableCharacter, cap: u32, signature: &str) -> ProjectionHold { ProjectionHold { protocol: PROTOCOL, policy_version: 1, progression_signature: signature.into(), max_player_level: cap, canonical_level: c.progression.level as u32, projected_level: cap, held_items: vec![c.items[0].id], held_spells: vec![], held_actions: vec![], settings: vec![], blocked_settings: vec![], subject: subject_of(c).unwrap() } diff --git a/crates/coa-core/src/portable/realm/reconcile.rs b/crates/coa-core/src/portable/realm/reconcile.rs index be931db..b77cab2 100644 --- a/crates/coa-core/src/portable/realm/reconcile.rs +++ b/crates/coa-core/src/portable/realm/reconcile.rs @@ -134,7 +134,7 @@ pub fn reconcile_session(db: &Db, store: &mut Store, id: CharacterId, server_id: } let view = read_realm(db, store, id, server_id, guid)?; let context = store.projection_context(id, server_id)?; - let projection = context.as_ref().map(|c| MergeProjection { freeze_progression: true, blocked: c.hold.blocked_settings.iter().cloned().collect() }); + let projection = context.as_ref().map(|c| MergeProjection { freeze_progression: true, adopt_progression: false, blocked: c.hold.blocked_settings.iter().cloned().collect() }); let merged = merge3_with(&baseline.c0, &baseline.b0, &view.exported.model, Mode::Lenient, projection.as_ref())?; let before = record.revision; let revision = store.commit_reconciled(id, server_id, merged.model, &view.exported.observations, &view.exported.pet_observations, note)?; @@ -332,7 +332,7 @@ fn update_inner(db: &Db, store: &mut Store, id: CharacterId, server_id: &str, op let base = super::project::stored_view(&synced, old_context.as_ref()); let projected = old_context.is_some() || new_plan.context.is_some(); let blocked: std::collections::BTreeSet = old_context.iter().chain(new_plan.context.iter()).flat_map(|c| c.hold.blocked_settings.iter().cloned()).collect(); - let projection = projected.then_some(MergeProjection { freeze_progression: false, blocked }); + let projection = projected.then_some(MergeProjection { freeze_progression: false, adopt_progression: old_context.is_some(), blocked }); let merged = merge3_with(&view.exported.model, &base, &new_plan.view, Mode::Strict, projection.as_ref())?; if !merged.conflicts.is_empty() { return Err(PortableError::UpdateConflicts(merged.conflicts.iter().map(|c| format!("{}: {}", c.path, c.detail)).collect())); diff --git a/crates/coa-core/src/portable/session/live_projection.rs b/crates/coa-core/src/portable/session/live_projection.rs new file mode 100644 index 0000000..cd2b160 --- /dev/null +++ b/crates/coa-core/src/portable/session/live_projection.rs @@ -0,0 +1,297 @@ +//! Phase 8 against a **real worldserver** whose `MaxPlayerLevel` is below the character's level, and real disposable MySQL servers. +//! `#[ignore]`d. Environment as for `live_session`, and +//! +//! ```text +//! COA_PORTABLE_LIVE_SERVER_B the cap-60 worldserver of realm B (its job directory is COA_PORTABLE_LIVE_JOBDIR) +//! COA_PORTABLE_LIVE_SERVER_B_CONF another configuration of the same realm with MaxPlayerLevel = 70 (the cap changes under the character) +//! COA_PORTABLE_LIVE_CANONICAL optional, "|": a real character to project instead of the fixture's +//! ``` + +use std::path::Path; +use std::time::Instant; + +use crate::portable::capabilities::RealmCapabilities; +use crate::portable::extension::ExtensionRegistry; +use crate::portable::ids::{CharacterId, ImportId, ProfileId}; +use crate::portable::projection::{settings, Decision, ProjectionContext}; +use crate::portable::realm::live_import::{number, opts, realms, reset_b, Realms, ACCOUNT, B}; +use crate::portable::realm::online::{import_character_online, job_bytes}; +use crate::portable::realm::profile::probe_capabilities; +use crate::portable::realm::project::decide_with_core; +use super::bridge::RowState; +use crate::portable::store::Store; + +use super::live::LiveBridge; +use super::live_session::*; +use super::protocol::*; +use super::*; + +const DATA: &str = "C:/games/coa-schema-fixture-20261005/Data"; + +const HELMET: u64 = crate::portable::realm::live_projection::HELMET; +const LEGS: u64 = crate::portable::realm::live_projection::LEGS; + +fn caps_of(r: &Realms, server: &Server) -> RealmCapabilities { + probe_capabilities(&r.b, Some(Path::new(DATA)), Some(&mut server.ra()), &ExtensionRegistry::new()).unwrap() +} + +fn options(caps: &RealmCapabilities) -> crate::portable::realm::ImportOptions { + let mut o = opts(); + o.capabilities = Some(std::sync::Arc::new(caps.clone())); + o +} + +/// The canonical character the tests project: a real one when asked, else the fixture's level-80 ranger with a helmet that only a level-80 +/// can wear, legwraps of the same kind in the backpack, and abilities that only exist above level 60. +fn canonical_character(r: &Realms, owner: &mut Store, profile: ProfileId) -> CharacterId { + if let Ok(spec) = std::env::var("COA_PORTABLE_LIVE_CANONICAL") { + let (dir, id) = spec.split_once('|').expect("|"); + let source = Store::open(Path::new(dir)).unwrap(); + let model = source.load_current(id.parse().unwrap()).unwrap(); + return owner.create_character(profile, model, "realm-a").unwrap(); + } + crate::portable::realm::live_projection::fixture_character(r, owner, profile) +} + +fn context_of(store: &Store, id: CharacterId) -> ProjectionContext { + store.projection_context(id, B).unwrap().expect("the character is projected on this realm") +} + +/// Mails of the realm's loader about items it could not equip or store (what an item above the cap would cause); the realm's own reward mails +/// (achievements) are not counted. +fn equip_problem_mails(db: &crate::db::Db, guid: u32) -> u64 { + number(db, &format!("SELECT COUNT(*) FROM acore_characters.mail WHERE receiver = {guid} AND subject LIKE '%problems with equipping%'")) +} + +/// Rows of the realm that carry an item of one of these entries: in the character's items, or attached to a mail it received. +fn traces(db: &crate::db::Db, guid: u32, entries: &[u64]) -> u64 { + let list = entries.iter().map(u64::to_string).collect::>().join(", "); + if entries.is_empty() { + return 0; + } + number(db, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE itemEntry IN ({list}) AND (owner_guid = {guid} OR guid IN (SELECT item_guid FROM acore_characters.mail_items WHERE receiver = {guid}))")) +} + +fn item_entries(db: &crate::db::Db, guid: u32) -> Vec { + sql(db, &format!("SELECT itemEntry FROM acore_characters.item_instance WHERE owner_guid = {guid} ORDER BY itemEntry")).lines().filter_map(|l| l.trim().parse().ok()).collect() +} + +fn spells_of(db: &crate::db::Db, guid: u32) -> std::collections::BTreeSet { + sql(db, &format!("SELECT spell FROM acore_characters.character_spell WHERE guid = {guid}")).lines().filter_map(|l| l.trim().parse().ok()).collect() +} + +fn pin_words(db: &crate::db::Db, guid: u32) -> String { + sql(db, &format!("SELECT data FROM acore_characters.character_settings WHERE guid = {guid} AND source = 'coa.portable.pin'")).trim().to_string() +} + +#[test] +#[ignore] +fn a_level_eighty_character_is_projected_onto_a_cap_sixty_worldserver_and_nothing_canonical_is_lost() { + let (Some(r), Some(spec), 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 mut owner = Store::open_file(&dir.path().join("owner.db")).unwrap(); + let mut host = Store::open_file(&dir.path().join("host.db")).unwrap(); + let profile = owner.default_profile().unwrap(); + let host_profile = host.default_profile().unwrap(); + let id = canonical_character(&r, &mut owner, profile); + let c0 = owner.load_current(id).unwrap(); + assert_eq!(c0.progression.level, 80); + + let server = Server::start(spec); + let mut ra = server.ra(); + let caps = caps_of(&r, &server); + let progression = caps.progression.clone().expect("the core reports its progression"); + assert_eq!(progression.max_player_level, 60); + assert!(caps.content.supports(crate::portable::capabilities::Feature::LevelProjection)); + host.set_realm_profile(B, &caps, "live").unwrap(); + let o = options(&caps); + + // the core refuses a level-80 character that was not projected, and a projection prepared for another progression + let record = owner.character(id).unwrap(); + let bad = ImportId::new(); + let unprojected = job_bytes(bad, [5, 5, 5, 5], ACCOUNT, record.revision, 10, &c0, None, None, None).unwrap(); + std::fs::write(jobs.join(format!("{bad}.job")), unprojected).unwrap(); + let before = number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters") + number(&r.b, "SELECT COUNT(*) FROM acore_characters.item_instance"); + assert!(ra.portable_import(bad).is_err()); + assert!(std::fs::read_to_string(jobs.join(format!("{bad}.result"))).unwrap().contains("above_level_cap")); + assert_eq!(number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters") + number(&r.b, "SELECT COUNT(*) FROM acore_characters.item_instance"), before, "nothing was written"); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.job"))); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.result"))); + + // the core's decision for this character + let decision = decide_with_core(&mut ra, &jobs, &c0).unwrap(); + let Decision::Projected(hold) = decision else { panic!("a level-80 character is projected at cap 60") }; + assert_eq!((hold.max_player_level, hold.canonical_level, hold.projected_level), (60, 80, 60)); + assert_eq!(hold.progression_signature, progression.progression_signature); + assert!(!hold.held_spells.is_empty(), "abilities of the character need more than level 60"); + if std::env::var("COA_PORTABLE_LIVE_CANONICAL").is_err() { + let helmet = c0.items.iter().find(|i| i.entry.id() == HELMET).unwrap().id; + let legs = c0.items.iter().find(|i| i.entry.id() == LEGS).unwrap().id; + assert!(hold.held_items.contains(&helmet), "the worn level-80 helmet is held"); + assert!(!hold.held_items.contains(&legs), "the same kind of item in the backpack is not: the realm loads it and refuses to wear it by itself"); + for spell in crate::portable::realm::live_projection::HIGH_SPELLS { + assert!(hold.held_spells.contains(&spell), "{spell} is held at 60"); + } + } + + // the session starts: the Owner offers the full canonical character, the Host projects it for its realm and imports the working copy + let offer = OwnerService::new(&mut owner).offer(id, B).unwrap(); + HostService::new(&mut host, B, HostConfig::default()).accept_offer(host_profile, &offer).unwrap(); + let imported = import_character_online(&mut ra, &mut host, id, B, ACCOUNT, &o, &jobs, Some(offer.session_id)).unwrap(); + HostService::new(&mut host, B, HostConfig::default()).bind(offer.session_id, imported.local_guid).unwrap(); + let guid = imported.local_guid; + let ctx = context_of(&host, id); + assert_eq!((ctx.canonical_level, ctx.projected_level, ctx.canonical_revision), (80, 60, record.revision)); + let name = name_of(&r.b, guid); + + assert_eq!(number(&r.b, &format!("SELECT level FROM acore_characters.characters WHERE guid = {guid}")), 60); + assert_eq!(number(&r.b, &format!("SELECT xp FROM acore_characters.characters WHERE guid = {guid}")), 0); + assert_eq!(item_entries(&r.b, guid).len(), c0.items.len() - hold.held_items.iter().filter(|h| c0.items.iter().any(|i| i.id == **h)).count() - c0.items.iter().filter(|i| hold.held_items.contains(&i.container.unwrap_or(i.id)) && i.container.is_some()).count(), "only what the realm can hold was written"); + let on_realm = spells_of(&r.b, guid); + assert!(hold.held_spells.iter().all(|s| !on_realm.contains(s)), "a held ability is absent from the realm's tables"); + let held_entries: Vec = hold.held_items.iter().filter_map(|h| c0.items.iter().find(|i| i.id == *h)).map(|i| i.entry.id()).collect(); + assert_eq!(traces(&r.b, guid, &held_entries), 0, "a held item is neither in the realm's tables nor in a mail"); + let real = std::env::var("COA_PORTABLE_LIVE_CANONICAL").is_ok(); + if real { + assert_eq!(equip_problem_mails(&r.b, guid), 0); + } + assert_eq!(pin_words(&r.b, guid), progression.pin_words().iter().map(|w| w.to_string()).collect::>().join(" "), "the core stores the progression the character was prepared for"); + for source in hold.settings.iter().map(|s| &s.source) { + let value: Vec = sql(&r.b, &format!("SELECT data FROM acore_characters.character_settings WHERE guid = {guid} AND source = '{source}'")).split_whitespace().filter_map(|w| w.parse().ok()).collect(); + let held = hold.settings.iter().find(|s| &s.source == source).unwrap(); + let record = settings::Record::parse(held.kind, &value).expect("a build record"); + assert!(held.entries.iter().all(|e| !record.entries.contains_key(e)) && held.buttons.iter().all(|b| !record.buttons.contains_key(b)), "{source}: nothing held was written"); + } + + // a projected character that is not what the core would project is refused (a held ability put back) + let mut view = crate::portable::projection::apply(&c0, &hold); + view.build.spells.push((hold.held_spells[0], 255)); + let bad = ImportId::new(); + let pin = context_of(&host, id).pin(); + std::fs::write(jobs.join(format!("{bad}.job")), job_bytes(bad, [6, 6, 6, 6], ACCOUNT, record.revision, 10, &view.normalized(), None, None, Some(&pin)).unwrap()).unwrap(); + assert!(ra.portable_import(bad).is_err()); + assert!(std::fs::read_to_string(jobs.join(format!("{bad}.result"))).unwrap().contains("projection_inconsistent")); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.job"))); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.result"))); + let mut other_profile = pin.clone(); + other_profile.progression_signature = "ab".repeat(32); + let bad = ImportId::new(); + std::fs::write(jobs.join(format!("{bad}.job")), job_bytes(bad, [7, 7, 7, 7], ACCOUNT, record.revision, 10, &crate::portable::projection::apply(&c0, &hold), None, None, Some(&other_profile)).unwrap()).unwrap(); + assert!(ra.portable_import(bad).is_err()); + assert!(std::fs::read_to_string(jobs.join(format!("{bad}.result"))).unwrap().contains("progression_changed")); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.job"))); + let _ = std::fs::remove_file(jobs.join(format!("{bad}.result"))); + + // the real login: the realm loads the working copy (no mail, no removed items), the Host takes B0 under the pin + let clock = Instant::now(); + ra.run(&format!("botcmd spawnbot {guid}")).unwrap(); + wait_until("the baseline marker", 90, || state_of(&r.b, guid).is_some_and(|row| row.state == RowState::BaselineReady)); + let mut h = HostService::new(&mut host, B, HostConfig { checkpoint_interval_secs: 1, ..HostConfig::default() }); + h.observe_profile(&caps, "live").unwrap(); + let mut o_service = OwnerService::new(&mut owner); + let mut bridge = LiveBridge::new(&r.b, server.ra()); + tick_until(&mut h, &mut bridge, &clock, "the baseline", |e| matches!(e, HostEvent::BaselineTaken { .. })); + let acks = deliver(&mut h, &mut o_service, &mut bridge); + assert_eq!(acks[0].outcome, AckOutcome::Applied); + assert!(acks[0].pin.as_ref().is_some_and(|p| p.projected && p.max_player_level == 60)); + assert_eq!(traces(&r.b, guid, &held_entries), 0, "the realm's loader found nothing above the cap to mail"); + if real { + assert_eq!(equip_problem_mails(&r.b, guid), 0, "the realm mailed no item it could not wear: nothing above the cap was written to it"); + } + let level_on_realm = |db: &crate::db::Db| number(db, &format!("SELECT level FROM acore_characters.characters WHERE guid = {guid}")); + assert_eq!(level_on_realm(&r.b), 60); + + // play: the realm moves the level (down and up again), checkpoints run; the canonical character never leaves 80 + ra.run(&format!("character level {name} 59")).unwrap(); + tick_until(&mut h, &mut bridge, &clock, "a checkpoint at level 59", |e| matches!(e, HostEvent::CheckpointQueued { .. })); + deliver(&mut h, &mut o_service, &mut bridge); + assert_eq!(level_on_realm(&r.b), 59); + let c1 = o_service.store().load_current(id).unwrap(); + assert_eq!((c1.progression.level, c1.progression.xp), (80, c0.progression.xp), "the realm's level is not the canonical one"); + for held in &hold.held_items { + assert!(c1.items.iter().any(|i| i.id == *held), "a held item is still canonical"); + } + for spell in &hold.held_spells { + assert!(c1.build.spells.iter().any(|(s, _)| s == spell), "a held ability is still canonical"); + } + for s in &hold.settings { + let value = c1.settings.get(&s.source).expect("the record is still canonical"); + let record = settings::Record::parse(s.kind, value).expect("a build record"); + assert!(s.entries.iter().all(|e| record.entries.contains_key(e)), "{}: what the realm could not hold is still in the record", s.source); + } + ra.run(&format!("character level {name} 60")).unwrap(); + + // logout: the final checkpoint; canonical level unchanged; the next session is armed on the same working copy + ra.run(&format!("botcmd despawn {guid}")).unwrap(); + wait_until("the logout save", 60, || state_of(&r.b, guid).is_some_and(|row| row.state == RowState::Ended)); + wait_until("the character to be offline", 60, || !online(&r.b, guid)); + tick_until(&mut h, &mut bridge, &clock, "the final checkpoint", |e| matches!(e, HostEvent::CheckpointQueued { final_checkpoint: true, .. })); + let acks = deliver(&mut h, &mut o_service, &mut bridge); + let next = acks[0].next_session.clone().expect("the next session"); + let c2 = o_service.store().load_current(id).unwrap(); + assert_eq!(c2.progression.level, 80); + assert_eq!(traces(&r.b, guid, &held_entries), 0); + assert_eq!(state_of(&r.b, guid).unwrap().session_id, next.session_id); + drop(bridge); + let revision_before_cap_change = o_service.store().character(id).unwrap().revision; + + // ---- the cap moves under the character (60 -> 70): nobody plays it under the new rules, its session ends under the old pin ---- + server.stop(); + let Some(conf) = std::env::var("COA_PORTABLE_LIVE_SERVER_B_CONF").ok().map(std::path::PathBuf::from) else { return }; + let server70 = Server::start_with(self::spec().unwrap(), Some(&conf)); + let caps70 = caps_of(&r, &server70); + assert_eq!(caps70.progression.as_ref().unwrap().max_player_level, 70); + assert_ne!(caps70.progression.as_ref().unwrap().progression_signature, progression.progression_signature); + let (change, _) = h.observe_profile(&caps70, "live").unwrap(); + assert!(change.progression_changed()); + + // the session is open under the old pin and the character's rows are the cap-60 working copy: the core refuses it at login + let status_before = state_of(&r.b, guid).unwrap(); + server70.ra().run(&format!("botcmd spawnbot {guid}")).unwrap(); + wait_until("the core to refuse the login", 60, || server70.output().contains(&format!("character {guid} of session")) && server70.output().contains("was prepared for another progression profile")); + assert!(server70.ra().run(&format!("portable status {guid}")).unwrap().contains("NONE"), "a refused character is not tracked as a session"); + assert_eq!(state_of(&r.b, guid).unwrap().state, status_before.state, "the session row was not advanced by a login that was refused: no baseline was taken"); + server70.ra().run(&format!("botcmd despawn {guid}")).unwrap(); + wait_until("the refused bot to leave", 60, || !online(&r.b, guid)); + + // the Host ends the session (the realm's state under the old rules is what the checkpoint carries), and does not arm the next one + let mut bridge = LiveBridge::new(&r.b, server70.ra()); + let events = h.tick(&mut bridge, clock.elapsed().as_secs()).unwrap(); + assert!(events.iter().any(|e| matches!(e, HostEvent::ProfileMoved { .. })), "{events:?}"); + let acks = deliver(&mut h, &mut o_service, &mut bridge); + assert!(acks.iter().all(|a| a.outcome == AckOutcome::Applied || a.outcome == AckOutcome::Duplicate), "{acks:?}"); + let c3 = o_service.store().load_current(id).unwrap(); + assert_eq!(c3.progression.level, 80); + assert!(c3.build.spells.len() >= c2.build.spells.len(), "ending a session loses nothing"); + let _ = revision_before_cap_change; + + // the Manager asks the running core for the decision at the new cap, stops the realm, and updates the working copy in place + let decision70 = { + let canonical = o_service.store().load_current(id).unwrap(); + let mut ra70 = server70.ra(); + decide_with_core(&mut ra70, &jobs, &canonical).unwrap() + }; + drop(bridge); + server70.stop(); + let Decision::Projected(hold70) = decision70 else { panic!("level 80 is above 70") }; + assert_eq!(hold70.max_player_level, 70); + assert!(hold70.held_spells.len() <= hold.held_spells.len(), "a higher cap holds back no more than a lower one"); + assert!(hold70.held_items.len() <= hold.held_items.len() + 0); + let mut o70 = options(&caps70); + o70.projection = Some(crate::portable::projection::Oracle(std::sync::Arc::new(crate::portable::projection::SuppliedDecision(hold70.clone())))); + let outcome = crate::portable::realm::reproject_session(&r.b, &mut host, id, B, &o70).unwrap().expect("a session waited for its working copy"); + assert!(outcome.updated); + assert_eq!(number(&r.b, &format!("SELECT level FROM acore_characters.characters WHERE guid = {guid}")), 70); + let on_realm = spells_of(&r.b, guid); + for spell in &hold.held_spells { + assert_eq!(on_realm.contains(spell), !hold70.held_spells.contains(spell), "{spell}: restored exactly when the new cap allows it"); + } + assert_eq!(traces(&r.b, guid, &held_entries.iter().copied().filter(|e| hold70.held_items.iter().any(|h| c0.items.iter().any(|i| i.id == *h && i.entry.id() == *e))).collect::>()), 0, "what the new cap still holds is still not on the realm"); + assert_eq!(pin_words(&r.b, guid), caps70.progression.as_ref().unwrap().pin_words().iter().map(|w| w.to_string()).collect::>().join(" ")); + let ctx = context_of(&host, id); + assert_eq!((ctx.projected_level, ctx.canonical_level), (70, 80)); + assert!(host.host_live_sessions(B).unwrap().iter().all(|s| !s.reproject), "the working copy was projected again, the session may run"); +} diff --git a/crates/coa-core/src/portable/session/live_session.rs b/crates/coa-core/src/portable/session/live_session.rs index 596f552..0d31c1c 100644 --- a/crates/coa-core/src/portable/session/live_session.rs +++ b/crates/coa-core/src/portable/session/live_session.rs @@ -26,14 +26,14 @@ use super::live::{read_session_row, LiveBridge}; use super::protocol::*; use super::*; -struct Spec { - core: PathBuf, - ra_port: u16, - user: String, - password: String, +pub(super) struct Spec { + pub(super) core: PathBuf, + pub(super) ra_port: u16, + pub(super) user: String, + pub(super) password: String, } -fn spec() -> Option { +pub(super) fn spec() -> Option { let raw = std::env::var("COA_PORTABLE_LIVE_SERVER_B").ok()?; let p: Vec<&str> = raw.split('|').collect(); assert_eq!(p.len(), 3, "COA_PORTABLE_LIVE_SERVER_B = ||"); @@ -43,18 +43,24 @@ fn spec() -> Option { } /// A worldserver this test started and owns. -struct Server { +pub(super) struct Server { child: Child, - spec: Spec, + pub(super) spec: Spec, } impl Server { - fn start(spec: Spec) -> Server { + pub(super) fn start(spec: Spec) -> Server { + Self::start_with(spec, None) + } + + /// Start with another configuration file (`None`: the realm's own). + pub(super) fn start_with(spec: Spec, conf: Option<&std::path::Path>) -> Server { + let conf = conf.map(|c| c.to_path_buf()).unwrap_or_else(|| spec.core.join("configs/worldserver.conf")); let child = Command::new(spec.core.join("worldserver.exe")) - .args(["-c", spec.core.join("configs/worldserver.conf").to_str().unwrap()]) + .args(["-c", conf.to_str().unwrap()]) .current_dir(&spec.core) .stdin(Stdio::null()) - .stdout(Stdio::null()) + .stdout(std::fs::File::create(spec.core.join("test-out.log")).map(Stdio::from).unwrap_or_else(|_| Stdio::null())) .stderr(Stdio::null()) .spawn() .expect("the worldserver starts"); @@ -72,16 +78,22 @@ impl Server { } } - fn ra(&self) -> Ra { + /// What the worldserver has printed since it started. + pub(super) fn output(&self) -> String { + let read = |name: &str| std::fs::read(self.spec.core.join(name)).map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default(); + read("Logs/Server.log") + &read("test-out.log") + } + + pub(super) fn ra(&self) -> Ra { Ra::connect_to(self.spec.ra_port, &self.spec.user, &self.spec.password).expect("RA") } - fn crash(mut self) { + pub(super) fn crash(mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } - fn stop(mut self) { + pub(super) fn stop(mut self) { let _ = self.ra().run("server shutdown 1"); let end = Instant::now() + Duration::from_secs(120); while self.child.try_wait().unwrap().is_none() { @@ -100,7 +112,7 @@ impl Drop for Server { } } -fn wait_until(what: &str, seconds: u64, mut f: impl FnMut() -> bool) { +pub(super) fn wait_until(what: &str, seconds: u64, mut f: impl FnMut() -> bool) { let end = Instant::now() + Duration::from_secs(seconds); while !f() { assert!(Instant::now() < end, "timed out waiting for {what}"); @@ -108,24 +120,24 @@ fn wait_until(what: &str, seconds: u64, mut f: impl FnMut() -> bool) { } } -fn sql(db: &Db, text: &str) -> String { +pub(super) fn sql(db: &Db, text: &str) -> String { db.query(text).unwrap_or_else(|e| panic!("{e}\n{text}")) } -fn name_of(db: &Db, guid: u32) -> String { +pub(super) fn name_of(db: &Db, guid: u32) -> String { sql(db, &format!("SELECT name FROM acore_characters.characters WHERE guid = {guid}")).trim().to_string() } -fn state_of(db: &Db, guid: u32) -> Option { +pub(super) fn state_of(db: &Db, guid: u32) -> Option { read_session_row(db, guid).unwrap() } -fn online(db: &Db, guid: u32) -> bool { +pub(super) fn online(db: &Db, guid: u32) -> bool { number(db, &format!("SELECT online FROM acore_characters.characters WHERE guid = {guid}")) != 0 } -fn deliver(host: &mut HostService, owner: &mut OwnerService, bridge: &mut LiveBridge) -> Vec { +pub(super) fn deliver(host: &mut HostService, owner: &mut OwnerService, bridge: &mut LiveBridge) -> Vec { let mut acks = Vec::new(); for m in host.outbox().unwrap() { let ack = if m.started { owner.handle_started(&m.bytes) } else { owner.handle_checkpoint(&m.bytes) }.unwrap(); @@ -135,7 +147,7 @@ fn deliver(host: &mut HostService, owner: &mut OwnerService, bridge: &mut LiveBr acks } -fn tick_until(host: &mut HostService, bridge: &mut LiveBridge, clock: &Instant, what: &str, want: impl Fn(&HostEvent) -> bool) -> Vec { +pub(super) fn tick_until(host: &mut HostService, bridge: &mut LiveBridge, clock: &Instant, what: &str, want: impl Fn(&HostEvent) -> bool) -> Vec { let end = Instant::now() + Duration::from_secs(60); let mut all = Vec::new(); loop { @@ -166,7 +178,7 @@ fn characters_written(log: &str) -> std::collections::BTreeSet { out } -struct Rig { +pub(super) struct Rig { owner: Store, host: Store, id: crate::portable::ids::CharacterId, @@ -175,7 +187,7 @@ struct Rig { _dir: tempfile::TempDir, } -fn arrive(r: &crate::portable::realm::live_import::Realms) -> Rig { +pub(super) fn arrive(r: &crate::portable::realm::live_import::Realms) -> Rig { reset_b(&r.b); let dir = tempfile::tempdir().unwrap(); let mut owner = Store::open_file(&dir.path().join("owner.db")).unwrap(); @@ -317,7 +329,7 @@ fn a_character_whose_host_never_answers_is_never_released_and_the_core_disconnec // ---- the production importer: into a RUNNING realm, through the core's PortableImportService ------------------------------------ -fn job_dir() -> Option { +pub(super) fn job_dir() -> Option { std::env::var("COA_PORTABLE_LIVE_JOBDIR").ok().map(PathBuf::from) } diff --git a/crates/coa-core/src/portable/session/mod.rs b/crates/coa-core/src/portable/session/mod.rs index bc94290..613debc 100644 --- a/crates/coa-core/src/portable/session/mod.rs +++ b/crates/coa-core/src/portable/session/mod.rs @@ -19,3 +19,5 @@ mod projection_tests; mod tests; #[cfg(test)] mod live_session; +#[cfg(test)] +mod live_projection; diff --git a/crates/coa-core/src/portable/session/projection_tests.rs b/crates/coa-core/src/portable/session/projection_tests.rs index 1bd196d..642e469 100644 --- a/crates/coa-core/src/portable/session/projection_tests.rs +++ b/crates/coa-core/src/portable/session/projection_tests.rs @@ -1,7 +1,6 @@ //! Runtime sessions of a character above the realm's level cap (Phase 8): the Owner and the Host as two stores, a simulated realm at //! cap 60 between them. The core's decision is supplied (the real core is exercised by the live suite). -use super::bridge::*; use super::fake::*; use super::protocol::*; use super::*; @@ -23,7 +22,6 @@ struct World { id: CharacterId, session: SessionId, c0: PortableCharacter, - caps: RealmCapabilities, } fn caps(cap: u32, signature: &str) -> RealmCapabilities { @@ -74,7 +72,7 @@ fn world() -> World { remember(&mut host, id, SERVER, &plan).unwrap(); HostService::new(&mut host, SERVER, HostConfig::default()).bind(offer.session_id, GUID).unwrap(); } - World { owner, host, realm, id, session: offer.session_id, c0, caps } + World { owner, host, realm, id, session: offer.session_id, c0 } } fn deliver(host: &mut HostService, owner: &mut OwnerService, realm: &mut FakeRealm) -> Vec { @@ -274,3 +272,26 @@ fn a_character_online_under_the_old_profile_ends_at_its_logout() { assert!(events.iter().any(|e| matches!(e, HostEvent::ProfileWaiting { session: s, .. } if *s == session)), "{events:?}"); assert!(h.outbox().unwrap().is_empty(), "nothing is sent while the character is online"); } + +#[test] +fn what_a_character_is_bound_to_is_remembered_per_realm_and_forgotten_on_request() { + let World { mut host, id, c0, .. } = world(); + let pin = host.character_pin(id, SERVER).unwrap().expect("the import bound the character"); + assert!(pin.projected && pin.max_player_level == 60 && pin.progression_signature == SIGNATURE); + let ctx = host.projection_context(id, SERVER).unwrap().unwrap(); + assert_eq!((ctx.canonical_level, ctx.projected_level, ctx.canonical_revision), (80, 60, 1)); + assert!(host.projection_context(id, "another-realm").unwrap().is_none() && host.character_pin(id, "another-realm").unwrap().is_none()); + + host.advance_projection_revision(id, SERVER, 7).unwrap(); + assert_eq!(host.projection_context(id, SERVER).unwrap().unwrap().canonical_revision, 7); + + assert!(host.clear_projection_context(id, SERVER).unwrap()); + assert!(host.projection_context(id, SERVER).unwrap().is_none()); + host.set_mapping_pin(id, SERVER, None).unwrap(); + assert!(host.character_pin(id, SERVER).unwrap().is_none()); + assert!(host.set_mapping_pin(id, "another-realm", None).is_err(), "a character that is not on a realm has no pin there"); + let mut bad = pin.clone(); + bad.progression_signature = "zz".into(); + assert!(host.set_mapping_pin(id, SERVER, Some(&bad)).is_err()); + let _ = c0; +} diff --git a/crates/coa-core/src/portable/store/owner.rs b/crates/coa-core/src/portable/store/owner.rs index 0c2731a..41544b3 100644 --- a/crates/coa-core/src/portable/store/owner.rs +++ b/crates/coa-core/src/portable/store/owner.rs @@ -260,7 +260,7 @@ impl Store { if b1.character_id != row.character_id || b1.ruleset != c0.ruleset || b1.content_namespace != c0.content_namespace { return Ok(reject("the snapshot is another character or ruleset")); } - let projection = row.progression.as_ref().and_then(|p| p.projection.as_ref()).map(|ctx| MergeProjection { freeze_progression: true, blocked: ctx.hold.blocked_settings.iter().cloned().collect() }); + let projection = row.progression.as_ref().and_then(|p| p.projection.as_ref()).map(|ctx| MergeProjection { freeze_progression: true, adopt_progression: false, blocked: ctx.hold.blocked_settings.iter().cloned().collect() }); let merged = match merge3_with(&c0, &b0, &b1, Mode::Lenient, projection.as_ref()) { Ok(m) => m.model, Err(e) => return Ok(reject(&format!("the checkpoint cannot be merged: {e}"))),