diff --git a/Cargo.lock b/Cargo.lock index 381c3e6..8e17467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.0" +version = "0.9.1" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.0" +version = "0.9.1" dependencies = [ "chrono", "coa-core", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.9.0" +version = "0.9.1" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index ca2cd34..cfdf328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] -version = "0.9.0" +version = "0.9.1" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261028-existing-character-update-validation.md b/changelog.d/20261028-existing-character-update-validation.md new file mode 100644 index 0000000..ce8950e --- /dev/null +++ b/changelog.d/20261028-existing-character-update-validation.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Existing unsupported character records no longer block unrelated server updates +--- +Updates compare each affected character with its saved pre-update state. Unchanged older problems are reported as warnings without changing characters. Newly missing race/class data still blocks the update and triggers recovery; schema and identity checks remain enforced. diff --git a/crates/coa-core/src/character_start_check.rs b/crates/coa-core/src/character_start_check.rs new file mode 100644 index 0000000..94ad94f --- /dev/null +++ b/crates/coa-core/src/character_start_check.rs @@ -0,0 +1,124 @@ +//! Update-only comparison of existing unsupported character starts. Never repairs or deletes rows. +use crate::{db::Db, Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct MissingStart { + pub guid: u32, + pub race: u32, + pub class: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Baseline { + pub schema: u32, + pub realms: BTreeMap>, +} + +pub fn parse_missing(text: &str) -> Result> { + text.lines() + .map(|line| { + let values = line + .split('\t') + .map(str::parse::) + .collect::, _>>() + .map_err(|_| { + Error::Invalid("Cannot read existing character start identities.".into()) + })?; + if values.len() != 3 { + return Err(Error::Invalid( + "Incomplete character start identity.".into(), + )); + } + Ok(MissingStart { + guid: values[0], + race: values[1], + class: values[2], + }) + }) + .collect() +} + +pub fn missing(db: &Db) -> Result> { + parse_missing(&db.query("SELECT c.guid,c.race,c.class FROM acore_characters.characters c LEFT JOIN acore_world.playercreateinfo p ON p.race=c.race AND p.class=c.class WHERE p.race IS NULL;")?) +} + +pub fn capture(db: &Db, root: &Path) -> Result { + let mut realms = BTreeMap::new(); + for mode in crate::realms::state(root)?.modes() { + realms.insert( + mode.name().to_owned(), + missing(&db.clone().for_realm(mode))?, + ); + } + Ok(Baseline { schema: 1, realms }) +} + +/// Match identity and race/class, never just totals: one new failure cannot replace an old one. +pub fn introduced( + current: &BTreeSet, + before: &BTreeSet, +) -> BTreeSet { + current.difference(before).copied().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn old_gaps_are_not_new_character_loss() { + let before = parse_missing("1\t44\t14\n2\t44\t14\n3\t44\t14").unwrap(); + assert!(introduced(&before, &before).is_empty()); + assert!(introduced(&BTreeSet::new(), &before).is_empty()); + let replaced = parse_missing("1\t44\t14\n2\t44\t14\n4\t44\t14").unwrap(); + assert_eq!( + introduced(&replaced, &before), + parse_missing("4\t44\t14").unwrap() + ); + let changed = parse_missing("1\t44\t15").unwrap(); + assert_eq!(introduced(&changed, &before), changed); + assert_eq!(introduced(&before, &BTreeSet::new()), before); + } + #[test] + fn malformed_identity_is_not_silently_ignored() { + for text in ["1\t44", "bad\t44\t14", "1\t44\t14\t0", "1\t44\t-1"] { + assert!(parse_missing(text).is_err()); + } + assert!(parse_missing("").unwrap().is_empty()); + } + + #[test] + fn reported_174_old_records_cannot_mask_one_newly_hidden_character() { + // Reproduce the report's scale without shipping another person's diagnostics or identities. + let before: BTreeSet<_> = (1..=174) + .map(|guid| MissingStart { + guid, + race: 44, + class: 14, + }) + .collect(); + assert!(introduced(&before, &before).is_empty()); + let mut after = before.clone(); + after.remove(&MissingStart { + guid: 1, + race: 44, + class: 14, + }); + after.insert(MissingStart { + guid: 175, + race: 44, + class: 14, + }); + assert_eq!(after.len(), before.len()); + assert_eq!( + introduced(&after, &before), + BTreeSet::from([MissingStart { + guid: 175, + race: 44, + class: 14 + }]) + ); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index efb7827..341df96 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod accounts; pub mod allsettings; pub mod backup; pub mod channels; +pub mod character_start_check; pub mod cleanbase; pub mod client; pub mod clientdl; diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 42b2988..30d8d2d 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -1004,6 +1004,89 @@ mod tests { }; let mut server = start(); db.query("CREATE DATABASE acore_characters; CREATE DATABASE acore_world; CREATE DATABASE acore_auth; CREATE TABLE acore_characters.probe(id INT PRIMARY KEY, note VARCHAR(40)); CREATE TABLE acore_world.probe(id INT NOT NULL DEFAULT 0); INSERT INTO acore_world.probe VALUES(43); CREATE TABLE acore_auth.probe(id INT); INSERT INTO acore_auth.probe VALUES(44); CREATE USER 'fixture_user'@'localhost' IDENTIFIED BY 'fixture'; CREATE TRIGGER acore_characters.probe_insert BEFORE INSERT ON acore_characters.probe FOR EACH ROW SET NEW.note='triggered'; INSERT INTO acore_characters.probe VALUES(42,'original'); CREATE VIEW acore_characters.probe_view AS SELECT c.id,w.id AS world_id FROM acore_characters.probe c CROSS JOIN acore_world.probe w; CREATE PROCEDURE acore_characters.probe_proc() SELECT id FROM acore_characters.probe; CREATE FUNCTION acore_characters.probe_func() RETURNS INT DETERMINISTIC RETURN 73;").unwrap(); + // Regression: an upgrade must distinguish pre-existing unsupported character starts + // from a newly lost start, without weakening any structural schema check. + db.query("CREATE TABLE acore_characters.characters(guid INT UNSIGNED PRIMARY KEY,race INT UNSIGNED NOT NULL,class INT UNSIGNED NOT NULL); CREATE TABLE acore_world.playercreateinfo(race INT UNSIGNED,class INT UNSIGNED,PRIMARY KEY(race,class)); INSERT INTO acore_characters.characters VALUES(100,44,14),(101,44,14),(102,7,14);").unwrap(); + let starts = [1, 2, 3, 4, 5, 6, 7, 8, 10, 11] + .into_iter() + .flat_map(|race| (12..=32).map(move |class| format!("({race},{class})"))) + .collect::>() + .join(","); + db.query(&format!( + "INSERT INTO acore_world.playercreateinfo VALUES {starts};" + )) + .unwrap(); + crate::schema_check::capture(&db, &root).unwrap(); + let baseline = crate::character_start_check::capture(&db, &root).unwrap(); + assert_eq!(baseline.realms[crate::realms::Mode::Coa.name()].len(), 2); + assert!( + !crate::schema_check::check(&db, &root).unwrap().is_empty(), + "ordinary diagnostics stay strict" + ); + assert!( + crate::schema_check::check_update_with_report(&db, &root, Some(&baseline)) + .unwrap() + .is_empty(), + "unchanged old gaps must not block an unrelated upgrade" + ); + assert_eq!( + db.query("SELECT COUNT(*) FROM acore_characters.characters;") + .unwrap() + .trim(), + "3" + ); + let report_dir = crate::registry::metadata_dir_for(&root) + .unwrap() + .join("diagnostics"); + let report_path = fs::read_dir(report_dir) + .unwrap() + .flatten() + .map(|e| e.path()) + .find(|p| { + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("schema-validation-") + }) + .unwrap(); + let report: serde_json::Value = fsx::read_json(&report_path).unwrap(); + assert_eq!(report["preexistingUnsupportedCharacters"], 2); + assert_eq!( + report["preexistingCharacterStarts"], + serde_json::json!([{"race":44,"class":14,"count":2}]) + ); + assert_eq!(report["problems"], serde_json::json!([])); + // Same count and race/class but a different GUID must still block. + db.query("UPDATE acore_characters.characters SET guid=103 WHERE guid=101;") + .unwrap(); + assert!( + !crate::schema_check::check_update_with_report(&db, &root, Some(&baseline)) + .unwrap() + .is_empty() + ); + db.query("UPDATE acore_characters.characters SET guid=101 WHERE guid=103;") + .unwrap(); + // Loss of a formerly valid start is never grandfathered. + db.query("DELETE FROM acore_world.playercreateinfo WHERE race=7 AND class=14;") + .unwrap(); + assert!( + !crate::schema_check::check_update_with_report(&db, &root, Some(&baseline)) + .unwrap() + .is_empty() + ); + db.query("INSERT INTO acore_world.playercreateinfo VALUES(7,14);") + .unwrap(); + db.query("ALTER TABLE acore_world.probe MODIFY id VARCHAR(30) NOT NULL DEFAULT '0';") + .unwrap(); + assert!( + !crate::schema_check::check_update_with_report(&db, &root, Some(&baseline)) + .unwrap() + .is_empty(), + "existing character warnings cannot hide a structural failure" + ); + db.query("ALTER TABLE acore_world.probe MODIFY id INT NOT NULL DEFAULT 0; DROP TABLE acore_characters.characters; DROP TABLE acore_world.playercreateinfo;").unwrap(); + fs::remove_file(root.join(crate::schema_check::CONTRACT)).unwrap(); + // Shared auth must not run again when the active realm changes. use crate::migrations::Store; db.query("CREATE DATABASE acore_world_wildcard;").unwrap(); diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index e3cd053..f48a9f4 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -95,13 +95,42 @@ pub struct Problem { /// Keep all differences for support; never change a database to match a contract automatically. pub fn check_with_report(db: &Db, root: &Path) -> Result> { - let problems = check(db, root)?; - if problems.is_empty() { + check_update_with_report(db, root, None) +} + +/// Only a hash-verified pre-update baseline may downgrade unchanged missing starts to warnings. +pub fn check_update_with_report( + db: &Db, + root: &Path, + baseline: Option<&crate::character_start_check::Baseline>, +) -> Result> { + let mut preexisting = BTreeMap::new(); + let before = match baseline { + Some(b) if b.schema == 1 => Some(b.realms.get(db.realm().name()).ok_or_else(|| { + crate::Error::Invalid("The character baseline does not contain this realm.".into()) + })?), + Some(_) => { + return Err(crate::Error::Invalid( + "Unsupported character start baseline.".into(), + )) + } + None => None, + }; + let problems = check_inner(db, root, before, &mut preexisting)?; + let existing: usize = preexisting.values().sum(); + let preexisting_details: Vec<_> = preexisting.into_iter().map(|((race, class), count)| serde_json::json!({"race": race, "class": class, "count": count})).collect(); + if existing > 0 { + tracing::warn!(realm = db.realm().name(), characters = existing, "Unchanged pre-update characters with missing starting data retained; no character records were repaired or deleted"); + } + if problems.is_empty() && existing == 0 { return Ok(problems); } let report = serde_json::json!({ "schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(), "realm": db.realm().name(), "problems": problems, + "preexistingUnsupportedCharacters": existing, + "preexistingCharacterStarts": preexisting_details, + "note": "Unchanged pre-update missing starts are reported separately. New missing character starts and all schema errors still block the update.", }); let path = crate::registry::metadata_dir_for(root)? .join("diagnostics") @@ -245,6 +274,15 @@ pub(crate) fn repair_missing_defaults( } pub fn check(db: &Db, root: &Path) -> Result> { + check_inner(db, root, None, &mut BTreeMap::new()) +} + +fn check_inner( + db: &Db, + root: &Path, + before: Option<&BTreeSet>, + existing: &mut BTreeMap<(u32, u32), usize>, +) -> Result> { let contract = if root.join(CONTRACT).is_file() { let contract: Contract = fsx::read_json(&root.join(CONTRACT))?; if contract.schema != 1 { @@ -367,12 +405,24 @@ pub fn check(db: &Db, root: &Path) -> Result> { } // Check owned characters too, including legacy classes and Wildcard characters. Their rows // must not silently disappear from character selection after an otherwise healthy update. - let missing = db.query("SELECT c.race,c.class,COUNT(*) FROM acore_characters.characters c LEFT JOIN acore_world.playercreateinfo p ON p.race=c.race AND p.class=c.class WHERE p.race IS NULL GROUP BY c.race,c.class;")?; - for row in missing.lines() { - let fields: Vec<_> = row.split('\t').collect(); - if fields.len() == 3 { - problems.push(Problem { database: "world".into(), table: "playercreateinfo".into(), column: String::new(), detail: format!("{} existing characters have no starting data for race {} / class {} and would be hidden from character selection", fields[2], fields[0], fields[1]) }); + let current = crate::character_start_check::missing(db)?; + let blocking = if let Some(before) = before { + let introduced = crate::character_start_check::introduced(¤t, before); + for character in current.intersection(before) { + *existing + .entry((character.race, character.class)) + .or_default() += 1; } + introduced + } else { + current + }; + let mut counts = BTreeMap::<(u32, u32), usize>::new(); + for character in blocking { + *counts.entry((character.race, character.class)).or_default() += 1; + } + for ((race, class), count) in counts { + problems.push(Problem { database: "world".into(), table: "playercreateinfo".into(), column: String::new(), detail: format!("{count} existing characters have no starting data for race {race} / class {class} and would be hidden from character selection") }); } Ok(problems) } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index f84ef2d..76daf3e 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -22,6 +22,8 @@ use crate::pkgsource::{fetch_manifest, fetch_parts, Source}; use crate::registry::{InstallMeta, MetaDir}; const STAGED_PREFIX: &str = "_migrations/"; +const CHARACTER_BASELINE: &str = "character-start-baseline.json"; +const CHARACTER_BASELINE_HASH: &str = "@character-starts"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -106,6 +108,9 @@ pub trait Env { fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } + fn character_start_baseline(&self) -> Result> { + Ok(None) + } fn snapshot(&self) -> Result; fn restore_snapshot(&self, id: &str) -> Result<()>; fn migrate(&self, manifest: &Manifest, staged_migrations: &Path) -> Result; @@ -223,11 +228,12 @@ fn read_journal(path: &Path) -> Result { "Update journal contains invalid recovery hashes.".into(), )); } - for key in txn - .recovery_hashes - .keys() - .filter(|key| !matches!(key.as_str(), "@install" | "@point")) - { + for key in txn.recovery_hashes.keys().filter(|key| { + !matches!( + key.as_str(), + "@install" | "@point" | CHARACTER_BASELINE_HASH + ) + }) { fsx::safe_join(path.parent().unwrap(), key)?; } if completed_legacy_repair { @@ -684,6 +690,7 @@ pub struct Params<'a> { #[derive(Debug, Serialize)] pub struct Outcome { pub txn: Txn, + pub preexisting_character_count: usize, pub migrations: Option, } @@ -816,6 +823,26 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { .insert("@point".into(), fsx::sha256_file(&point_meta)?); } save(meta_dir, &txn)?; + // Capture before any new files or SQL are applied; retry must reuse these exact bytes. + let baseline = match p.env.character_start_baseline() { + Ok(baseline) => baseline, + Err(error) => { + txn.state = State::Failed; + txn.message = Some(format!("The update was not applied because the character baseline could not be captured: {error}")); + save(meta_dir, &txn)?; + return Err(error); + } + }; + let preexisting_character_count = baseline + .as_ref() + .map_or(0, |b| b.realms.values().map(|r| r.len()).sum()); + if let Some(baseline) = &baseline { + let path = tdir.join(CHARACTER_BASELINE); + fsx::atomic_write_json(&path, baseline)?; + txn.recovery_hashes + .insert(CHARACTER_BASELINE_HASH.into(), fsx::sha256_file(&path)?); + save(meta_dir, &txn)?; + } txn.ops = items .iter() .filter(|i| i.action != Action::Skip) @@ -909,6 +936,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { save(meta_dir, &txn)?; return Ok(Outcome { txn, + preexisting_character_count, migrations: migrated, }); } @@ -919,6 +947,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { let _ = archive; Ok(Outcome { txn, + preexisting_character_count, migrations: migrated, }) } @@ -1039,6 +1068,7 @@ pub fn retry_validation( ))); } } + read_character_baseline(meta_dir, &txn)?; env.ensure_stopped()?; if let Err(error) = env.validate() { txn.message = Some(format!( @@ -1115,7 +1145,13 @@ fn restore_transaction( txn: &mut Txn, env: &dyn Env, ) -> Result<()> { - for (rel, expected) in &txn.recovery_hashes { + // This baseline is a validation input, not a recovery copy: corruption must block + // retry/commit, but must never prevent restoration of the verified safety backup. + for (rel, expected) in txn + .recovery_hashes + .iter() + .filter(|(key, _)| key.as_str() != CHARACTER_BASELINE_HASH) + { let saved = match rel.as_str() { "@install" => before.join("manager-install.json"), "@point" => crate::backup::point_json( @@ -1504,6 +1540,33 @@ fn prune(meta_dir: &Path, keep: usize) { } } +fn read_character_baseline( + meta: &Path, + txn: &Txn, +) -> Result> { + let path = txn_dir(meta, &txn.id)?.join(CHARACTER_BASELINE); + let Some(expected) = txn.recovery_hashes.get(CHARACTER_BASELINE_HASH) else { + if path.exists() { + return Err(Error::Invalid( + "An unverified character start baseline was found.".into(), + )); + } + return Ok(None); // Older transactions remain strict; never learn a baseline after applying SQL. + }; + if fsx::sha256_file(&path)? != *expected { + return Err(Error::Invalid( + "The saved character start baseline failed verification.".into(), + )); + } + let baseline: crate::character_start_check::Baseline = fsx::read_json(&path)?; + if baseline.schema != 1 { + return Err(Error::Invalid( + "Unsupported character start baseline.".into(), + )); + } + Ok(Some(baseline)) +} + /// The real environment: a repack-shaped installation driven through its launcher. pub struct RepackEnv<'a> { pub root: &'a Path, @@ -1511,6 +1574,12 @@ pub struct RepackEnv<'a> { } impl RepackEnv<'_> { + fn saved_character_baseline(&self) -> Result> { + match unfinished(self.meta_dir) { + Some(txn) => read_character_baseline(self.meta_dir, &txn), + None => Ok(None), + } + } pub fn pending_migrations(&self, manifest: &Manifest) -> Result { Ok(self .pending_migrations_with(manifest, DatabaseAccess::Start)? @@ -1527,9 +1596,9 @@ impl RepackEnv<'_> { return Ok(Some(0)); } let count = |db: &crate::db::Db| -> Result { - let db = db.clone().with_read_deadline( - std::time::Instant::now() + std::time::Duration::from_secs(15), - ); + let db = db + .clone() + .with_read_deadline(std::time::Instant::now() + std::time::Duration::from_secs(15)); let realms = crate::realms::state(self.root)?; let modes = realms.modes(); let mut count = 0; @@ -1772,12 +1841,25 @@ impl Env for RepackEnv<'_> { Ok(()) } + fn character_start_baseline(&self) -> Result> { + crate::backup::with_database(self.root, |db| { + crate::character_start_check::capture(db, self.root) + }) + .map(Some) + } + fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result { crate::backup::with_database(self.root, |db| { let identities = database_identities(db)?; - let result = migrate_databases(db, self.root, manifest, staged, &|| { - self.migration_snapshot() - })?; + let baseline = self.saved_character_baseline()?; + let result = migrate_databases( + db, + self.root, + manifest, + staged, + &|| self.migration_snapshot(), + baseline.as_ref(), + )?; if result.failed.is_none() { verify_database_identities(db, &identities)?; } @@ -1799,6 +1881,11 @@ impl RepackEnv<'_> { crate::mysql_snapshot::stop(self.root)?; self.ensure_stopped()?; self.recover_validation()?; + let baseline = if check_schema { + self.saved_character_baseline()? + } else { + None + }; let result = (|| -> Result<()> { crate::update_isolation::begin(self.root, self.meta_dir)?; use crate::process::{observe, ServiceState}; @@ -1806,9 +1893,10 @@ impl RepackEnv<'_> { if check_schema { let modes = crate::realms::state(self.root)?.modes(); for mode in modes { - if let Some(p) = crate::schema_check::check_with_report( + if let Some(p) = crate::schema_check::check_update_with_report( &db.clone().for_realm(mode), self.root, + baseline.as_ref(), )? .first() { @@ -1851,8 +1939,7 @@ impl RepackEnv<'_> { if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running - && o.extra_worlds() - .all(|s| s.state == ServiceState::Running) + && o.extra_worlds().all(|s| s.state == ServiceState::Running) { let since = ready_since.get_or_insert_with(std::time::Instant::now); if since.elapsed() >= std::time::Duration::from_secs(10) { @@ -1869,7 +1956,26 @@ impl RepackEnv<'_> { }; if healthy { crate::backup::with_database(self.root, |db| { - verify_database_identities(db, &identities) + verify_database_identities(db, &identities)?; + if check_schema { + for mode in crate::realms::state(self.root)?.modes() { + if let Some(problem) = crate::schema_check::check_update_with_report( + &db.clone().for_realm(mode), + self.root, + baseline.as_ref(), + )? + .first() + { + return Err(Error::Invalid(format!( + "Database validation failed after startup on {}: {}: {}", + mode.name(), + problem.table, + problem.detail + ))); + } + } + } + Ok(()) })?; return Ok(()); } @@ -1892,6 +1998,7 @@ fn migrate_databases( manifest: &Manifest, staged: &Path, snapshot: &dyn Fn() -> Result, + baseline: Option<&crate::character_start_check::Baseline>, ) -> Result { let realms = crate::realms::state(root)?; let modes = realms.modes(); @@ -1943,7 +2050,8 @@ fn migrate_databases( return Ok(result); } crate::schema_check::repair_missing_defaults(&db, root, contract)?; - if let Some(p) = crate::schema_check::check_with_report(&db, root)?.first() { + if let Some(p) = crate::schema_check::check_update_with_report(&db, root, baseline)?.first() + { return Err(Error::Invalid(format!( "Database validation failed on {}: {}.{}: {}", mode.name(), @@ -2010,6 +2118,8 @@ mod tests { use std::cell::{Cell, RefCell}; struct Fake { + character_baseline: RefCell>, + baseline_failed: Cell, activation_expected: RefCell>, activation_failed: Cell, activated: Cell, @@ -2027,6 +2137,8 @@ mod tests { impl Fake { fn ok() -> Fake { Fake { + character_baseline: RefCell::new(None), + baseline_failed: Cell::new(false), activation_expected: RefCell::new(None), activation_failed: Cell::new(false), activated: Cell::new(false), @@ -2044,6 +2156,14 @@ mod tests { } impl Env for Fake { + fn character_start_baseline( + &self, + ) -> Result> { + if self.baseline_failed.get() { + return Err(Error::Invalid("fixture baseline unavailable".into())); + } + Ok(self.character_baseline.borrow().clone()) + } fn activate(&self) -> Result<()> { if let Some((meta, version)) = self.activation_expected.borrow().as_ref() { ensure_recovered(meta)?; @@ -3485,4 +3605,115 @@ mod tests { .iter() .any(|i| i.path == "Core/newfile.dll" && i.action == Action::Create)); } + fn baseline_fixture() -> crate::character_start_check::Baseline { + crate::character_start_check::Baseline { + schema: 1, + realms: BTreeMap::from([( + "coa".into(), + crate::character_start_check::parse_missing("1\t44\t14\n2\t44\t14\n3\t44\t14") + .unwrap(), + )]), + } + } + + #[test] + fn character_baseline_is_captured_once_persisted_and_verified_for_retry() { + let w = world(&[], true); + let env = Fake::ok(); + *env.character_baseline.borrow_mut() = Some(baseline_fixture()); + env.healthy.set(false); + let out = run(&w, &env, BTreeMap::new(), None).unwrap(); + assert_eq!(out.txn.state, State::NeedsDecision); + assert_eq!(out.preexisting_character_count, 3); + let baseline_path = txn_dir(&w.meta, &out.txn.id) + .unwrap() + .join(CHARACTER_BASELINE); + let original = fs::read(&baseline_path).unwrap(); + assert_eq!( + read_character_baseline(&w.meta, &out.txn) + .unwrap() + .unwrap() + .realms["coa"] + .len(), + 3 + ); + // A retry may not learn new gaps from an already modified database. + *env.character_baseline.borrow_mut() = None; + env.healthy.set(true); + fs::write(&baseline_path, b"{}").unwrap(); + assert!(retry_validation( + &w.root, + &w.meta, + &out.txn.id, + &Source::Dir(w.pkg.clone()), + &w.key, + &env + ) + .is_err()); + fs::write(&baseline_path, original).unwrap(); + let done = retry_validation( + &w.root, + &w.meta, + &out.txn.id, + &Source::Dir(w.pkg.clone()), + &w.key, + &env, + ) + .unwrap(); + assert_eq!(done.state, State::Committed); + } + + #[test] + fn corrupt_character_baseline_does_not_prevent_safe_rollback() { + let w = world(&[], true); + let env = Fake::ok(); + *env.character_baseline.borrow_mut() = Some(baseline_fixture()); + env.healthy.set(false); + let out = run(&w, &env, BTreeMap::new(), None).unwrap(); + fs::write( + txn_dir(&w.meta, &out.txn.id) + .unwrap() + .join(CHARACTER_BASELINE), + b"bad", + ) + .unwrap(); + let restored = rollback(&w.root, &w.meta, &out.txn.id, &env).unwrap(); + assert_eq!(restored.state, State::RolledBack); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + } + + #[test] + fn missing_or_unverified_character_baselines_fail_closed() { + let w = world(&[], true); + let env = Fake::ok(); + env.healthy.set(false); + let out = run(&w, &env, BTreeMap::new(), None).unwrap(); + assert!(read_character_baseline(&w.meta, &out.txn) + .unwrap() + .is_none()); + let path = txn_dir(&w.meta, &out.txn.id) + .unwrap() + .join(CHARACTER_BASELINE); + fsx::atomic_write_json(&path, &baseline_fixture()).unwrap(); + assert!(read_character_baseline(&w.meta, &out.txn).is_err()); + let mut txn = out.txn; + txn.recovery_hashes.insert( + CHARACTER_BASELINE_HASH.into(), + fsx::sha256_file(&path).unwrap(), + ); + fs::remove_file(path).unwrap(); + assert!(read_character_baseline(&w.meta, &txn).is_err()); + } + + #[test] + fn baseline_capture_failure_precedes_file_and_database_changes() { + let w = world(&[], true); + let env = Fake::ok(); + env.baseline_failed.set(true); + assert!(run(&w, &env, BTreeMap::new(), None).is_err()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(!env.calls.borrow().contains(&"migrate")); + assert!(!env.calls.borrow().contains(&"validate")); + assert!(unfinished(&w.meta).is_none()); + } } diff --git a/package-lock.json b/package-lock.json index a8e5a6b..a2411a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 9cf329c..9cfc15b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.0", + "version": "0.9.1", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bf85a93..1f0cb7c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.9.0", + "version": "0.9.1", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/data/latest-note-translations.json b/src/data/latest-note-translations.json new file mode 100644 index 0000000..ba66fed --- /dev/null +++ b/src/data/latest-note-translations.json @@ -0,0 +1,20 @@ +{ + "manager": { + "ru": { + "Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера" + }, + "de": { + "Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr" + }, + "fr": { + "Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport" + }, + "es": { + "Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas" + }, + "zh": { + "Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新" + } + }, + "server": {} +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 29b0c9c..f0683a5 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -2,6 +2,8 @@ import type en from "./en"; /** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */ const de: Partial> = { + "upd.preexistingCharacters": "Bereits vor dem Update fehlten bei {n} Charakterdatensätzen die Startdaten für Volk/Klasse. Unveränderte Datensätze wurden bewahrt und blockierten das Update nicht. Details stehen in der Diagnose; diese älteren Unstimmigkeiten wurden nicht automatisch behoben.", + "races.unknownIds": "Nicht mehr im Katalog vorhandene Völker-IDs: {ids}. Beim Speichern werden diese alten Einschränkungen entfernt.", "races.reviewCatalog": "Der Völkerkatalog wurde geändert oder noch nicht geprüft. Prüfe die Liste und speichere sie erneut.", "races.available": "Verfügbare Völker", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index efb3d3e..564f99c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,5 +1,7 @@ /** English is the source of truth: every key must exist here; other locales fall back to it. */ const en = { + "upd.preexistingCharacters": "Before the update, {n} character records already lacked race/class starting data. Unchanged records were preserved and did not block the update. Details are saved in diagnostics; these older inconsistencies have not been automatically repaired.", + "races.unknownIds": "RaceIDs no longer in this catalog: {ids}. Saving the reviewed list removes these old restrictions.", "races.reviewCatalog": "The race catalog changed or has not been verified. Review the list and save it before applying restrictions.", "races.available": "Available races", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index e6a6360..15e86c3 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -2,6 +2,8 @@ import type en from "./en"; /** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */ const es: Partial> = { + "upd.preexistingCharacters": "Antes de actualizar, a {n} registros de personaje ya les faltaban datos iniciales de raza/clase. Los registros sin cambios se conservaron y no bloquearon la actualización. Los detalles están en el diagnóstico; estas inconsistencias anteriores no se repararon automáticamente.", + "races.unknownIds": "RaceID que ya no están en el catálogo: {ids}. Al guardar la lista revisada se eliminan estas restricciones antiguas.", "races.reviewCatalog": "El catálogo de razas ha cambiado o aún no está verificado. Revisa la lista y guárdala.", "races.available": "Razas disponibles", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 1a85569..9ab717e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -2,6 +2,8 @@ import type en from "./en"; /** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */ const fr: Partial> = { + "upd.preexistingCharacters": "Avant la mise à jour, {n} fiches de personnage n’avaient déjà pas de données de départ pour leur race/classe. Les fiches inchangées ont été conservées sans bloquer la mise à jour. Les détails sont dans le diagnostic ; ces incohérences anciennes n’ont pas été corrigées automatiquement.", + "races.unknownIds": "RaceID absents de ce catalogue : {ids}. Sauvegarder la liste vérifiée supprime ces anciennes restrictions.", "races.reviewCatalog": "Le catalogue des races a changé ou n’a pas été vérifié. Vérifiez la liste et sauvegardez-la.", "races.available": "Races disponibles", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 036b246..b21bf90 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -2,6 +2,8 @@ import type en from "./en"; /** Русский. Черновой перевод; требует проверки носителем перед релизом. */ const ru: Partial> = { + "upd.preexistingCharacters": "До обновления у {n} записей персонажей уже отсутствовали стартовые данные расы/класса. Неизменившиеся записи сохранены и не заблокировали обновление. Подробности есть в диагностике; эти старые несоответствия автоматически не исправлялись.", + "races.unknownIds": "RaceID, которых больше нет в каталоге: {ids}. Сохранение проверенного списка удалит эти старые ограничения.", "races.reviewCatalog": "Каталог рас изменился или ещё не проверен. Проверьте список и сохраните его перед применением ограничений.", "races.available": "Доступные расы", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 0680c7e..b6bf94e 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1,5 +1,7 @@ /** Simplified Chinese localization (zh-CN) */ const zh = { + "upd.preexistingCharacters": "更新前已有 {n} 条角色记录缺少种族/职业初始数据。未变化的记录已保留,不会阻止更新。详情已保存到诊断报告;这些既有问题尚未自动修复。", + "races.unknownIds": "此目录中已不存在的种族 ID:{ids}。保存检查后的列表将移除这些旧限制。", "races.reviewCatalog": "种族目录已更改或尚未验证。请检查列表并保存后再应用限制。", "races.available": "可用种族", diff --git a/src/lib/api.ts b/src/lib/api.ts index 3642853..e0f4d41 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -542,7 +542,7 @@ export const api = { checkUpdate: (id: string, source?: string, background?: boolean) => invoke("check_update", { id, source: source ?? null, background: background ?? false }), pendingUpdate: (id: string) => invoke("pending_update", { id }), applyUpdate: (id: string, resolutions: Record, source?: string) => - invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }), + invoke<{ txn: UpdateTxn; preexisting_character_count?: number }>("apply_update", { id, source: source ?? null, resolutions }), rollbackUpdate: (id: string, txn: string) => invoke("rollback_update", { id, txn }), retryUpdateValidation: (id: string, txn: string) => invoke("retry_update_validation", { id, txn }), population: (id: string) => invoke("get_population", { id }), diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index a89e878..4f46dac 100644 --- a/src/lib/serverUpdate.ts +++ b/src/lib/serverUpdate.ts @@ -21,7 +21,7 @@ export interface ServerUpdateState { stateError: UiError | null; stateRevision: number; /** How the last update ended; kept here so the Settings card can show it after the user left and came back. */ - result: { committed: string } | { pending: UpdateTxn } | null; + result: { committed: string; preexistingCharacters?: number } | { pending: UpdateTxn } | null; } const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, stateError: null, stateRevision: 0, result: null }; @@ -98,7 +98,7 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record undefined); return true; } catch (e) { diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 3703d37..3f0ba27 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -554,6 +554,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o

)} + {upd.result && "committed" in upd.result && !!upd.result.preexistingCharacters &&

{t("upd.preexistingCharacters", { n: upd.result.preexistingCharacters })}

} {upd.available && upd.preview && (

{t("overview.updateAvail", { to: upd.preview.to_version, from: upd.preview.from_version ?? "?" })}

diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index a15c025..06c3325 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -210,6 +210,7 @@ export function SettingsHome({ serverId, serverName = "", path, onForget }: { se )} {done && pendingChecked && !pendingStateError &&

{done}

} + {upd.result && "committed" in upd.result && !!upd.result.preexistingCharacters &&

{t("upd.preexistingCharacters", { n: upd.result.preexistingCharacters })}

} {error && (

diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs index 2132b30..beac035 100644 --- a/tools/server-update-refresh.test.mjs +++ b/tools/server-update-refresh.test.mjs @@ -52,6 +52,19 @@ test("refresh after recovery returns the latest disk state, then its absence", a assert.equal(await refreshServerUpdateState("recovery-case"), null); }); +test("successful update keeps the existing-character warning across disk refresh", async () => { + const original = globalThis.__hotfixApi.applyUpdate; + try { + applyFailure = false; + stateFailure = false; + pending = null; + globalThis.__hotfixApi.applyUpdate = async () => ({ txn: { state: "committed", to_version: "2" }, preexisting_character_count: 174 }); + assert.equal(await applyServerUpdate("old-character-warnings", { choices: {} }), true); + await refreshServerUpdateState("old-character-warnings"); + assert.deepEqual(useServerUpdate("old-character-warnings").result, { committed: "2", preexistingCharacters: 174 }); + } finally { globalThis.__hotfixApi.applyUpdate = original; } +}); + test("a stopped server is left stopped after a successful update by default", async () => { calls.length = 0; serverRunning = false;