mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
23 files changed
+578
-35
No files matched your search
Generated
+3
-3
@@ -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
@@ -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
|
||||
}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¤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)
|
||||
}
|
||||
|
||||
+248
-17
@@ -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());
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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,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,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": "Доступные расы",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Simplified Chinese localization (zh-CN) */
|
||||
const zh = {
|
||||
"upd.preexistingCharacters": "更新前已有 {n} 条角色记录缺少种族/职业初始数据。未变化的记录已保留,不会阻止更新。详情已保存到诊断报告;这些既有问题尚未自动修复。",
|
||||
|
||||
"races.unknownIds": "此目录中已不存在的种族 ID:{ids}。保存检查后的列表将移除这些旧限制。",
|
||||
"races.reviewCatalog": "种族目录已更改或尚未验证。请检查列表并保存后再应用限制。",
|
||||
"races.available": "可用种族",
|
||||
|
||||
+1
-1
@@ -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 }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user