Merge pull request #179 from Corfirean/fix/update-preexisting-character-starts

Manager 0.9.1 hotfix: preserve existing character gaps during server updates
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-28 18:19:47 +05:00
commit 50e858dcd5
23 files changed
+578 -35

No files matched your search

Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
@@ -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.
@@ -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<String, BTreeSet<MissingStart>>,
}
pub fn parse_missing(text: &str) -> Result<BTreeSet<MissingStart>> {
text.lines()
.map(|line| {
let values = line
.split('\t')
.map(str::parse::<u32>)
.collect::<std::result::Result<Vec<_>, _>>()
.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<BTreeSet<MissingStart>> {
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<Baseline> {
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<MissingStart>,
before: &BTreeSet<MissingStart>,
) -> BTreeSet<MissingStart> {
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
}])
);
}
}
+1
View File
@@ -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;
+83
View File
@@ -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::<Vec<_>>()
.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();
+57 -7
View File
@@ -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<Vec<Problem>> {
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<Vec<Problem>> {
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<Vec<Problem>> {
check_inner(db, root, None, &mut BTreeMap::new())
}
fn check_inner(
db: &Db,
root: &Path,
before: Option<&BTreeSet<crate::character_start_check::MissingStart>>,
existing: &mut BTreeMap<(u32, u32), usize>,
) -> Result<Vec<Problem>> {
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<Vec<Problem>> {
}
// 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(&current, 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)
}
+248 -17
View File
@@ -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<Option<crate::character_start_check::Baseline>> {
Ok(None)
}
fn snapshot(&self) -> Result<String>;
fn restore_snapshot(&self, id: &str) -> Result<()>;
fn migrate(&self, manifest: &Manifest, staged_migrations: &Path) -> Result<ApplyReport>;
@@ -223,11 +228,12 @@ fn read_journal(path: &Path) -> Result<Txn> {
"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<ApplyReport>,
}
@@ -816,6 +823,26 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
.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<Outcome> {
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<Outcome> {
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<Option<crate::character_start_check::Baseline>> {
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<Option<crate::character_start_check::Baseline>> {
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<usize> {
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<usize> {
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<Option<crate::character_start_check::Baseline>> {
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<ApplyReport> {
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<String>,
baseline: Option<&crate::character_start_check::Baseline>,
) -> Result<ApplyReport> {
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<Option<crate::character_start_check::Baseline>>,
baseline_failed: Cell<bool>,
activation_expected: RefCell<Option<(PathBuf, String)>>,
activation_failed: Cell<bool>,
activated: Cell<bool>,
@@ -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<Option<crate::character_start_check::Baseline>> {
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());
}
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+20
View File
@@ -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": {}
}
+2
View File
@@ -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<Record<keyof typeof en, string>> = {
"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",
+2
View File
@@ -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",
+2
View File
@@ -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<Record<keyof typeof en, string>> = {
"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",
+2
View File
@@ -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<Record<keyof typeof en, string>> = {
"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",
+2
View File
@@ -2,6 +2,8 @@ import type en from "./en";
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
const ru: Partial<Record<keyof typeof en, string>> = {
"upd.preexistingCharacters": "До обновления у {n} записей персонажей уже отсутствовали стартовые данные расы/класса. Неизменившиеся записи сохранены и не заблокировали обновление. Подробности есть в диагностике; эти старые несоответствия автоматически не исправлялись.",
"races.unknownIds": "RaceID, которых больше нет в каталоге: {ids}. Сохранение проверенного списка удалит эти старые ограничения.",
"races.reviewCatalog": "Каталог рас изменился или ещё не проверен. Проверьте список и сохраните его перед применением ограничений.",
"races.available": "Доступные расы",
+2
View File
@@ -1,5 +1,7 @@
/** Simplified Chinese localization (zh-CN) */
const zh = {
"upd.preexistingCharacters": "更新前已有 {n} 条角色记录缺少种族/职业初始数据。未变化的记录已保留,不会阻止更新。详情已保存到诊断报告;这些既有问题尚未自动修复。",
"races.unknownIds": "此目录中已不存在的种族 ID:{ids}。保存检查后的列表将移除这些旧限制。",
"races.reviewCatalog": "种族目录已更改或尚未验证。请检查列表并保存后再应用限制。",
"races.available": "可用种族",
+1 -1
View File
@@ -542,7 +542,7 @@ export const api = {
checkUpdate: (id: string, source?: string, background?: boolean) => invoke<UpdatePreview>("check_update", { id, source: source ?? null, background: background ?? false }),
pendingUpdate: (id: string) => invoke<UpdateTxn | null>("pending_update", { id }),
applyUpdate: (id: string, resolutions: Record<string, "keep" | "replace">, 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<UpdateTxn>("rollback_update", { id, txn }),
retryUpdateValidation: (id: string, txn: string) => invoke<UpdateTxn>("retry_update_validation", { id, txn }),
population: (id: string) => invoke<Population | null>("get_population", { id }),
+2 -2
View File
@@ -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<str
}
try {
const out = await api.applyUpdate(id, opts?.choices ?? {}, opts?.source);
set(id, { result: out.txn.state === "committed" ? { committed: out.txn.to_version ?? "" } : { pending: out.txn } });
set(id, { result: out.txn.state === "committed" ? { committed: out.txn.to_version ?? "", ...(out.preexisting_character_count ? { preexistingCharacters: out.preexisting_character_count } : {}) } : { pending: out.txn } });
if (out.txn.state === "committed" && !wasRunning && !startAfterUpdate("server", id)) await api.stop(id).catch(() => undefined);
return true;
} catch (e) {
+1
View File
@@ -554,6 +554,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
<Button size="sm" variant="ghost" onClick={() => void startClientUpdate(server.id)}>{t("client.dl.retry")}</Button>
</p>
)}
{upd.result && "committed" in upd.result && !!upd.result.preexistingCharacters && <p className="mt-3 text-sm text-warn" role="status">{t("upd.preexistingCharacters", { n: upd.result.preexistingCharacters })}</p>}
{upd.available && upd.preview && (
<div className="mt-4 rounded-md border border-gold/40 bg-gold/5 p-3 text-sm">
<p>{t("overview.updateAvail", { to: upd.preview.to_version, from: upd.preview.from_version ?? "?" })}</p>
+1
View File
@@ -210,6 +210,7 @@ export function SettingsHome({ serverId, serverName = "", path, onForget }: { se
)}
{done && pendingChecked && !pendingStateError && <p className="mt-4 text-sm text-ok" role="status">{done}</p>}
{upd.result && "committed" in upd.result && !!upd.result.preexistingCharacters && <p className="mt-3 text-sm text-warn" role="status">{t("upd.preexistingCharacters", { n: upd.result.preexistingCharacters })}</p>}
{error && (
<div className="mt-4" role="alert">
<p className="selectable whitespace-pre-wrap break-words text-sm text-bad">
+13
View File
@@ -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;