diff --git a/.gitignore b/.gitignore index 1dcd39e..075742d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ src-tauri/gen services/*/target/ # Disposable database fixtures and private recovery data .cache/ + +release-preview/ diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 8f964f1..a6eaeeb 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod preview; pub mod process; pub mod provenance; pub mod ra; +pub mod race_availability; pub mod realm_registry; pub mod realmlist; pub mod realms; diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 2dd0133..899e88c 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -302,10 +302,13 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { if let Some(ref reg) = plugin_reg { for (plugin_id, plugin_entry) in ®.plugins { if !views.iter().any(|v| v.id == *plugin_id) { - let (display_name, desc_map) = if let Ok(meta) = crate::plugins::load_store_metadata(root, plugin_id, &plugin_entry.sha256) { - (meta.name, meta.description) + let mut display_name = if let Ok(meta) = crate::plugins::load_store_metadata(root, plugin_id, &plugin_entry.sha256) { + meta.name } else { - let name = if plugin_id.len() == 40 && plugin_id.chars().all(|c| c.is_ascii_hexdigit()) { + String::new() + }; + if display_name.is_empty() || (display_name.len() == 40 && display_name.chars().all(|c| c.is_ascii_hexdigit())) { + display_name = if plugin_id.len() == 40 && plugin_id.chars().all(|c| c.is_ascii_hexdigit()) { if plugin_entry.conf_files.iter().any(|c| c.contains("npc_buffer")) { "NPC Buffer".to_string() } else if plugin_entry.conf_files.iter().any(|c| c.contains("ah_bot") || c.contains("ahbot")) { @@ -330,14 +333,18 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { } else if plugin_id == "reward-played-time" { "Reward Played Time".to_string() } else { - plugin_id.clone() + plugin_id.split('-').map(|s| { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }).collect::>().join(" ") }; - let mut desc = BTreeMap::new(); - desc.insert("ru".to_string(), format!("Динамический модуль {}", name)); - desc.insert("en".to_string(), format!("Dynamic module {}", name)); - (name, desc) - }; - + } + let mut desc_map = BTreeMap::new(); + desc_map.insert("ru".to_string(), format!("Динамический модуль {}", display_name)); + desc_map.insert("en".to_string(), format!("Dynamic module {}", display_name)); let has_settings = settings_in(cat, root, plugin_id).map(|s| !s.is_empty()).unwrap_or(false); views.push(ModuleView { @@ -716,6 +723,18 @@ fn save_settings_in( "Use the module switch to turn this module on or off.".into(), )); } + if id == "time-is-time" { + let valid = match key.as_str() { + "TimeIsTime.Announce" => matches!(value, "0" | "1"), + "TimeIsTime.SpeedRate" => value.parse::().is_ok_and(|v| v.is_finite() && (0.0..=60.0).contains(&v)), + "TimeIsTime.TimeStart" => value.parse::().is_ok_and(|v| v >= 0), + "TimeIsTime.HourOffset" => value.parse::().is_ok_and(|v| v.is_finite() && (-24.0..=24.0).contains(&v)), + _ => true, + }; + if !valid { + return Err(Error::Invalid(format!("Invalid value for {key}."))); + } + } if id == "content-scaling" && key == "CoAContentScaling.Difficulty.DamageMultiplier" { let multiplier = value.parse::().map_err(|_| { Error::Invalid("Enemy damage must be a number from 0.25 to 2.0.".into()) @@ -1003,8 +1022,8 @@ mod tests { // it may be empty (modules are added to it one by one); whatever is in it must be complete for e in &c { assert!( - e.description.contains_key("en") && e.description.len() == 5, - "{} has all five descriptions", + ["en", "ru", "de", "fr", "es"].iter().all(|locale| e.description.get(*locale).is_some_and(|text| !text.is_empty())), + "{} has the existing catalog languages", e.id ); assert!( diff --git a/crates/coa-core/src/preview.rs b/crates/coa-core/src/preview.rs index 868a9db..0e92ad6 100644 --- a/crates/coa-core/src/preview.rs +++ b/crates/coa-core/src/preview.rs @@ -220,6 +220,22 @@ pub fn install_qualified_module( } } + if display_name.len() == 40 && display_name.chars().all(|c| c.is_ascii_hexdigit()) { + if dist_confs.iter().any(|(n, _)| n.contains("npc_buffer")) { + display_name = "NPC Buffer".to_string(); + } else if dist_confs.iter().any(|(n, _)| n.contains("ah_bot") || n.contains("ahbot")) { + display_name = "Auction House Bot".to_string(); + } else if dist_confs.iter().any(|(n, _)| n.contains("enchanter")) { + display_name = "NPC Enchanter".to_string(); + } else if dist_confs.iter().any(|(n, _)| n.contains("congrats")) { + display_name = "Congrats On Level".to_string(); + } else if dist_confs.iter().any(|(n, _)| n.contains("reward_played_time")) { + display_name = "Reward Played Time".to_string(); + } else { + display_name = format!("Module ({})", &display_name[..8]); + } + } + let meta = PluginMetadata { id: module_id.to_string(), name: display_name.clone(), diff --git a/crates/coa-core/src/race_availability.rs b/crates/coa-core/src/race_availability.rs new file mode 100644 index 0000000..0080f71 --- /dev/null +++ b/crates/coa-core/src/race_availability.rs @@ -0,0 +1,515 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::config::parser::ConfFile; +use crate::error::{Error, Result}; + +pub const KEY: &str = "CoACustomRaces.DisabledRaceIds"; +pub const CATALOG_KEY: &str = "CoACustomRaces.DisabledRaceCatalogSha256"; +const CONF: &str = "Core/configs/modules/coa_custom_races.conf"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Race { + pub id: u32, + pub name: String, + pub faction: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Availability { + pub realm: crate::realms::Mode, + pub races: Vec, + pub disabled_race_ids: BTreeSet, + pub catalog_sha256: String, + pub server_config_supported: bool, + pub catalog_needs_review: bool, + pub unrecognized_race_ids: BTreeSet, +} + +fn invalid(message: &str) -> Error { + Error::Invalid(message.into()) +} + +fn stock(id: u32) -> bool { + (1..=11).contains(&id) && id != 9 +} + +pub fn catalog(bytes: &[u8]) -> Result> { + let word = |offset: usize| -> Result { + let data = bytes + .get(offset..offset + 4) + .ok_or_else(|| invalid("Truncated race catalog."))?; + Ok(u32::from_le_bytes(data.try_into().unwrap())) + }; + if bytes.get(..4) != Some(b"WDBC") || word(8)? != 69 || word(12)? != 276 { + return Err(invalid("Unsupported race catalog format.")); + } + let count = word(4)? as usize; + let string_size = word(16)? as usize; + if count == 0 || count > 255 { + return Err(invalid("Invalid race catalog record count.")); + } + let strings_start = 20 + count * 276; + if strings_start.checked_add(string_size) != Some(bytes.len()) || string_size == 0 { + return Err(invalid("Invalid race catalog bounds.")); + } + let strings = &bytes[strings_start..]; + if strings.last() != Some(&0) { + return Err(invalid("Invalid race catalog strings.")); + } + let mut ids = BTreeSet::new(); + let mut races = Vec::new(); + for row in 0..count { + let offset = 20 + row * 276; + let id = word(offset)?; + if id == 0 || id > 255 || !ids.insert(id) { + return Err(invalid("Invalid or duplicate RaceID in the catalog.")); + } + let mut name = String::new(); + for field in 14..30 { + let index = word(offset + field * 4)? as usize; + let remaining = strings + .get(index..) + .ok_or_else(|| invalid("Invalid race name offset."))?; + let end = remaining + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| invalid("Unterminated race name."))?; + let value = std::str::from_utf8(&remaining[..end]) + .map_err(|_| invalid("Invalid race name encoding."))?; + if name.is_empty() && !value.is_empty() { + name = value.into(); + } + } + if stock(id) || word(offset + 4)? & 1 != 0 { + continue; + } + if name.trim().is_empty() { + return Err(invalid("A playable custom race has no name.")); + } + let faction = match word(offset + 13 * 4)? { + 0 => Some("alliance".into()), + 1 => Some("horde".into()), + _ => None, + }; + races.push(Race { id, name, faction }); + } + races.sort_by_key(|race| race.id); + Ok(races) +} + +fn parse_ids(value: &str) -> Result> { + let value = value.trim(); + let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 { + &value[1..value.len() - 1] + } else { + value + }; + if value.trim().is_empty() { + return Ok(BTreeSet::new()); + } + let mut disabled = BTreeSet::new(); + for token in value.split(',') { + let token = token.trim(); + if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid( + "Disabled RaceIDs must be a comma-separated list of whole numbers.", + )); + } + let id = token + .parse::() + .map_err(|_| invalid("Invalid disabled RaceID."))?; + if id == 0 || id > 255 || stock(id) || !disabled.insert(id) { + return Err(invalid( + "Disabled RaceIDs contain a stock, unknown or duplicate race.", + )); + } + } + Ok(disabled) +} + +pub fn parse_disabled(value: &str, races: &[Race]) -> Result> { + let disabled = parse_ids(value)?; + let known: BTreeSet = races.iter().map(|race| race.id).collect(); + if !disabled.is_subset(&known) { + return Err(invalid("Disabled RaceIDs contain an unknown race.")); + } + Ok(disabled) +} + +fn dbc_path(root: &Path) -> Result { + let config_path = root.join("Core/configs/worldserver.conf"); + if config_path.is_file() { + let conf = ConfFile::parse_bytes(&fs::read(config_path)?)?; + let value = conf.get("DataDir").unwrap_or("."); + let data = PathBuf::from(value.trim().trim_matches('"')); + return Ok(if data.is_absolute() { + data + } else { + root.join("Core").join(data) + } + .join("dbc_races/ChrRaces.dbc")); + } + Ok(root.join("Data/dbc_races/ChrRaces.dbc")) +} + +pub fn status(root: &Path) -> Result { + let bytes = fs::read(dbc_path(root)?)?; + let races = catalog(&bytes)?; + let conf = ConfFile::parse_bytes(&fs::read(root.join(CONF))?)?; + let configured = parse_ids(conf.get(KEY).unwrap_or(""))?; + let known: BTreeSet = races.iter().map(|race| race.id).collect(); + let unrecognized_race_ids: BTreeSet = configured.difference(&known).copied().collect(); + let disabled_race_ids = configured.intersection(&known).copied().collect(); + let dist = fs::read(root.join(format!("{CONF}.dist"))) + .ok() + .map(|bytes| ConfFile::parse_bytes(&bytes)) + .transpose()?; + let catalog_sha256 = hex::encode(Sha256::digest(&bytes)); + let saved_hash = conf + .get(CATALOG_KEY) + .unwrap_or("") + .trim() + .trim_matches('"') + .to_ascii_lowercase(); + let catalog_needs_review = !configured.is_empty() + && (saved_hash != catalog_sha256 || !unrecognized_race_ids.is_empty()); + Ok(Availability { + realm: crate::realms::state(root)?.active, + races, + disabled_race_ids, + catalog_sha256, + catalog_needs_review, + unrecognized_race_ids, + server_config_supported: dist + .is_some_and(|file| file.get(KEY).is_some() && file.get(CATALOG_KEY).is_some()) + && fs::read(crate::layout::executables(root).0).is_ok_and(|binary| { + [KEY, CATALOG_KEY] + .iter() + .all(|key| binary.windows(key.len()).any(|part| part == key.as_bytes())) + }), + }) +} + +pub fn save( + root: &Path, + meta: &Path, + expected_catalog: &str, + expected_realm: crate::realms::Mode, + disabled: &[u32], +) -> Result { + crate::update::ensure_recovered(meta)?; + fs::create_dir_all(root.join(".state"))?; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join(".state/control.lock"))?; + if !fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { + return Err(invalid("Another server operation is in progress.")); + } + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if observed.world.state != crate::process::ServiceState::Stopped + || crate::multiworld::is_running(root) + { + return Err(invalid( + "Stop the server before changing race availability.", + )); + } + let current = status(root)?; + if current.realm != expected_realm { + return Err(invalid( + "The selected realm changed. Reload race availability before saving.", + )); + } + if !current.server_config_supported { + return Err(invalid( + "Update the server to a build supporting individual race restrictions first.", + )); + } + if current.catalog_sha256 != expected_catalog { + return Err(invalid( + "The race catalog changed. Reload the race list before saving.", + )); + } + let value = disabled + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let normalized = parse_disabled(&value, ¤t.races)?; + let value = normalized + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + crate::modules::save_settings( + root, + meta, + "custom-races", + &BTreeMap::from([ + (KEY.into(), format!("\"{value}\"")), + ( + CATALOG_KEY.into(), + format!( + "\"{}\"", + if normalized.is_empty() { + "" + } else { + ¤t.catalog_sha256 + } + ), + ), + ]), + )?; + status(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dbc() -> Vec { + let mut bytes = b"WDBC".to_vec(); + for value in [4u32, 69, 276, 11] { + bytes.extend(value.to_le_bytes()); + } + for id in [1u32, 82, 83, 97] { + let mut row = vec![0; 276]; + row[..4].copy_from_slice(&id.to_le_bytes()); + row[4..8].copy_from_slice(&12u32.to_le_bytes()); + row[56..60].copy_from_slice(&1u32.to_le_bytes()); + bytes.extend(row); + } + bytes.extend(b"\0Synthetic\0"); + bytes + } + + #[test] + fn catalog_preserves_ids_and_excludes_stock_and_nonplayable() { + let mut bytes = dbc(); + bytes[20 + 276 + 4] |= 1; + let races = catalog(&bytes).unwrap(); + assert_eq!( + races.iter().map(|race| race.id).collect::>(), + [83, 97] + ); + assert_eq!(races[0].name, "Synthetic"); + } + + #[test] + fn rejects_corrupt_layout_bounds_ids_and_names() { + for (offset, value) in [ + (4, 0), + (8, 68), + (12, 272), + (16, 100), + (20, 0), + (20 + 276, 1), + (20 + 56, 999), + ] { + let mut bytes = dbc(); + bytes[offset..offset + 4].copy_from_slice(&(value as u32).to_le_bytes()); + assert!(catalog(&bytes).is_err(), "offset {offset}"); + } + assert!(catalog(&dbc()[..19]).is_err()); + assert!(catalog(&dbc()[..100]).is_err()); + } + + #[test] + fn disabled_set_is_strict_and_atomic() { + let races = catalog(&dbc()).unwrap(); + assert_eq!( + parse_disabled("\"97,82\"", &races).unwrap(), + BTreeSet::from([82, 97]) + ); + assert!(parse_disabled("\"\"", &races).unwrap().is_empty()); + for value in [ + "82,1", "82,82", "82,250", "82,", "-82", "+82", "82,1-97", "82x", "0", + ] { + assert!(parse_disabled(value, &races).is_err(), "{value}"); + } + } + + #[test] + fn status_uses_current_config_and_requires_server_capability() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::create_dir_all(root.join("Data/dbc_races")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), dbc()).unwrap(); + fs::write(root.join(CONF), format!("{KEY} = \"82\"\n")).unwrap(); + let before = status(root).unwrap(); + assert!(!before.server_config_supported); + assert_eq!(before.disabled_race_ids, BTreeSet::from([82])); + fs::write( + root.join(format!("{CONF}.dist")), + format!("{KEY} = \"\"\n{CATALOG_KEY} = \"\"\n"), + ) + .unwrap(); + assert!(!status(root).unwrap().server_config_supported); + fs::write( + root.join("Core/worldserver.exe"), + format!("{KEY} {CATALOG_KEY}"), + ) + .unwrap(); + let after = status(root).unwrap(); + assert!(after.server_config_supported); + assert_eq!(before.catalog_sha256, after.catalog_sha256); + } + + fn server(root: &Path) { + fs::create_dir_all(root.join("Data/dbc_races")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::write( + root.join("Core/worldserver.exe"), + format!("{KEY} {CATALOG_KEY}"), + ) + .unwrap(); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::write( + root.join("Settings/repack.json"), + r#"{"worldPort":0,"authPort":0,"mysqlPort":0,"raPort":0}"#, + ) + .unwrap(); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), dbc()).unwrap(); + fs::write( + root.join(CONF), + format!("CoACustomRaces.Enable = 1\n{KEY} = \"82\"\nUser.Custom = 42\n"), + ) + .unwrap(); + fs::write( + root.join(format!("{CONF}.dist")), + format!("CoACustomRaces.Enable = 0\n{KEY} = \"\"\n{CATALOG_KEY} = \"\"\n"), + ) + .unwrap(); + fs::write( + root.join("Core/configs/modules/playerbots.conf"), + "AiPlayerbot.ExcludedBotRaces = \"1,83\"\nAiPlayerbot.CrashingBotRaces = \"97\"\n", + ) + .unwrap(); + } + + #[test] + fn saving_and_allow_all_preserve_user_settings_and_bot_exclusions() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let hash = status(&root).unwrap().catalog_sha256; + let bot_path = root.join("Core/configs/modules/playerbots.conf"); + let bot_before = fs::read(&bot_path).unwrap(); + let saved = save(&root, &meta, &hash, crate::realms::Mode::Coa, &[97, 83]).unwrap(); + assert_eq!(saved.disabled_race_ids, BTreeSet::from([83, 97])); + assert!(!saved.catalog_needs_review); + assert!(fs::read_to_string(root.join(CONF)).unwrap().contains(&hash)); + assert!(fs::read_to_string(root.join(CONF)) + .unwrap() + .contains("User.Custom = 42")); + assert_eq!(fs::read(&bot_path).unwrap(), bot_before); + assert!(save(&root, &meta, &hash, crate::realms::Mode::Coa, &[]) + .unwrap() + .disabled_race_ids + .is_empty()); + } + + #[test] + fn stale_catalog_and_invalid_lists_cannot_change_configuration() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let hash = status(&root).unwrap().catalog_sha256; + let before = fs::read(root.join(CONF)).unwrap(); + assert!(save(&root, &meta, "stale", crate::realms::Mode::Coa, &[83]).is_err()); + for ids in [&[82, 1][..], &[82, 82][..], &[250][..]] { + assert!(save(&root, &meta, &hash, crate::realms::Mode::Coa, ids).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + } + + #[test] + fn switching_realms_cannot_apply_a_draft_from_another_profile() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let loaded = status(&root).unwrap(); + let before = fs::read(root.join(CONF)).unwrap(); + let realm = crate::realms::RealmState { + active: crate::realms::Mode::Wildcard, + ..Default::default() + }; + fs::write( + root.join("Settings/realm-profile.json"), + serde_json::to_vec(&realm).unwrap(), + ) + .unwrap(); + assert!(save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[83]).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + + #[test] + fn concurrent_server_operations_cannot_change_policy() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + fs::create_dir_all(root.join(".state")).unwrap(); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join(".state/control.lock")) + .unwrap(); + assert!(fs4::fs_std::FileExt::try_lock_exclusive(&lock).unwrap()); + let loaded = status(&root).unwrap(); + let before = fs::read(root.join(CONF)).unwrap(); + assert!(save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[83]).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + + #[test] + fn relative_data_dir_uses_the_worldserver_working_directory() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + server(&root); + fs::write( + root.join("Core/configs/worldserver.conf"), + "DataDir = \"../Data\"\n", + ) + .unwrap(); + assert_eq!(status(&root).unwrap().races.len(), 3); + } + + #[test] + fn changed_catalog_requires_explicit_review_and_unknown_ids_are_not_valid_races() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let loaded = status(&root).unwrap(); + assert!(loaded.catalog_needs_review); + save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[82]).unwrap(); + assert!(!status(&root).unwrap().catalog_needs_review); + let mut changed = dbc(); + changed[20 + 276..24 + 276].copy_from_slice(&84u32.to_le_bytes()); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), changed).unwrap(); + let updated = status(&root).unwrap(); + assert!(updated.catalog_needs_review); + assert_eq!(updated.unrecognized_race_ids, BTreeSet::from([82])); + assert!(!updated.races.iter().any(|race| race.id == 82)); + assert!(updated.disabled_race_ids.is_empty()); + let reviewed = save(&root, &meta, &updated.catalog_sha256, updated.realm, &[]).unwrap(); + assert!(!reviewed.catalog_needs_review); + assert!(reviewed.unrecognized_race_ids.is_empty()); + } +} diff --git a/modules/catalog.json b/modules/catalog.json index 4a1bfd7..ec1d762 100644 --- a/modules/catalog.json +++ b/modules/catalog.json @@ -9,7 +9,8 @@ "ru": "Боты-компаньоны для групп, подземелий и гильдии.", "de": "Begleiter-Bots für Gruppen, Dungeons und deine Gilde.", "fr": "Des bots compagnons pour les groupes, les donjons et votre guilde.", - "es": "Bots acompañantes para grupos, mazmorras y tu hermandad." + "es": "Bots acompañantes para grupos, mazmorras y tu hermandad.", + "zh": "用于组队、地下城和公会的伙伴机器人。" }, "repo": "https://github.com/Corfirean/mod-coa-playerbots", "conf": "mod_coa_playerbots.conf", @@ -26,7 +27,8 @@ "ru": "Подстраивает подземелья и рейды под вашу группу, есть соло-режимы.", "de": "Passt Dungeons und Schlachtzüge an deine Gruppe an, mit Solo-Modi.", "fr": "Adapte donjons et raids à votre groupe, avec des modes solo.", - "es": "Adapta mazmorras y bandas a tu grupo, con modos en solitario." + "es": "Adapta mazmorras y bandas a tu grupo, con modos en solitario.", + "zh": "按队伍规模调整地下城和团队副本,支持单人模式。" }, "repo": "https://github.com/Corfirean/mod-coa-content-scaling", "conf": "mod-coa-content-scaling.conf", @@ -42,7 +44,8 @@ "ru": "Подземелья и рейды Запределья, подогнанные под ваши уровни.", "de": "Dungeons und Schlachtzüge der Scherbenwelt, an deine Stufen angepasst.", "fr": "Donjons et raids de l'Outreterre, adaptés à vos niveaux.", - "es": "Mazmorras y bandas de Terrallende, adaptadas a tus niveles." + "es": "Mazmorras y bandas de Terrallende, adaptadas a tus niveles.", + "zh": "根据等级范围调整外域地下城和团队副本。" }, "repo": "https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content", "conf": "mod-coa-tbc-content.conf", @@ -58,7 +61,8 @@ "ru": "Подземелья и рейды Нордскола, подогнанные под ваши уровни.", "de": "Dungeons und Schlachtzüge Nordends, an deine Stufen angepasst.", "fr": "Donjons et raids du Norfendre, adaptés à vos niveaux.", - "es": "Mazmorras y bandas de Rasganorte, adaptadas a tus niveles." + "es": "Mazmorras y bandas de Rasganorte, adaptadas a tus niveles.", + "zh": "根据等级范围调整诺森德地下城和团队副本。" }, "repo": "https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content", "conf": "mod-coa-wotlk-content.conf", @@ -74,7 +78,8 @@ "ru": "Наполняет аукцион реалистичными настраиваемыми лотами.", "de": "Füllt das Auktionshaus mit realistischen, einstellbaren Angeboten.", "fr": "Remplit l'hôtel des ventes d'annonces réalistes et réglables.", - "es": "Llena la casa de subastas con ofertas realistas y ajustables." + "es": "Llena la casa de subastas con ofertas realistas y ajustables.", + "zh": "为拍卖行生成合理的商品,可调整上架规则。" }, "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-ah-bot-plus", "conf": "mod_ahbot.conf", @@ -92,7 +97,8 @@ "ru": "НПС, который зачаровывает ваше снаряжение.", "de": "Ein NPC, der deine Ausrüstung für dich verzaubert.", "fr": "Un PNJ qui enchante votre équipement pour vous.", - "es": "Un PNJ que encanta tu equipo por ti." + "es": "Un PNJ que encanta tu equipo por ti.", + "zh": "提供装备附魔服务的 NPC。" }, "repo": "https://github.com/azerothcore/mod-npc-enchanter", "conf": "npc_enchanter.conf", @@ -110,7 +116,8 @@ "ru": "Альтернативная система ботов со случайными ботами и полным ИИ.", "de": "Ein alternatives Bot-System mit Zufalls-Bots und voller KI.", "fr": "Un système de bots alternatif avec bots aléatoires et IA complète.", - "es": "Un sistema de bots alternativo con bots aleatorios e IA completa." + "es": "Un sistema de bots alternativo con bots aleatorios e IA completa.", + "zh": "另一套机器人系统,支持随机机器人和完整 AI。" }, "repo": "https://github.com/Zyth45/mod-playerbots", "conf": "playerbots.conf", @@ -129,7 +136,8 @@ "ru": "Пользовательские расы (Esteria и дополнительные расы) с расширенной внешностью.", "de": "Spielbare benutzerdefinierte Völker (Esteria und weitere Völker) mit erweitertem Aussehen.", "fr": "Races personnalisées jouables (Esteria et autres) avec apparences étendues.", - "es": "Razas personalizadas jugables (Esteria y razas adicionales) con apariencias ampliadas." + "es": "Razas personalizadas jugables (Esteria y razas adicionales) con apariencias ampliadas.", + "zh": "添加可选种族及其种族技能,需要配套客户端补丁。" }, "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa", "conf": "coa_custom_races.conf", @@ -139,6 +147,24 @@ "enable_key": "CoACustomRaces.Enable", "default_on": false }, + { + "id": "time-is-time", + "name": "TimeIsTime", + "description": { + "en": "Adjust the day and night cycle speed and the starting game time.", + "ru": "Настройка скорости смены дня и ночи и начального игрового времени.", + "de": "Geschwindigkeit des Tag-Nacht-Zyklus und Startzeit im Spiel anpassen.", + "fr": "Réglez la vitesse du cycle jour-nuit et l’heure de départ en jeu.", + "es": "Ajusta la velocidad del ciclo de día y noche y la hora inicial del juego.", + "zh": "调整昼夜循环速度和游戏初始时间。" + }, + "repo": "https://github.com/dunjeon/mod-TimeIsTime", + "conf": "mod-time_is_time.conf", + "enable_key": "TimeIsTime.Enable", + "default_on": true, + "status": "early", + "icon": "clock" + }, { "id": "client-compat", "name": "Client compatibility", @@ -147,7 +173,8 @@ "ru": "Связь клиента с сервером и разблокировка внешних видов.", "de": "Verbindung Client–Server und Aussehen-Freischaltungen.", "fr": "Lien client-serveur et déblocage des apparences.", - "es": "Enlace cliente-servidor y desbloqueo de apariencias." + "es": "Enlace cliente-servidor y desbloqueo de apariencias.", + "zh": "客户端与服务器连接,以及外观解锁。" }, "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa", "conf": "coa.conf", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a13221..9f25c10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1269,6 +1269,32 @@ async fn custom_races_status( .await } +#[tauri::command] +async fn race_availability( + state: State<'_, AppState>, + id: String, +) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || coa_core::race_availability::status(&root)).await +} + +#[tauri::command] +async fn race_availability_save( + state: State<'_, AppState>, + id: String, + catalog_sha256: String, + realm: coa_core::realms::Mode, + disabled_race_ids: Vec, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + let (dir, _) = install_meta(&root)?; + coa_core::race_availability::save(&root, &dir, &catalog_sha256, realm, &disabled_race_ids) + }) + .await +} + #[tauri::command] async fn custom_races_install( app: AppHandle, @@ -3107,6 +3133,8 @@ pub fn run() { console_risk, console_command, custom_races_status, + race_availability, + race_availability_save, custom_races_install, custom_races_uninstall, custom_races_toggle_client, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8976b6a..eabfc73 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -257,6 +257,19 @@ const en = { "comp.saved": "Saved. {n} companions will be created when the server starts.", // Client + "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", + "races.count": "{allowed} allowed · {disabled} disabled", + "races.allowCreation": "Allow new characters", + "races.allowAll": "Allow all", + "races.alliance": "Alliance", + "races.horde": "Horde", + "races.updateServer": "Update the server to a build supporting individual race restrictions.", + "races.restart": "Stop the server before saving; start it again to apply the policy.", + "races.saved": "Server configuration saved. Start the server to apply it.", + "races.clientPending": "Client visibility: automatic synchronization is not configured yet.", + "races.existing": "Existing characters keep their race and data.", "client.races.install": "Install custom race patch", "client.title": "Game client", "client.looking": "Looking…", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index a94c6c4..db85097 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -241,6 +241,19 @@ const ru: Partial> = { "comp.saved": "Сохранено. Компаньоны ({n}) будут созданы при запуске сервера.", // Client + "races.unknownIds": "RaceID, которых больше нет в каталоге: {ids}. Сохранение проверенного списка удалит эти старые ограничения.", + "races.reviewCatalog": "Каталог рас изменился или ещё не проверен. Проверьте список и сохраните его перед применением ограничений.", + "races.available": "Доступные расы", + "races.count": "Разрешено: {allowed} · Отключено: {disabled}", + "races.allowCreation": "Разрешить создание", + "races.allowAll": "Разрешить все", + "races.alliance": "Альянс", + "races.horde": "Орда", + "races.updateServer": "Обновите сервер для поддержки ограничений по расам.", + "races.restart": "Остановите сервер перед сохранением и запустите снова для применения.", + "races.saved": "Настройки сервера сохранены. Запустите сервер для применения.", + "races.clientPending": "Клиент: автоматическая синхронизация пока не настроена.", + "races.existing": "Существующие персонажи сохраняют расу и данные.", "client.races.install": "Установить патч кастомных рас", "client.title": "Клиент игры", "client.looking": "Ищу…", diff --git a/src/lib/api.ts b/src/lib/api.ts index c760980..570fe18 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -516,6 +516,16 @@ export interface ClientDownloadCheck { export type ClientPatchState = "not-installed" | "enabled" | "disabled" | "partially-installed"; +export interface RaceAvailability { + realm: RealmMode; + races: { id: number; name: string; faction: "alliance" | "horde" | null }[]; + disabledRaceIds: number[]; + catalogSha256: string; + serverConfigSupported: boolean; + catalogNeedsReview: boolean; + unrecognizedRaceIds: number[]; +} + export interface CustomRacesClientStatus { version: string | null; state: ClientPatchState; @@ -681,6 +691,9 @@ export const api = { clientDownload: (id: string, parent: string) => invoke("client_download", { id, parent }), clientCancel: () => invoke("client_cancel"), customRacesStatus: (id: string) => invoke("custom_races_status", { id }), + raceAvailability: (id: string) => invoke("race_availability", { id }), + raceAvailabilitySave: (id: string, catalogSha256: string, realm: RealmMode, disabledRaceIds: number[]) => + invoke("race_availability_save", { id, catalogSha256, realm, disabledRaceIds }), customRacesUninstall: (id: string) => invoke("custom_races_uninstall", { id }), customRacesInstall: (id: string) => invoke("custom_races_install", { id }), customRacesToggleClient: (id: string, enabled: boolean) => invoke("custom_races_toggle_client", { id, enabled }), diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index 3a6492f..81e375d 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -17,6 +17,11 @@ const fields = moduleSettingDefinitions; function add(key: string, type: string, title: string[], description: string[], extra: Partial = {}) { fields.push({ key, type, title, description, ...extra }); } +add("TimeIsTime.Enable", "bool", ["Day and night cycle", "Смена дня и ночи", "Tag-Nacht-Zyklus", "Cycle jour-nuit", "Ciclo de día y noche", "昼夜循环"], ["Enable custom game time.", "Включает настройку игрового времени.", "Angepasste Spielzeit aktivieren.", "Active l’heure de jeu personnalisée.", "Activa la hora de juego personalizada.", "启用自定义游戏时间。"]); +add("TimeIsTime.Announce", "bool", ["Announce on login", "Сообщение при входе", "Hinweis beim Anmelden", "Annonce à la connexion", "Aviso al entrar", "登录公告"], ["Show players a module announcement when they log in.", "Показывает игрокам сообщение о модуле при входе.", "Spielern beim Anmelden einen Modulhinweis anzeigen.", "Affiche un message sur le module à la connexion.", "Muestra un aviso sobre el módulo al entrar.", "玩家登录时显示模块公告。"]); +add("TimeIsTime.SpeedRate", "float", ["Game time speed", "Скорость игрового времени", "Geschwindigkeit der Spielzeit", "Vitesse du temps en jeu", "Velocidad del tiempo de juego", "游戏时间速度"], ["1 = a 24-hour day; 7.5 = 3 hours 12 minutes; 60 = 24 minutes. 0 pauses the clock. Applies when players log in again.", "1 — сутки за 24 часа; 7.5 — за 3 часа 12 минут; 60 — за 24 минуты. 0 останавливает часы. Применяется после повторного входа игроков.", "1 = ein Tag in 24 Stunden; 7.5 = 3 Stunden 12 Minuten; 60 = 24 Minuten. 0 hält die Uhr an. Gilt nach erneuter Anmeldung.", "1 = une journée en 24 heures ; 7.5 = 3 h 12 ; 60 = 24 minutes. 0 arrête l’horloge. S’applique à la reconnexion des joueurs.", "1 = un día en 24 horas; 7.5 = 3 horas y 12 minutos; 60 = 24 minutos. 0 detiene el reloj. Se aplica cuando los jugadores vuelven a entrar.", "1表示一天为24小时;7.5为3小时12分钟;60为24分钟。0暂停时钟。玩家重新登录后生效。"], { min: 0, max: 60 }); +add("TimeIsTime.TimeStart", "int", ["Starting game time (Unix timestamp)", "Начальное игровое время (Unix timestamp)", "Spielstartzeit (Unix-Zeitstempel)", "Heure de départ (horodatage Unix)", "Hora inicial (marca de tiempo Unix)", "游戏初始时间(Unix时间戳)"], ["0 uses the server clock. A positive Unix timestamp sets the same starting date and time at every login. Applies when players log in again.", "0 — использовать часы сервера. Положительная метка Unix задаёт одинаковые начальные дату и время при каждом входе. Применяется после повторного входа игроков.", "0 nutzt die Serveruhr. Ein positiver Unix-Zeitstempel setzt bei jeder Anmeldung dieselbe Startzeit. Gilt nach erneuter Anmeldung.", "0 utilise l’horloge du serveur. Un horodatage Unix positif fixe la même date et heure à chaque connexion. S’applique à la reconnexion.", "0 usa el reloj del servidor. Una marca Unix positiva fija la misma fecha y hora inicial en cada entrada. Se aplica al volver a entrar.", "0使用服务器时钟。正Unix时间戳使每次登录都从同一日期和时间开始。重新登录后生效。"], { min: 0, max: 2147483647, advanced: true }); +add("TimeIsTime.HourOffset", "float", ["Starting time offset (hours)", "Смещение начального времени (часы)", "Versatz der Startzeit (Stunden)", "Décalage de départ (heures)", "Desfase de hora inicial (horas)", "初始时间偏移(小时)"], ["Added only when a positive starting timestamp is set. Fractional hours are allowed, for example 5.5. Applies when players log in again.", "Добавляется только при положительной начальной метке времени. Допустимы дробные часы, например 5.5. Применяется после повторного входа игроков.", "Wird nur bei positivem Startzeitstempel addiert. Bruchteile sind erlaubt, etwa 5.5 Stunden. Gilt nach erneuter Anmeldung.", "Ajouté uniquement avec un horodatage de départ positif. Les fractions sont acceptées, par exemple 5.5 heures. S’applique à la reconnexion.", "Se suma solo si la marca de tiempo inicial es positiva. Admite fracciones, por ejemplo 5.5 horas. Se aplica al volver a entrar.", "仅在初始时间戳为正时叠加。支持小数小时,例如5.5。重新登录后生效。"], { min: -24, max: 24, advanced: true }); add("AiPlayerbot.RandomBotAutologin", "bool", ["Populate the world automatically", "Автоматически населять мир", "Welt automatisch bevölkern", "Peupler automatiquement le monde", "Poblar el mundo automáticamente", "自动填充世界"], ["Log random bots into the world when the server starts.", "Вводит случайных ботов в мир при запуске сервера.", "Meldet Zufallsbots beim Serverstart in der Welt an.", "Connecte les bots aléatoires au démarrage du serveur.", "Conecta bots aleatorios al iniciar el servidor.", "服务器启动时让随机机器人登录世界。"]); add("AiPlayerbot.MinRandomBots", "int", ["Minimum online bots", "Минимум ботов онлайн", "Mindestens aktive Bots", "Minimum de bots connectés", "Mínimo de bots conectados", "最少在线机器人"], ["Lower limit of the automatically selected online population. Must not exceed the maximum.", "Нижний предел автоматически выбираемого числа ботов онлайн. Не должен превышать максимум.", "Untergrenze der automatisch gewählten Bot-Zahl. Darf das Maximum nicht überschreiten.", "Limite basse du nombre de bots connectés. Ne doit pas dépasser le maximum.", "Límite inferior de bots conectados. No debe superar el máximo.", "自动选择的在线机器人数量下限,不得超过上限。"], { min: 0 }); add("AiPlayerbot.MaxRandomBots", "int", ["Maximum online bots", "Максимум ботов онлайн", "Maximal aktive Bots", "Maximum de bots connectés", "Máximo de bots conectados", "最多在线机器人"], ["Upper limit of the random bot population; more bots use more server resources.", "Верхний предел числа случайных ботов онлайн. Больше ботов — выше нагрузка на сервер.", "Obergrenze der Zufallsbots; mehr Bots benötigen mehr Serverressourcen.", "Limite haute des bots aléatoires ; plus de bots utilisent plus de ressources.", "Límite superior de bots aleatorios; más bots requieren más recursos del servidor.", "随机机器人在线数量上限,数量越多越占用服务器资源。"], { min: 0 }); @@ -72,8 +77,10 @@ add(scaling + "LFG.DefaultMode", "enum", ["Dungeon Finder group", "Группа ] }); add(scaling + "LFG.DefaultChallengeSize", "int", ["Challenge group size", "Размер группы для расчёта сложности", "Gruppengröße für die Schwierigkeit", "Taille du groupe pour la difficulté", "Tamaño del grupo para dificultad", "难度目标人数"], ["0 follows actual party size. 1–40 simulates that many players. Players can override with .lfgchallenge.", "0 — реальный размер группы. 1–40 — сложность как для указанного числа игроков. Игрок может изменить через .lfgchallenge.", "0 nutzt die tatsächliche Gruppe. 1–40 simuliert diese Spielerzahl. Mit .lfgchallenge änderbar.", "0 suit le groupe réel. 1–40 simule ce nombre de joueurs. Modifiable avec .lfgchallenge.", "0 usa el grupo real. 1–40 simula ese número de jugadores. Modificable con .lfgchallenge.", "0按实际人数,1–40模拟指定人数。玩家可通过 .lfgchallenge 更改。"], { min: 0, max: 40 }); add(scaling + "GroupScaling.LockOnEncounterStart", "bool", ["Lock scaling during combat", "Фиксировать скейлинг на время боя", "Skalierung im Kampf sperren", "Figer l’adaptation en combat", "Fijar ajuste durante combate", "战斗期间锁定缩放"], ["Joining or leaving during a boss fight does not change its scaling.", "Вход и выход игроков во время боя с боссом не меняют скейлинг.", "Beitreten oder Verlassen während eines Bosskampfs ändert die Skalierung nicht.", "Les arrivées et départs pendant un boss ne changent pas l’adaptation.", "Entrar o salir durante un jefe no cambia el ajuste.", "首领战期间玩家进出不会改变缩放。"], { advanced: true }); -add(scaling + "Progression.Mode", "enum", ["Progression boundaries", "Границы прогрессии", "Fortschrittsgrenzen", "Limites de progression", "Límites de progresión", "进度边界"], ["Automatic boundaries follow the level cap and enabled expansions. Custom uses the levels below.", "Автоматические границы зависят от максимального уровня и включённых дополнений. Ручные используют уровни ниже.", "Automatisch folgt der Maximalstufe und Erweiterungen. Benutzerdefiniert nutzt die folgenden Stufen.", "Automatique suit le niveau maximal et les extensions. Manuel utilise les niveaux ci-dessous.", "Automático sigue el nivel máximo y las expansiones. Manual usa los niveles siguientes.", "自动边界按等级上限和启用的资料片计算,自定义使用以下等级。"], { advanced: true, options: [ +add(scaling + "Progression.Mode", "enum", ["Progression boundaries", "Границы прогрессии", "Fortschrittsgrenzen", "Limites de progression", "Límites de progresión", "进度边界"], ["Automatic splits the level cap between the expansions one after another. Parallel scales Classic over 1 to the level cap and TBC and WotLK over 45 to the level cap. Global scales every expansion over 1 to the level cap. Custom uses the levels below.", "Автоматически делит максимальный уровень между дополнениями по очереди. Параллельно: классика на уровнях 1 — максимум, TBC и WotLK на 45 — максимум. Глобально: все дополнения на 1 — максимум. Вручную использует уровни ниже.", "Automatisch teilt die Maximalstufe nacheinander auf die Erweiterungen auf. Parallel skaliert Classic über 1 bis zur Maximalstufe und TBC und WotLK über 45 bis zur Maximalstufe. Global skaliert alle Erweiterungen über 1 bis zur Maximalstufe. Benutzerdefiniert nutzt die folgenden Stufen.", "Automatique répartit le niveau maximal entre les extensions l'une après l'autre. Parallèle étend Classic de 1 au niveau maximal, TBC et WotLK de 45 au niveau maximal. Global étend toutes les extensions de 1 au niveau maximal. Manuel utilise les niveaux ci-dessous.", "Automático reparte el nivel máximo entre las expansiones una tras otra. Paralelo escala Classic de 1 al nivel máximo y TBC y WotLK de 45 al nivel máximo. Global escala todas las expansiones de 1 al nivel máximo. Manual usa los niveles siguientes.", "自动:按顺序在各资料片之间分配等级上限。并行:经典旧世为 1 至等级上限,TBC 和 WotLK 为 45 至等级上限。全局:所有资料片均为 1 至等级上限。自定义:使用以下等级。"], { advanced: true, options: [ { value: "Auto", label: ["Automatic", "Автоматически", "Automatisch", "Automatique", "Automático", "自动"] }, + { value: "Parallel", label: ["Parallel (Classic 1-cap, TBC/WotLK 45-cap)", "Параллельно (классика 1–макс., TBC/WotLK 45–макс.)", "Parallel (Classic 1–Max., TBC/WotLK 45–Max.)", "Parallèle (Classic 1–max., TBC/WotLK 45–max.)", "Paralelo (Classic 1–máx., TBC/WotLK 45–máx.)", "并行(经典 1–上限,TBC/WotLK 45–上限)"] }, + { value: "Global", label: ["Global (everything 1-cap)", "Глобально (всё на 1–макс.)", "Global (alles 1–Max.)", "Global (tout 1–max.)", "Global (todo 1–máx.)", "全局(全部 1–上限)"] }, { value: "Custom", label: ["Custom", "Вручную", "Benutzerdefiniert", "Manuel", "Manual", "自定义"] }, ] }); for (const era of ["Classic", "Tbc"]) add(scaling + `Progression.${era}End`, "int", [`${era} last level`, `Последний уровень ${era}`, `${era}-Endstufe`, `Dernier niveau ${era}`, `Último nivel ${era}`, `${era} 最高等级`], ["Used in custom progression. 0 chooses automatically.", "Для ручной прогрессии. 0 — автоматически.", "Für benutzerdefinierten Fortschritt. 0 wählt automatisch.", "Pour la progression manuelle. 0 choisit automatiquement.", "Para progresión manual. 0 elige automáticamente.", "用于自定义进度,0为自动。"], { min: 0, max: 80, advanced: true }); @@ -104,7 +111,6 @@ add("CoACustomRaces.BotsEnable", "bool", const ah = "AuctionHouseBot."; add(ah + "GUIDs", "string", ["Auction characters", "Персонажи аукциона", "Auktionscharaktere", "Personnages d’enchères", "Personajes de subasta", "拍卖角色"], ["Leave 0 and the server creates and uses its own auction character. To use your own, enter GUIDs separated by commas, without spaces; use dedicated offline characters, not companion bots.", "Оставьте 0, и сервер сам создаст и будет использовать своего персонажа аукциона. Чтобы использовать своих, укажите GUID через запятую без пробелов; нужны отдельные офлайн-персонажи, не боты-компаньоны.", "Bei 0 legt der Server selbst einen Auktionscharakter an und nutzt ihn. Eigene Charaktere: GUIDs durch Kommas, ohne Leerzeichen; separate Offline-Charaktere verwenden, keine Begleiter-Bots.", "Laissez 0 : le serveur crée et utilise son propre personnage d’enchères. Pour les vôtres, indiquez des GUID séparés par des virgules sans espaces ; personnages dédiés hors ligne, pas des bots compagnons.", "Deja 0 y el servidor crea y usa su propio personaje de subastas. Para usar los tuyos, indica GUID separados por comas sin espacios; personajes dedicados desconectados, no bots acompañantes.", "保持0时由服务器自动创建并使用拍卖角色。要使用自己的角色,请以逗号分隔填写GUID,不加空格;使用专用离线角色,勿用伙伴机器人。"], { advanced: true }); -add(ah + "EnableSeller", "bool", ["Post auctions", "Выставлять лоты", "Auktionen einstellen", "Publier des enchères", "Publicar subastas", "发布拍卖"], ["Let the auction characters sell items. The module switch is also required.", "Разрешает персонажам продавать предметы. Нужен включённый модуль.", "Erlaubt Verkäufe. Der Modulschalter muss ebenfalls aktiv sein.", "Autorise les ventes. Le module doit aussi être activé.", "Permite vender. También requiere el módulo activado.", "允许拍卖角色出售物品,还需启用模块。"]); add(ah + "CoA.ObtainableItemsOnly", "bool", ["Only obtainable items", "Только доступные в игре предметы", "Nur erhältliche Gegenstände", "Objets accessibles uniquement", "Solo objetos obtenibles", "仅可获取物品"], ["List items from world loot, vendors and professions. Other item filters still apply.", "Выставляет предметы из добычи, торговцев и профессий. Остальные фильтры продолжают действовать.", "Listet Gegenstände aus Beute, Händlern und Berufen. Weitere Filter gelten weiterhin.", "Publie les objets de butin, vendeurs et métiers. Les autres filtres s’appliquent.", "Publica objetos de botín, vendedores y profesiones. Los demás filtros siguen activos.", "发布来自掉落、商人和专业的物品,其他筛选仍生效。"]); add(ah + "ItemsPerCycle", "int", ["Listings per cycle", "Лотов за цикл", "Angebote pro Durchlauf", "Annonces par cycle", "Anuncios por ciclo", "每轮上架数量"], ["Maximum items posted during one auction update.", "Максимум предметов за одно обновление аукциона.", "Maximale Angebote pro Auktionsaktualisierung.", "Nombre maximal d’objets par mise à jour.", "Máximo de objetos por actualización.", "每轮拍卖更新最多上架的物品数量。"], { min: 0 }); for (const [kind, titles] of [ @@ -138,6 +144,7 @@ for (const [prefix, labels, bounds, titles] of [ add(ah + prefix + ".Enabled", "bool", labels, desc); bounds.forEach((bound, i) => add(ah + prefix + "." + bound, "int", [`${titles[0]}: ${i ? "maximum" : "minimum"}`, `${titles[1]}: ${i ? "максимум" : "минимум"}`, `${titles[2]}: ${i ? "Maximum" : "Minimum"}`, `${titles[3]} : ${i ? "maximum" : "minimum"}`, `${titles[4]}: ${i ? "máximo" : "mínimo"}`, `${titles[5]}:${i ? "最大" : "最小"}`], desc, { min: 0 })); } +export const retiredModuleKeys = new Set(["AuctionHouseBot.EnableSeller"]); export function moduleEnableKey(id: string) { return catalog.modules.find(m => m.id === id)?.enable_key; } export const auctionCategories: Record = { Consumable: ["Consumables", "Расходники", "Verbrauchsgüter", "Consommables", "Consumibles", "消耗品"], @@ -208,6 +215,6 @@ export function canonicalModuleValue(key: string, value: string): string { const v = value.trim().replace(/^"|"$/g, ""); if (key === scaling + "SoloAssist.Mode") return ({ "0": "None", "1": "Light", "2": "Full", none: "None", light: "Light", full: "Full" } as Record)[v.toLowerCase()] ?? v; if (key === scaling + "LFG.DefaultMode") return ({ "0": "Matchmaking", "1": "BotFill", "2": "CurrentParty", matchmaking: "Matchmaking", botfill: "BotFill", currentparty: "CurrentParty" } as Record)[v.toLowerCase()] ?? v; - if (key === scaling + "Progression.Mode") return v.toLowerCase() === "auto" ? "Auto" : v.toLowerCase() === "custom" ? "Custom" : v; + if (key === scaling + "Progression.Mode") return ({ auto: "Auto", parallel: "Parallel", global: "Global", all: "Global", "1-60": "Global", custom: "Custom" } as Record)[v.toLowerCase()] ?? v; return v; } diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index e844e09..01fcb02 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -48,6 +48,7 @@ import { Card } from "@/components/ui/card"; import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n"; import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField } from "@/lib/moduleSettings"; import { AuctionTypes } from "@/screens/AuctionTypes"; +import { RaceAvailabilitySettings } from "@/screens/RaceAvailabilitySettings"; import type { UnsavedChangesGuard } from "@/lib/unsavedGuard"; /** The GitHub mark */ @@ -143,12 +144,28 @@ const OFFICIAL_IDS = new Set([ "content-scaling", "tbc-content", "wotlk-content", + "ah-bot", + "npc-enchanter", "playerbots", "custom-races", "time-is-time", "client-compat", ]); +function cleanModuleName(name: string, id: string): string { + if (name && (name.length !== 40 || !/^[0-9a-f]+$/i.test(name))) { + return name; + } + if (id === "npc-buffer" || id.includes("npc_buffer") || name.includes("npc_buffer")) return "NPC Buffer"; + if (id === "ah-bot" || id.includes("ah_bot") || name.includes("ah_bot")) return "Auction House Bot"; + if (id === "npc-enchanter" || id.includes("enchanter")) return "NPC Enchanter"; + if (id === "congrats-on-level" || id.includes("congrats")) return "Congrats On Level"; + if (id === "reward-played-time" || id.includes("reward_played")) return "Reward Played Time"; + if (id === "solocraft" || id.includes("solocraft")) return "Solocraft"; + if (id && id.length === 40 && /^[0-9a-f]+$/i.test(id)) return `Module (${id.slice(0, 8)})`; + return id || name; +} + const VERIFIED_CATALOG: VerifiedCatalogModule[] = [ // Official Core Modules { @@ -323,21 +340,22 @@ const VERIFIED_CATALOG: VerifiedCatalogModule[] = [ { id: "ah-bot", name: "Auction House Bot", - repo: "https://github.com/azerothcore/mod-ah-bot", - category: "economy", + repo: "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-ah-bot-plus", + category: "official", description: { - ru: "Автоматическое наполнение аукциона сбалансированными лотами предметов от имени виртуальных игроков.", - en: "Populates auction houses with realistic listings from virtual buyers and sellers.", + ru: "Наполняет аукцион реалистичными настраиваемыми лотами.", + en: "Fills the auction house with realistic, adjustable listings.", }, - qualification: "LINK_VERIFIED", - tier: "Tier 1: Dynamic DLL", - verificationLevel: "L0-L3 PASS (SDK v0.3.2+)", + qualification: "OFFICIAL_CORE", + tier: "Core Module", + verificationLevel: "PRODUCTION_SUPPORTED", notes: { - ru: "Успешно связывается с SDK v0.3.2 (добавлены экспорты Player::~Player, OnAuctionSuccessful, GetAuctionHouseEntryFromHouse).", - en: "Successfully links against SDK v0.3.2 after resolving Player destructor and auction manager exports.", + ru: "Встроенный официальный модуль аукциона виртуальных персонажей.", + en: "Built-in official virtual characters auction house module.", }, sqlRequired: false, canInstall: true, + isOfficial: true, icon: "gavel", }, { @@ -364,20 +382,21 @@ const VERIFIED_CATALOG: VerifiedCatalogModule[] = [ id: "npc-enchanter", name: "NPC Enchanter", repo: "https://github.com/azerothcore/mod-npc-enchanter", - category: "profession", + category: "official", description: { - ru: "NPC-зачарователь для наложения чар на оружие и броню за золото и реагенты.", - en: "NPC offering equipment enchanting services for gold and custom tokens.", + ru: "НПС, который зачаровывает ваше снаряжение.", + en: "An NPC that enchants your gear for you.", }, - qualification: "INSTALL_VERIFIED", - tier: "Tier 1: Dynamic DLL", - verificationLevel: "L0-L4 PASS, L6 PASS", + qualification: "OFFICIAL_CORE", + tier: "Core Module", + verificationLevel: "PRODUCTION_SUPPORTED", notes: { - ru: "Подтверждена сборка и установка. Для спавна NPC используется команда .npc add 601015.", - en: "Build and install verified. Spawn using command .npc add 601015.", + ru: "Встроенный официальный модуль зачарования экипировки у НПС.", + en: "Built-in official NPC equipment enchanting module.", }, - sqlRequired: true, + sqlRequired: false, canInstall: true, + isOfficial: true, icon: "sparkles", }, { @@ -400,26 +419,7 @@ const VERIFIED_CATALOG: VerifiedCatalogModule[] = [ canInstall: true, icon: "plug", }, - { - id: "transmog", - name: "Transmogrification", - repo: "https://github.com/azerothcore/mod-transmog", - category: "utility", - description: { - ru: "Трансмогрификация экипировки персонажей в стиле современных дополнений WoW.", - en: "Modern retail-style equipment transmogrification system.", - }, - qualification: "BUILT", - tier: "Tier 1: Dynamic DLL", - verificationLevel: "L0-L2 PASS", - notes: { - ru: "Сборка C++20 с адаптером успешна.", - en: "C++20 compilation with adapter succeeds.", - }, - sqlRequired: true, - canInstall: true, - icon: "plug", - }, + { id: "solocraft", name: "Solocraft", @@ -499,10 +499,17 @@ export function ModulesPage({ // Action busy indicators const [actionInProgress, setActionInProgress] = useState(null); + const [racesGuard, setRacesGuard] = useState(null); + useEffect(() => { - onRegisterGuard?.(guard); + const guards = [guard, racesGuard].filter((item): item is UnsavedChangesGuard => item !== null); + onRegisterGuard?.(guards.length ? { + isDirty: () => guards.some(item => item.isDirty()), + save: async () => { for (const item of guards) if (item.isDirty() && !await item.save()) return false; return true; }, + discard: () => guards.forEach(item => item.discard()), + } : null); return () => onRegisterGuard?.(null); - }, [guard, onRegisterGuard]); + }, [guard, racesGuard, onRegisterGuard]); const loadToolchain = useCallback(async () => { try { @@ -1112,7 +1119,7 @@ export function ModulesPage({ )} {installedModules.map((m, i) => { - const isDynamic = !OFFICIAL_IDS.has(m.id) || installedPlugins.some((p) => p.id === m.id); + const isDynamic = !OFFICIAL_IDS.has(m.id) && (installedPlugins.some((p) => p.id === m.id) || m.id.length === 40); return (
-
+
-

{m.name}

+

{cleanModuleName(m.name, m.id)}

{m.version && ( {m.version} )} @@ -1363,6 +1370,14 @@ export function ModulesPage({ onSaved={() => setChanged(true)} onRegisterGuard={setGuard} /> + {selectedModule.id === "custom-races" && ( + setChanged(true)} + onRegisterGuard={setRacesGuard} + /> + )} )} @@ -1540,10 +1555,21 @@ export function ModulesPage({ size="sm" variant="primary" onClick={async () => { - if (matchedModule) { - await toggleOfficialModule(matchedModule); - setOpenSettings(matchedModule.id); + try { + const mod = modules?.find((m) => m.id === item.id); + if (mod && mod.enabled) { + setOpenSettings(mod.id); + setActiveTab("installed"); + return; + } + await api.moduleSetEnabled(serverId, item.id, true); + setChanged(true); + await load(); + setOpenSettings(item.id); setActiveTab("installed"); + onChanged?.(); + } catch (e) { + setError(asUiError(e)); } }} className="text-xs h-7 px-3" @@ -1630,11 +1656,21 @@ export function ModulesPage({ const modId = selectedCatalogModule.id; setSelectedCatalogModule(null); if (isOff) { - const m = modules?.find(x => x.id === modId); - if (m) { - await toggleOfficialModule(m); - setOpenSettings(m.id); + try { + const m = modules?.find((x) => x.id === modId); + if (m && m.enabled) { + setOpenSettings(m.id); + setActiveTab("installed"); + return; + } + await api.moduleSetEnabled(serverId, modId, true); + setChanged(true); + await load(); + setOpenSettings(modId); setActiveTab("installed"); + onChanged?.(); + } catch (e) { + setError(asUiError(e)); } } else { setGithubUrl(repo); diff --git a/src/screens/RaceAvailabilitySettings.tsx b/src/screens/RaceAvailabilitySettings.tsx new file mode 100644 index 0000000..fef426d --- /dev/null +++ b/src/screens/RaceAvailabilitySettings.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { api, asUiError, type RaceAvailability, type UiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { useHuman, useI18n } from "@/i18n"; +import type { UnsavedChangesGuard } from "@/lib/unsavedGuard"; + +export function RaceAvailabilitySettings({ serverId, blocked, onSaved, onRegisterGuard }: { + serverId: string; + blocked: boolean; + onSaved: () => void; + onRegisterGuard: (guard: UnsavedChangesGuard | null) => void; +}) { + const { t } = useI18n(); + const human = useHuman(); + const [data, setData] = useState(null); + const [disabled, setDisabled] = useState([]); + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const canonical = (ids: number[]) => [...ids].sort((a, b) => a - b).join(","); + const dirty = !!data && canonical(disabled) !== canonical(data.disabledRaceIds); + const locked = blocked || busy || !data?.serverConfigSupported; + + useEffect(() => { + let active = true; + setData(null); + setDisabled([]); + setError(null); + setSaved(false); + void api.raceAvailability(serverId).then(result => { + if (active) { setData(result); setDisabled(result.disabledRaceIds); } + }).catch(reason => { if (active) setError(asUiError(reason)); }); + return () => { active = false; }; + }, [serverId]); + + async function save(): Promise { + if (!data || locked) return false; + setBusy(true); + setError(null); + try { + const result = await api.raceAvailabilitySave(serverId, data.catalogSha256, data.realm, disabled); + setData(result); + setDisabled(result.disabledRaceIds); + setSaved(true); + onSaved(); + return true; + } catch (reason) { + setError(asUiError(reason)); + return false; + } finally { + setBusy(false); + } + } + + useEffect(() => { + onRegisterGuard(dirty ? { + isDirty: () => dirty, + save, + discard: () => { setDisabled(data?.disabledRaceIds ?? []); setSaved(false); }, + } : null); + return () => onRegisterGuard(null); + }, [dirty, disabled, data, busy, blocked, serverId, onRegisterGuard]); + + return ( +
+

{t("races.available")}

+ {error &&

{error.human.code === "unknown" ? error.technical : human(error.human).message}

} + {data && <> +

{t("races.count", { allowed: data.races.length - disabled.length, disabled: disabled.length })}

+ {!data.serverConfigSupported &&

{t("races.updateServer")}

} + {data.catalogNeedsReview &&

{t("races.reviewCatalog")}

} + {data.unrecognizedRaceIds.length > 0 &&

{t("races.unknownIds", { ids: data.unrecognizedRaceIds.join(", ") })}

} + setQuery(event.target.value)} placeholder={t("set.search")} + aria-label={t("set.search")} className="w-full rounded border border-line bg-bg px-2 py-1 text-sm" /> +
+ {(["horde", "alliance", null] as const).map(faction => { + const races = data.races.filter(race => race.faction === faction); + if (faction === null && !races.length) return null; + const title = t(faction === "horde" ? "races.horde" : faction === "alliance" ? "races.alliance" : "races.available"); + return ( +
+
+ + + + + + + + {races.filter(race => `${race.name} ${race.id}`.toLowerCase().includes(query.trim().toLowerCase())).map(race => ( + + + + + + ))} + +
ID{title}{t("races.allowCreation")}
{race.id}{race.name} + { setSaved(false); setDisabled(previous => event.target.checked ? previous.filter(id => id !== race.id) : [...previous, race.id]); }} /> +
+
+
+ ); + })} +
+
+ + + +
+

{t(saved ? "races.saved" : "races.restart")}

+

{t("races.clientPending")}

+

{t("races.existing")}

+ } +
+ ); +}