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
)}
+ {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;