mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Keep SQUID settings usable with unknown metadata and preserve translations
This commit is contained in:
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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] };
|
||||
}
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user