From 654b7cddd31a7fb15bc954b22b2a2f92d40ebaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 5 Oct 2026 18:09:36 +0500 Subject: [PATCH] Keep SQUID settings usable with unknown metadata and preserve translations --- .../20261005-squid-release-metadata.md | 2 +- changelog.d/20261005-squid-version-reports.md | 2 +- crates/coa-core/src/squid.rs | 60 +++++++++++++++---- src/lib/moduleSettings.ts | 3 +- src/lib/squidSettings.ts | 2 +- tools/check-module-settings.mjs | 7 +++ 6 files changed, 60 insertions(+), 16 deletions(-) diff --git a/changelog.d/20261005-squid-release-metadata.md b/changelog.d/20261005-squid-release-metadata.md index 1bcfb69..4de32ab 100644 --- a/changelog.d/20261005-squid-release-metadata.md +++ b/changelog.d/20261005-squid-release-metadata.md @@ -4,4 +4,4 @@ type: added audience: admins title: SquidBots settings follow the options shipped by the module --- -New options, descriptions, groups and value limits appear automatically from the module settings file. Reset values follow its documented defaults; omitted keys retain their code defaults. +New settings use the module's descriptions, groups, defaults and value limits while retaining existing translations. Unknown formats or invalid fields use built-in controls so the settings page remains available. diff --git a/changelog.d/20261005-squid-version-reports.md b/changelog.d/20261005-squid-version-reports.md index 0c37f3c..20b3cec 100644 --- a/changelog.d/20261005-squid-version-reports.md +++ b/changelog.d/20261005-squid-version-reports.md @@ -4,4 +4,4 @@ type: added audience: admins title: SquidBots shows its bundled release and includes it in problem reports --- -The Bots and Modules pages show the shipped release tag and commit when available. Problem reports include the same identifiers to help support diagnose the correct version. +The Bots and Modules pages and problem reports include the release identifiers. Imported repacks use their recorded bot revision; empty tags are hidden. The settings file's source commit is not presented as the build revision. diff --git a/crates/coa-core/src/squid.rs b/crates/coa-core/src/squid.rs index be27265..5ae6897 100644 --- a/crates/coa-core/src/squid.rs +++ b/crates/coa-core/src/squid.rs @@ -17,9 +17,12 @@ pub fn release(root: &Path) -> Option { if bytes.len() > 1024 * 1024 { continue; } let Ok(json) = serde_json::from_slice::(&bytes) else { continue; }; let safe = |key: &str| json.get(key).and_then(|value| value.as_str()) - .filter(|value| value.len() <= 80 && value.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))).map(str::to_owned); + .filter(|value| !value.is_empty() && value.len() <= 80 && value.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))).map(str::to_owned); let tag = safe("tag").or_else(|| safe("version")); - let commit = safe("commit").filter(|value| value.len() >= 7 && value.len() <= 40 && value.chars().all(|c| c.is_ascii_hexdigit())); + let revision = if name == "CoA-Bots/release.json" { + json.get("botsModuleRevision").and_then(|value| value.as_str()).and_then(|value| value.split_whitespace().next()).map(str::to_owned).or_else(|| safe("commit")) + } else if name.ends_with(".settings.json") { None } else { safe("commit") }; + let commit = revision.filter(|value| value.len() >= 7 && value.len() <= 40 && value.chars().all(|c| c.is_ascii_hexdigit())); if tag.is_some() || commit.is_some() { return Some(Release { tag, commit }); } } None @@ -46,7 +49,7 @@ pub struct Field { #[derive(Deserialize)] struct Settings { format: u32, - settings: Vec, + settings: Vec, #[serde(default)] groups: std::collections::BTreeMap, } @@ -54,26 +57,36 @@ struct Settings { pub fn fields(root: &Path) -> crate::Result> { let path = root.join("Core/configs/modules/playerbots.conf.settings.json"); if !path.is_file() { return Ok(Default::default()); } - let bytes = std::fs::read(path)?; - let invalid = || crate::Error::Invalid("The SquidBots settings JSON is invalid or uses an unsupported format.".into()); - if bytes.len() > 1024 * 1024 { return Err(invalid()); } - let settings: Settings = serde_json::from_slice(&bytes).map_err(|_| invalid())?; - if settings.format != 1 { return Err(invalid()); } + let Ok(bytes) = std::fs::read(path) else { return Ok(Default::default()); }; + if bytes.len() > 1024 * 1024 { return Ok(Default::default()); } + let Ok(settings) = serde_json::from_slice::(&bytes) else { return Ok(Default::default()); }; + if settings.format != 1 { return Ok(Default::default()); } let mut fields = std::collections::BTreeMap::new(); - for mut field in settings.settings { + for value in settings.settings { + let Ok(mut field) = serde_json::from_value::(value) else { continue; }; field.group_title = settings.groups.get(&field.group).cloned(); if !matches!(field.kind.as_str(), "bool" | "int" | "float" | "string") || field.key.len() > 200 || field.key.is_empty() || !field.key.chars().all(|c| c.is_ascii_alphanumeric() || "._".contains(c)) || field.min.zip(field.max).is_some_and(|(min,max)| min > max) - || scalar(&field.default).is_none() - || field.default_if_missing.as_ref().is_some_and(|value| scalar(value).is_none()) - || fields.contains_key(&field.key) { return Err(invalid()); } + || !default_type(&field.kind, &field.default) + || field.default_if_missing.as_ref().is_some_and(|value| !default_type(&field.kind, value)) + || fields.contains_key(&field.key) { continue; } fields.insert(field.key.clone(), field); } Ok(fields) } +fn default_type(kind: &str, value: &serde_json::Value) -> bool { + match kind { + "bool" => value.is_boolean(), + "int" => value.is_i64() || value.is_u64(), + "float" => value.is_number(), + "string" => value.is_string(), + _ => false, + } +} + pub fn scalar(value: &serde_json::Value) -> Option { match value { serde_json::Value::Bool(value) => Some(if *value { "1" } else { "0" }.into()), @@ -109,6 +122,29 @@ mod tests { assert!(validate(field, "5000").is_ok()); for value in ["5001", "-1", "0.5", "NaN"] { assert!(validate(field, value).is_err()); } } + #[test] + fn unsupported_metadata_falls_back_and_bad_entries_do_not_hide_valid_fields() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("Core/configs/modules/playerbots.conf.settings.json"); + for data in [r#"{"format":99,"settings":[]}"#, "invalid json", r#"{"format":1,"settings":{}}"#] { + crate::fsx::atomic_write(&path, data.as_bytes()).unwrap(); + assert!(fields(root.path()).unwrap().is_empty()); + } + crate::fsx::atomic_write(&path, br#"{"format":1,"settings":[{"key":"Broken","type":"enum","title":"Broken","description":"Unknown type","default":1},{"key":"Malformed"},{"key":"WrongDefault","type":"int","title":"Wrong","description":"Bad default","default":"oops"},{"key":"Good","type":"bool","title":"Good","description":"Good field","default":true}]}"#).unwrap(); + let parsed = fields(root.path()).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(parsed.contains_key("Good")); + } + + #[test] + fn imported_revision_uses_its_first_word_and_empty_tags_are_hidden() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("CoA-Bots/release.json"), br#"{"tag":"","botsModuleRevision":"48c4786a 2026-10-01 (coa branch)","commit":"abcdef01"}"#).unwrap(); + assert_eq!(release(root.path()).unwrap(), Release { tag: None, commit: Some("48c4786a".into()) }); + crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"tag":"v1.8.1","commit":"11111111"}"#).unwrap(); + assert_eq!(release(root.path()).unwrap(), Release { tag: Some("v1.8.1".into()), commit: None }); + } + #[test] fn diagnostics_only_expose_release_identifiers() { let root = tempfile::tempdir().unwrap(); diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index ca1cca5..951f384 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -174,7 +174,8 @@ for (const [category, categoryNames] of Object.entries(auctionCategories)) { ); } export function moduleField(key: string, locale: Locale, metadata?: ModuleSetting["field"]): ModuleField | undefined { - if (metadata) return { title: metadata.title, description: metadata.description, type: metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined }; + const translated = locale !== "en" ? fields.find(f => f.key === key) : undefined; + if (metadata) return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined }; const field = fields.find(f => f.key === key); if (field) return { ...field, title: text(field.title, locale), description: text(field.description, locale), category: "general", advanced: field.advanced ?? false, step: field.type === "range" ? 0.05 : undefined, options: field.options?.map(o => ({ value: o.value, label: text(o.label, locale) })) }; const existing = [...bots.settings, ...server.settings].find(s => s.key === key); diff --git a/src/lib/squidSettings.ts b/src/lib/squidSettings.ts index ebe18d9..1f0d1b7 100644 --- a/src/lib/squidSettings.ts +++ b/src/lib/squidSettings.ts @@ -42,6 +42,6 @@ export function squidSettingsView(items: ModuleSetting[], locale: Locale): Setti }; }); const categories = new Map(groups.map(g => [g.id, g.titles[languages.indexOf(locale)]])); - for (const item of items) if (item.field?.group) categories.set("squid." + item.field.group, item.field.group_title ?? categories.get("squid." + item.field.group) ?? item.field.group); + for (const item of items) if (item.field?.group) categories.set("squid." + item.field.group, categories.get("squid." + item.field.group) ?? item.field.group_title ?? item.field.group); return { scope: "bots", categories: [...categories].map(([id, title]) => ({ id, title })), settings, unknown_keys: 0, drift_keys: [], files: ["Core/configs/modules/playerbots.conf"] }; } diff --git a/tools/check-module-settings.mjs b/tools/check-module-settings.mjs index 2fff933..0194eb4 100644 --- a/tools/check-module-settings.mjs +++ b/tools/check-module-settings.mjs @@ -72,3 +72,10 @@ const upstream = squidSettingsView([{ key: "AiPlayerbot.FutureOption", value: "2 const future = upstream.settings[0]; if (future.title !== "Future option" || future.description !== "From the release JSON" || future.type !== "float" || future.min !== 0 || future.max !== 3 || future.advanced || future.category !== "squid.new-group" || upstream.categories.find(c=>c.id===future.category)?.title !== "New activity") throw new Error("New upstream JSON options must appear without handwritten definitions"); console.log("Upstream metadata: new fields, titles, ranges and arbitrary groups verified"); + +const localized = moduleField("AiPlayerbot.MaxRandomBots", "ru", { key:"AiPlayerbot.MaxRandomBots", type:"int", title:"Upstream English title", description:"Upstream English description", group:"population", min:10, max:1234 }); +const original = moduleField("AiPlayerbot.MaxRandomBots", "ru"); +if (localized.title !== original.title || localized.description !== original.description || localized.max !== 1234) throw new Error("Existing translations must survive upstream metadata while ranges stay current"); +const localizedGroup = squidSettingsView([{key:"AiPlayerbot.MaxRandomBots",value:"100",default:"500",doc:"",field:{key:"AiPlayerbot.MaxRandomBots",type:"int",title:"English",description:"English",group:"population",group_title:"English population"}}],"ru"); +if (localizedGroup.categories.find(c=>c.id==="squid.population")?.title !== "Население мира") throw new Error("Known groups must retain translated captions"); +console.log("Existing setting/group translations retained with upstream English fallback");