mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Phase 8 (part 2): live gates, smoke tool, pin at login, same policy on import and update
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
426808527b
commit
ffb1df3fce
14 files changed
+646
-47
No files matched your search
@@ -28,6 +28,9 @@
|
||||
//! reevaluate <character-id> <server-id> look again at what was held back when the realm's content profile changed (realm stopped)
|
||||
//! collection-add <kind> <id>... (owner store) add ids to a profile collection by hand (a test aid)
|
||||
//! collection-show [<account>] the Owner's collections (revision, count, hash) and, with an account, the realm's
|
||||
//! project <character-id> <server-id> ask the realm's running core what a level-cap projection of the character holds (needs --ra-port
|
||||
//! and --job-dir); --out <file> writes the decision, which a later `import`/`update` of a STOPPED realm can be given with
|
||||
//! --projection <file> (it answers only for the exact state of the character it was made for)
|
||||
//!
|
||||
//! --collection-interval <seconds> 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<u16> = 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())?;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<ProjectionContext>(&json).unwrap(), ctx);
|
||||
assert!(serde_json::from_str::<ProjectionContext>(&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));
|
||||
}
|
||||
@@ -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="<bin>|13998|<pw>" COA_PORTABLE_LIVE_B="<bin>|13997|<pw>" 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<ProjectionHold>) -> 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<u32> {
|
||||
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::<Vec<_>>().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)));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -598,7 +598,7 @@ pub fn build_plan(model: &PortableCharacter, ctx: &PlanContext<'_>) -> Result<Im
|
||||
line(assert_count("acore_characters.character_queststatus_rewarded WHERE guid = @char", counts.rewarded));
|
||||
line(assert_count("acore_characters.character_action WHERE guid = @char", counts.actions));
|
||||
line(assert_count("acore_characters.character_pet WHERE owner = @char", counts.pets));
|
||||
line(assert_count("acore_characters.character_settings WHERE guid = @char", counts.settings + 1));
|
||||
line(assert_count("acore_characters.character_settings WHERE guid = @char", counts.settings + 1 + usize::from(ctx.pin.is_some())));
|
||||
if let Some(w) = &wardrobe_rows {
|
||||
line(assert_count("acore_characters.character_appearance WHERE guid = @char", w.active.len()));
|
||||
line(assert_count("acore_characters.character_appearance_outfit WHERE guid = @char", w.outfits.len()));
|
||||
|
||||
@@ -83,11 +83,7 @@ pub fn assemble(db: &Db, probe: &SchemaProbe, data_dir: Option<&Path>, 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.
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<String> = 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()));
|
||||
|
||||
@@ -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, "<Manager store dir>|<character id>": 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("<store dir>|<character id>");
|
||||
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::<Vec<_>>().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<u64> {
|
||||
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<u32> {
|
||||
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<u64> = 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::<Vec<_>>().join(" "), "the core stores the progression the character was prepared for");
|
||||
for source in hold.settings.iter().map(|s| &s.source) {
|
||||
let value: Vec<u32> = 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::<Vec<_>>()), 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::<Vec<_>>().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");
|
||||
}
|
||||
@@ -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<Spec> {
|
||||
pub(super) fn spec() -> Option<Spec> {
|
||||
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 = <Core dir>|<RA port>|<repack.json>");
|
||||
@@ -43,18 +43,24 @@ fn spec() -> Option<Spec> {
|
||||
}
|
||||
|
||||
/// 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<SessionRow> {
|
||||
pub(super) fn state_of(db: &Db, guid: u32) -> Option<SessionRow> {
|
||||
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<OwnerAck> {
|
||||
pub(super) fn deliver(host: &mut HostService, owner: &mut OwnerService, bridge: &mut LiveBridge) -> Vec<OwnerAck> {
|
||||
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<HostEvent> {
|
||||
pub(super) fn tick_until(host: &mut HostService, bridge: &mut LiveBridge, clock: &Instant, what: &str, want: impl Fn(&HostEvent) -> bool) -> Vec<HostEvent> {
|
||||
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<u32> {
|
||||
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<PathBuf> {
|
||||
pub(super) fn job_dir() -> Option<PathBuf> {
|
||||
std::env::var("COA_PORTABLE_LIVE_JOBDIR").ok().map(PathBuf::from)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,5 @@ mod projection_tests;
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod live_session;
|
||||
#[cfg(test)]
|
||||
mod live_projection;
|
||||
@@ -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<OwnerAck> {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"))),
|
||||
|
||||
Reference in new issue
Block a user