mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
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:
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");
|
||||
}
|
||||
@@ -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(¤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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user