portable: Phase 5 - in-place update arms the next session; verification record

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-06 14:35:55 +05:00
1 parent 9870a3814c
commit cfcace8337
7 files changed
+93 -10

No files matched your search

@@ -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");
}
+1 -1
View File
@@ -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.
@@ -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<UpdateOutcome> {
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<SessionId>) -> Result<UpdateOutcome> {
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<PortablePetId, u32> = 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)
}
@@ -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<PortableItemId, u32>,
pub pets: &'a HashMap<PortablePetId, u32>,
/// Arm a runtime portable session on the updated character (state 0: the core takes the baseline at its next load).
pub session: Option<super::plan::SessionArm>,
}
/// 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());
@@ -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(&current);
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(&current, &merged, &ctx).is_err(), "gender");
@@ -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");
+32
View File
@@ -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.