Keep SQUID settings usable with unknown metadata and preserve translations

This commit is contained in:
Дмитрий committed 2026-10-05 18:09:36 +05:00
1 parent f8301d7394
commit 654b7cddd3
6 files changed
+60 -16

No files matched your search

@@ -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.
@@ -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.
+48 -12
View File
@@ -17,9 +17,12 @@ pub fn release(root: &Path) -> Option<Release> {
if bytes.len() > 1024 * 1024 { continue; }
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&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<Field>,
settings: Vec<serde_json::Value>,
#[serde(default)]
groups: std::collections::BTreeMap<String, String>,
}
@@ -54,26 +57,36 @@ struct Settings {
pub fn fields(root: &Path) -> crate::Result<std::collections::BTreeMap<String, Field>> {
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::<Settings>(&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::<Field>(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<String> {
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();
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -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"] };
}
+7
View File
@@ -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");