diff --git a/crates/coa-core/src/portable/realm/live_update.rs b/crates/coa-core/src/portable/realm/live_update.rs index cabeeb9..c325993 100644 --- a/crates/coa-core/src/portable/realm/live_update.rs +++ b/crates/coa-core/src/portable/realm/live_update.rs @@ -220,7 +220,7 @@ fn prepare_update(r: &Realms, store: &mut Store, id: CharacterId) -> (crate::por let schema = probe(&r.b).unwrap(); let users = opts().game_server_users; let revision = canonical_revision(store, id); - let ctx = |nonce| UpdateContext { ruleset: Ruleset::Coa, local_guid: guid, revision, nonce, game_server_users: &users, probe: &schema, items: &items, pets: &pets }; + let ctx = |nonce| UpdateContext { ruleset: Ruleset::Coa, local_guid: guid, revision, nonce, game_server_users: &users, probe: &schema, items: &items, pets: &pets, session: None }; let draft = build_update(&exported.model, &merged.model, &ctx([0; 4])).unwrap(); let by_id: std::collections::HashMap<_, _> = merged.model.items.iter().map(|i| (i.id, i)).collect(); let plan = UpdatePlan { @@ -579,3 +579,32 @@ fn the_complete_round_trip_without_a_server() { assert!(store.open_imports(B).unwrap().is_empty() && store.open_imports("realm-a").unwrap().is_empty()); sql(&r.a, "UPDATE acore_characters.characters SET online = 1 WHERE guid = 1006"); } + +#[test] +#[ignore] +fn an_in_place_update_arms_the_next_runtime_session_in_the_same_transaction() { + let Some(r) = realms() else { return }; + reset_b(&r.b); + let (mut store, profile) = fresh_store(); + let (id, g) = join(&r, &mut store, profile, 1002); + let mut c1 = store.load_current(id).unwrap(); + c1.progression.money += 3; + store.commit_snapshot(id, 1, c1.normalized(), "realm-a", None).unwrap(); + let session = crate::portable::ids::SessionId::new(); + let outcome = update_realm_character_in_session(&r.b, &mut store, id, B, &opts(), Some(session)).unwrap(); + assert!(outcome.updated); + let row = crate::portable::session::live::read_session_row(&r.b, g).unwrap().expect("the update armed the session"); + assert_eq!((row.session_id, row.character_id, row.state, row.imported_revision), (session, id, crate::portable::session::bridge::RowState::WaitingBaseline, 2)); + // a failed update arms nothing + let before = counts(&r.b); + let mut c2 = store.load_current(id).unwrap(); + c2.build.spells.push((500_090, 255)); + store.commit_snapshot(id, 2, c2.normalized(), "realm-a", None).unwrap(); + r.b.query("DROP TRIGGER IF EXISTS acore_characters.coa_test_fail").unwrap(); + r.b.query("DELIMITER //\nCREATE TRIGGER acore_characters.coa_test_fail BEFORE INSERT ON acore_characters.character_spell FOR EACH ROW BEGIN IF NEW.spell = 500090 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'injected failure'; END IF; END//\nDELIMITER ;").unwrap(); + let again = crate::portable::ids::SessionId::new(); + assert!(update_realm_character_in_session(&r.b, &mut store, id, B, &opts(), Some(again)).is_err()); + r.b.query("DROP TRIGGER IF EXISTS acore_characters.coa_test_fail").unwrap(); + assert_eq!(counts(&r.b), before); + assert_eq!(crate::portable::session::live::read_session_row(&r.b, g).unwrap().unwrap().session_id, session, "the failed update left the earlier session in place"); +} diff --git a/crates/coa-core/src/portable/realm/mod.rs b/crates/coa-core/src/portable/realm/mod.rs index e85b4ec..9788352 100644 --- a/crates/coa-core/src/portable/realm/mod.rs +++ b/crates/coa-core/src/portable/realm/mod.rs @@ -34,7 +34,7 @@ use super::store::{RealmRegistration, Store}; pub use blockers::Blocker; pub use export::{build, session_row, ExportRequest, Exported}; pub use import::{import_character, import_character_in_session, preflight, recover_imports, resolve_import, ImportOptions, ImportOutcome, ImportProblem, PreflightReport, Resolution}; -pub use reconcile::{begin_session, reconcile_session, resolve_import_update, update_realm_character, ReconcileOutcome, SessionStart, UpdateOutcome}; +pub use reconcile::{begin_session, reconcile_session, resolve_import_update, update_realm_character, update_realm_character_in_session, ReconcileOutcome, SessionStart, UpdateOutcome}; pub use script::SchemaProbe; /// The ruleset a database belongs to is decided by which realm profile it is (`realms::Mode`), never by the caller. diff --git a/crates/coa-core/src/portable/realm/reconcile.rs b/crates/coa-core/src/portable/realm/reconcile.rs index f0410e5..5d78759 100644 --- a/crates/coa-core/src/portable/realm/reconcile.rs +++ b/crates/coa-core/src/portable/realm/reconcile.rs @@ -23,12 +23,12 @@ use std::collections::HashMap; use crate::db::Db; use super::super::error::{PortableError, Result}; -use super::super::ids::{CharacterId, ImportId, PortableItemId, PortablePetId}; +use super::super::ids::{CharacterId, ImportId, PortableItemId, PortablePetId, SessionId}; use super::super::merge::{merge3, ItemOutcome, Mode, PetOutcome}; use super::super::model::PortableCharacter; use super::super::store::{BaselineInput, ImportAllocation, ImportState, JournalEntry, JournalKind, PlannedItem, PlannedPet, Store, UpdatePlan}; use super::import::{ImportOptions, ImportProblem, Resolution}; -use super::plan::IMPORT_LOCK; +use super::plan::{SessionArm, IMPORT_LOCK}; use super::script::{parse_output, Query}; use super::sqlenc::Val; use super::update::{build_update, new_content, parse_update_report, UpdateContext, UpdateCounts}; @@ -209,6 +209,11 @@ fn update_problems(db: &Db, local_guid: u32, items: &std::collections::BTreeSet< /// Refused (nothing written) when a session is open on that realm (reconcile it first), when the realm is running, or when /// the realm and the canonical character changed the same thing differently since they were last synchronised. pub fn update_realm_character(db: &Db, store: &mut Store, id: CharacterId, server_id: &str, opts: &ImportOptions) -> Result { + update_realm_character_in_session(db, store, id, server_id, opts, None) +} + +/// The same, arming the runtime portable session `session` on the updated character in the same realm transaction. +pub fn update_realm_character_in_session(db: &Db, store: &mut Store, id: CharacterId, server_id: &str, opts: &ImportOptions, session: Option) -> Result { let (guid, last_revision) = local_guid(store, id, server_id)?; if store.open_baseline(id, server_id)?.is_some() { return Err(PortableError::SessionOpen); @@ -241,6 +246,7 @@ pub fn update_realm_character(db: &Db, store: &mut Store, id: CharacterId, serve probe: &schema, items: &view.items, pets: &view.pets, + session: session.map(|session_id| SessionArm { session_id, character_id: id, generation: 1 }), }; // the plan (what is added, what is removed) does not depend on the nonce let draft = build_update(&view.exported.model, &merged.model, &context([0; 4]))?; @@ -380,5 +386,5 @@ pub fn summarize(merged: &PortableCharacter, current: &PortableCharacter) -> Res let pets: HashMap = current.pets.iter().enumerate().map(|(i, p)| (p.id, i as u32 + 1)).collect(); let users = ["acore".to_string()]; let schema = super::script::SchemaProbe::default(); - Ok(build_update(current, merged, &UpdateContext { ruleset: current.ruleset, local_guid: 1, revision: 1, nonce: [0; 4], game_server_users: &users, probe: &schema, items: &items, pets: &pets })?.counts) + Ok(build_update(current, merged, &UpdateContext { ruleset: current.ruleset, local_guid: 1, revision: 1, nonce: [0; 4], game_server_users: &users, probe: &schema, items: &items, pets: &pets, session: None })?.counts) } diff --git a/crates/coa-core/src/portable/realm/update.rs b/crates/coa-core/src/portable/realm/update.rs index 5f6643f..4a1b0a6 100644 --- a/crates/coa-core/src/portable/realm/update.rs +++ b/crates/coa-core/src/portable/realm/update.rs @@ -37,6 +37,8 @@ pub struct UpdateContext<'a> { /// The items the realm holds now (portable id -> local guid): what `current` was exported from. pub items: &'a HashMap, pub pets: &'a HashMap, + /// Arm a runtime portable session on the updated character (state 0: the core takes the baseline at its next load). + pub session: Option, } /// What the script does, in numbers (and as the plan the journal records). @@ -631,6 +633,18 @@ pub fn build_update(current: &PortableCharacter, merged: &PortableCharacter, ctx m.row(vec![Val::Expr("@char"), markers[0].clone(), Val::text(marker.clone())])?; m.row(vec![Val::Expr("@char"), markers[1].clone(), Val::Expr("CONCAT(@item_base, ' ', @pet_base, ' ')")])?; line(m.sql().expect("two rows")); + if let Some(arm) = &ctx.session { + if !ctx.probe.has("coa_portable_session") { + return Err(PortableError::Invalid("this realm's core has no portable session support (coa_portable_session is missing)".into())); + } + line(format!( + "INSERT INTO acore_characters.`coa_portable_session` (`guid`, `session_id`, `character_id`, `imported_revision`, `baseline_generation`, `state`, `checkpoint_seq`, `save_seq`, `updated_at`) VALUES (@char, {}, {}, {}, {}, 0, 0, 0, UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE `session_id` = VALUES(`session_id`), `character_id` = VALUES(`character_id`), `imported_revision` = VALUES(`imported_revision`), `baseline_generation` = VALUES(`baseline_generation`), `state` = 0, `checkpoint_seq` = 0, `updated_at` = VALUES(`updated_at`);", + Val::text(arm.session_id.to_string()).sql(), + Val::text(arm.character_id.to_string()).sql(), + ctx.revision, + arm.generation + )); + } line("SELECT '#R:update', @char, @item_base, @pet_base;".into()); line("COMMIT;".into()); diff --git a/crates/coa-core/src/portable/realm/update_tests.rs b/crates/coa-core/src/portable/realm/update_tests.rs index 5198094..d902527 100644 --- a/crates/coa-core/src/portable/realm/update_tests.rs +++ b/crates/coa-core/src/portable/realm/update_tests.rs @@ -21,7 +21,7 @@ fn script_of(current: &PortableCharacter, merged: &PortableCharacter) -> UpdateS let (items, pets) = realm_ids(current); let users = ["acore".to_string()]; let probe = probe(); - build_update(current, merged, &UpdateContext { ruleset: Ruleset::Coa, local_guid: 4242, revision: 9, nonce: [1, 2, 3, 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets }).unwrap() + build_update(current, merged, &UpdateContext { ruleset: Ruleset::Coa, local_guid: 4242, revision: 9, nonce: [1, 2, 3, 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets, session: None }).unwrap() } fn statements(script: &UpdateScript) -> Vec<&str> { @@ -184,7 +184,7 @@ fn what_a_character_is_cannot_be_changed_in_place() { let (items, pets) = realm_ids(¤t); let users = ["acore".to_string()]; let probe = probe(); - let ctx = UpdateContext { ruleset: Ruleset::Coa, local_guid: 1, revision: 2, nonce: [0; 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets }; + let ctx = UpdateContext { ruleset: Ruleset::Coa, local_guid: 1, revision: 2, nonce: [0; 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets, session: None }; let mut merged = current.clone(); merged.identity.gender ^= 1; assert!(build_update(¤t, &merged, &ctx).is_err(), "gender"); diff --git a/crates/coa-core/src/portable/session/live_session.rs b/crates/coa-core/src/portable/session/live_session.rs index b22dfc4..b12d699 100644 --- a/crates/coa-core/src/portable/session/live_session.rs +++ b/crates/coa-core/src/portable/session/live_session.rs @@ -339,7 +339,8 @@ fn the_core_imports_characters_into_a_running_realm_like_the_offline_importer_do let mut ra = server.ra(); let chars_before = number(&r.b, "SELECT COUNT(*) FROM acore_characters.characters"); let next_guid = number(&r.b, "SELECT MAX(guid) + 1 FROM acore_characters.characters"); - let numchars_before = number(&r.b, &format!("SELECT IFNULL((SELECT numchars FROM acore_auth.realmcharacters WHERE acctid = {ACCOUNT} AND realmid = 1), 0)")); + sql(&r.b, &format!("REPLACE INTO acore_auth.realmcharacters (realmid, acctid, numchars) VALUES (1, {ACCOUNT}, 0)")); + let numchars_before = 0; let mut imported = Vec::new(); for a_guid in [1002u32, 1004, 1005, 1001] { @@ -361,7 +362,8 @@ fn the_core_imports_characters_into_a_running_realm_like_the_offline_importer_do assert!(reply.contains("changed level"), "the name cache knows the new character: {reply}"); assert_eq!(number(&r.b, &format!("SELECT level FROM acore_characters.characters WHERE guid = {guid}")), canonical.progression.level as u64 + 1); let numchars = number(&r.b, &format!("SELECT numchars FROM acore_auth.realmcharacters WHERE acctid = {ACCOUNT} AND realmid = 1")); - assert_eq!(numchars, numchars_before + 4, "the realm character count of the account was updated"); + assert_eq!(numchars, number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.characters WHERE account = {ACCOUNT}")), "the realm character count of the account was updated to what the account really has"); + assert!(numchars > numchars_before); let _ = id; ra.run(&format!("botcmd spawnbot {guid}")).unwrap(); wait_until("the imported character to log in", 90, || online(&r.b, *guid)); @@ -397,7 +399,7 @@ fn the_core_imports_characters_into_a_running_realm_like_the_offline_importer_do let _ = std::fs::remove_file(jobs.join(format!("{job}.job"))); let _ = std::fs::remove_file(jobs.join(format!("{job}.result"))); }; - let mut impossible = model.clone(); + let mut impossible = imported[0].3.clone(); impossible.items[0].entry = crate::portable::ids::ContentId::new("coa", "item", 4_000_000_000).unwrap(); refused(crate::portable::realm::online::job_bytes(ticket.import_id, [9, 9, 9, 9], ACCOUNT, 1, 10, &impossible, None).unwrap(), "an item the realm does not know"); refused(crate::portable::realm::online::job_bytes(ticket.import_id, [9, 9, 9, 8], 99_999_999, 1, 10, &model, None).unwrap(), "an account that does not exist"); diff --git a/docs/PORTABLE_SESSIONS.md b/docs/PORTABLE_SESSIONS.md index b562822..74b6068 100644 --- a/docs/PORTABLE_SESSIONS.md +++ b/docs/PORTABLE_SESSIONS.md @@ -214,3 +214,35 @@ transfer, personal Ascension bank, equipment sets, level-cap projection. checkpoint; disconnect/crash before the final checkpoint. * Real worldserver with the character online and several checkpoints without logout; the number of `characters` rows written during a checkpoint is counted on the database (general log) to show one character is saved, not all. + +## 10. What was verified + +Manager: 413 workspace tests (the session layer alone has 15 with a simulated realm that normalises characters, holds the player until +the baseline is released, saves on request and crashes on demand), `check-i18n`, `npm run build`. Owner and Host are two separate stores in every +test; restarts of either are tests of their own (files closed and reopened between steps). + +Live, two disposable MySQL servers and **a real worldserver built from the core branch** (`feat/portable-session-bridge`, commit `aeba87f04`, +not pushed): + +* `a_running_worldserver_takes_its_own_baseline_and_checkpoints_one_character_without_a_logout`: the character is imported with its session + armed, logs in (`botcmd spawnbot`, the real `HandlePlayerLoginFromDB`) next to two other online characters, is held at `state = 1`, the Host + takes `B0` and releases it, then three **real** level changes each produce one checkpoint **without a logout**; the database's general log shows + exactly one `characters` row written per checkpoint (the character's own), the Owner gets revisions 2, 3, 4 with the real level, and what the realm + deleted at load is still in the canonical character. Then logout -> final checkpoint -> the next session armed -> the next login takes its baseline + by itself again; a worldserver **crash** in the middle of a session is continued without a new baseline and without gate; a **clean shutdown** + ends the session like a logout and the Host sends the final checkpoint. +* `a_character_whose_host_never_answers_is_never_released_and_the_core_disconnects_it`: no Host, no release: the core gives up after the + gate timeout, the row stays in `state = 1`, nothing was played. +* `the_core_imports_characters_into_a_running_realm_like_the_offline_importer_does`: four characters imported into the **running** realm; each + reads back identical to what the offline importer makes of it; the id generators, the name cache (a command by name finds the new character), the + account's realm character count and an immediate login all work; the same job id twice creates nothing; an unknown item, a missing account, a + tampered snapshot and garbage are refused and write nothing. +* the earlier live suites (export, offline import, reconcile, in-place update, round trip) still pass with the new table in the schema; the update + also arms the next session in the same realm transaction (`an_in_place_update_arms_the_next_runtime_session_in_the_same_transaction`). + +Core: `python -B tools/verify_all.py --stages source --base origin/coa-bots` passes (comments, boundaries, registrations); the C++ code style +check passes on the changed files. The build, unit, harness and gameplay stages were not run: a worldserver was built and exercised through the live +tests above instead. + +Known limits: a bot session has no socket, so the gate's packet hold and kick are only exercised for real clients by a manual test; the +core's job parser is exercised through the live importer test, not through its own unit tests.