From 3d0de60d84213d32c2db166d26172a2b9968fa33 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: Fri, 6 Nov 2026 22:20:54 +0500 Subject: [PATCH] feat(manager): expand multi-realm bot fleet controls, DB routing, and compatibility guards --- crates/coa-core/src/companions.rs | 23 +- crates/coa-core/src/population.rs | 37 +- crates/coa-core/src/ra.rs | 12 + crates/coa-core/src/realms.rs | 64 ++- src-tauri/src/lib.rs | 70 ++- src/lib/api.ts | 7 +- src/screens/CompanionsCard.tsx | 808 ++++++++++++++++++++++++------ 7 files changed, 799 insertions(+), 222 deletions(-) diff --git a/crates/coa-core/src/companions.rs b/crates/coa-core/src/companions.rs index 3dab4e0..63d2721 100644 --- a/crates/coa-core/src/companions.rs +++ b/crates/coa-core/src/companions.rs @@ -19,37 +19,46 @@ pub fn seed_sql(base_guid: &str) -> String { format!("SET @G = {base_guid}; SET @ACC = {TEMPLATE_ACCOUNT};\n{SQL}") } -/// Number of characters the bot module could use as templates (level 80, a CoA class, not account 1). +/// Number of characters the bot module could use as templates. pub fn template_count(db: &Db) -> Result { - let n = db.query("SELECT COUNT(*) FROM acore_characters.characters WHERE account<>1 AND level>=80 AND class BETWEEN 12 AND 32;")?; + template_count_for_schema(db, "acore_characters") +} + +pub fn template_count_for_schema(db: &Db, chars_db: &str) -> Result { + let n = db.query(&format!("SELECT COUNT(*) FROM {chars_db}.characters WHERE account<>1 AND level>=80;"))?; Ok(n.trim().parse().unwrap_or(0)) } -fn run_seed(db: &Db, base_guid: &str) -> Result<()> { +fn run_seed(db: &Db, chars_db: &str, base_guid: &str) -> Result<()> { let file = std::env::temp_dir().join(format!( "coa-companion-templates-{}.sql", std::process::id() )); fs::write(&file, seed_sql(base_guid))?; - let r = db.run_sql_file("acore_characters", &file); + let r = db.run_sql_file(chars_db, &file); let _ = fs::remove_file(&file); r } /// Fresh databases (release tooling): the original guids. pub fn seed_fresh(db: &Db) -> Result<()> { - run_seed(db, "0") + run_seed(db, "acore_characters", "0") } /// An existing server without templates: add them far above the highest guid in use, so a running world can never /// hand a new character one of their guids. Returns whether anything was added. pub fn ensure_templates(db: &Db) -> Result { - if template_count(db)? > 0 { + ensure_templates_for_schema(db, "acore_characters") +} + +pub fn ensure_templates_for_schema(db: &Db, chars_db: &str) -> Result { + if template_count_for_schema(db, chars_db)? > 0 { return Ok(false); } run_seed( db, - "(SELECT IFNULL(MAX(guid),0) FROM acore_characters.characters) + 100000", + chars_db, + &format!("(SELECT IFNULL(MAX(guid),0) FROM {chars_db}.characters) + 100000"), )?; Ok(true) } diff --git a/crates/coa-core/src/population.rs b/crates/coa-core/src/population.rs index f228e4b..133f447 100644 --- a/crates/coa-core/src/population.rs +++ b/crates/coa-core/src/population.rs @@ -49,17 +49,19 @@ fn parse_counts(out: &str) -> Result { }) } -/// Read-only query with the game's own database account. Requires the database to be running. +/// Read-only query with the game's own database account. Routes automatically to the active realm's characters database. pub fn query(root: &Path) -> Result { - let db = Db::from_repack(root, Account::App)?; - query_with( - &db, - &bot_account_prefix(root), - &crate::accounts::squid_bot_prefix(root), - ) + let mode = crate::realms::state(root) + .map(|s| s.active) + .unwrap_or(crate::realms::Mode::Coa); + query_for_realm(root, mode) } fn counts_sql(prefix: &str, squid_prefix: &str) -> Result { + counts_sql_for_db("acore_characters", prefix, squid_prefix) +} + +pub fn counts_sql_for_db(chars_db: &str, prefix: &str, squid_prefix: &str) -> Result { if [prefix, squid_prefix] .iter() .any(|prefix| prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_alphanumeric())) @@ -74,9 +76,9 @@ fn counts_sql(prefix: &str, squid_prefix: &str) -> Result { let bots = format!("(UPPER(a.username) LIKE '{prefix}%' OR UPPER(a.username) LIKE '{squid_prefix}%')"); Ok(format!( - "SELECT (SELECT COUNT(*) FROM acore_characters.characters WHERE online=1), \ - (SELECT COUNT(*) FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND {bots}), \ - (SELECT COUNT(*) FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE {bots});" + "SELECT (SELECT COUNT(*) FROM {chars_db}.characters WHERE online=1), \ + (SELECT COUNT(*) FROM {chars_db}.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND {bots}), \ + (SELECT COUNT(*) FROM {chars_db}.characters c JOIN acore_auth.account a ON a.id=c.account WHERE {bots});" )) } @@ -85,6 +87,21 @@ pub fn query_with(db: &Db, prefix: &str, squid_prefix: &str) -> Result Result { + parse_counts(&db.query(&counts_sql_for_db(chars_db, prefix, squid_prefix)?)?) +} + +pub fn query_for_realm(root: &Path, mode: crate::realms::Mode) -> Result { + let db = Db::from_repack(root, Account::App)?; + let schema = mode.schema("characters")?; + query_for_schema( + &db, + schema, + &bot_account_prefix(root), + &crate::accounts::squid_bot_prefix(root), + ) +} + #[derive(Debug, Clone, Serialize)] pub struct Hardware { pub cores: u32, diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 2523c04..aeca493 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -249,6 +249,18 @@ impl Ra { Ok(out.lines().last().unwrap_or("").to_string()) } + /// Ask the bot module to spawn a fleet of bots of specific race and level (.botcmd spawnfleet ). + pub fn spawn_fleet(&mut self, race: u8, level: u8) -> Result { + let out = self.bot_command(&format!("botcmd spawnfleet {race} {level}"))?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + + /// Bring all offline bots online gradually (.botcmd loginall). + pub fn login_all(&mut self) -> Result { + let out = self.bot_command("botcmd loginall")?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + /// Set a new password for an existing account. pub fn set_account_password(&mut self, name: &str, password: &str) -> Result<()> { validate_account(name, password)?; diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs index 6d839ae..ba3b3d0 100644 --- a/crates/coa-core/src/realms.rs +++ b/crates/coa-core/src/realms.rs @@ -185,8 +185,21 @@ pub fn view(root: &Path) -> Result { } pub fn guard_module(root: &Path, id: &str) -> Result<()> { - // Multi-realm Playerbots 1.0 supports all realms (CoA, Wildcard, Reborn). - let _ = (root, id); + let s = state(root)?; + if matches!(id, "companions" | "playerbots") { + if let Ok(v) = view(root) { + if s.active == Mode::Wildcard && !v.supported { + return Err(Error::Invalid( + "The installed server binary does not support Wildcard mode.".into(), + )); + } + if s.active == Mode::Reborn && !v.reborn_supported { + return Err(Error::Invalid( + "The installed server binary does not support Warcraft Reborn mode.".into(), + )); + } + } + } Ok(()) } @@ -295,18 +308,14 @@ fn wildcard_configs(mut files: Files) -> Result { } } } - set( - &mut files, - "Core/configs/modules/mod_coa_playerbots.conf", - "CoaBots.Enable", - "1", - )?; - set( - &mut files, - "Core/configs/modules/playerbots.conf", - "AiPlayerbot.Enabled", - "0", - )?; + // Preserve existing CoaBots.Enable if present; do not force enable or alter alternative bot systems + if let Some(bytes) = files.get("Core/configs/modules/mod_coa_playerbots.conf") { + if let Ok(conf) = ConfFile::parse_bytes(bytes) { + if conf.get("CoaBots.Enable") == Some("1") { + set(&mut files, "Core/configs/modules/mod_coa_playerbots.conf", "CoaBots.Enable", "1")?; + } + } + } Ok(files) } @@ -334,18 +343,14 @@ fn reborn_configs(mut files: Files) -> Result { set(&mut files, path, "RealmID", "3")?; } } - set( - &mut files, - "Core/configs/modules/mod_coa_playerbots.conf", - "CoaBots.Enable", - "1", - )?; - set( - &mut files, - "Core/configs/modules/playerbots.conf", - "AiPlayerbot.Enabled", - "0", - )?; + // Preserve existing CoaBots.Enable if present; do not force enable or alter alternative bot systems + if let Some(bytes) = files.get("Core/configs/modules/mod_coa_playerbots.conf") { + if let Ok(conf) = ConfFile::parse_bytes(bytes) { + if conf.get("CoaBots.Enable") == Some("1") { + set(&mut files, "Core/configs/modules/mod_coa_playerbots.conf", "CoaBots.Enable", "1")?; + } + } + } Ok(files) } @@ -920,11 +925,12 @@ mod tests { .get("RealmID"), Some("3") ); + // Requirement 6: Never alter alternative bot system settings (AiPlayerbot.Enabled) without explicit user choice assert_eq!( ConfFile::parse_bytes(&reborn["Core/configs/modules/playerbots.conf"]) .unwrap() .get("AiPlayerbot.Enabled"), - Some("0") + Some("1") ); write_configs(t.path(), &reborn).unwrap(); write_configs(t.path(), &coa).unwrap(); @@ -1161,6 +1167,10 @@ CoA.GameModeMask = 0 "Core/configs/modules/mod-coa-content-scaling.conf".into(), b"[worldserver]\nCoAContentScaling.Enable = 1\n".to_vec(), ); + f.insert( + "Core/configs/modules/mod_coa_playerbots.conf".into(), + b"[worldserver]\nCoaBots.Enable = 1\n".to_vec(), + ); f.insert( "Settings/worldserver.conf.template".into(), b"[worldserver]\nRealmID = 1\n".to_vec(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 747e064..0f1cd5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1749,6 +1749,7 @@ async fn retry_update_validation( async fn get_population( state: State<'_, AppState>, id: String, + realm: Option, ) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; Ok(tauri::async_runtime::spawn_blocking(move || { @@ -1756,12 +1757,46 @@ async fn get_population( if o.mysql.state != coa_core::process::ServiceState::Running { return None; } - coa_core::population::query(&root).ok() + if let Some(mode) = realm { + coa_core::population::query_for_realm(&root, mode).ok() + } else { + coa_core::population::query(&root).ok() + } }) .await .unwrap_or(None)) } +#[tauri::command] +async fn companions_login_all( + state: State<'_, AppState>, + id: String, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + let mut ra = Ra::connect(&root)?; + ra.login_all() + }) + .await +} + +#[tauri::command] +async fn companions_spawn_fleet( + state: State<'_, AppState>, + id: String, + race: u8, + level: u8, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + let mut ra = Ra::connect(&root)?; + ra.spawn_fleet(race, level) + }) + .await +} + #[derive(Serialize)] struct CompanionAction { /// Bots affected (cancelled from the queue, or logged out). @@ -1912,6 +1947,7 @@ async fn add_companions( state: State<'_, AppState>, id: String, count: u32, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; @@ -1921,6 +1957,14 @@ async fn add_companions( "Choose between 1 and 2000 companions.".into(), )); } + let mode = realm.unwrap_or_else(|| { + coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default() + }); + let chars_db = match mode { + coa_core::realms::Mode::Coa => "acore_characters", + coa_core::realms::Mode::Wildcard => "acore_characters_wildcard", + coa_core::realms::Mode::Reborn => "acore_characters_reborn", + }; let meta = meta_dir(&root)?; let mut changes = BTreeMap::new(); changes.insert("CoaBots.AutoLoginOnStartup".to_string(), Value::Bool(true)); @@ -1934,7 +1978,7 @@ async fn add_companions( .and_then(|s| s.value.as_u64()) }) .unwrap_or(0) as u32; - let already = coa_core::population::query(&root) + let already = coa_core::population::query_for_realm(&root, mode) .map(|p| p.bots_total) .unwrap_or(0); changes.insert( @@ -1943,10 +1987,11 @@ async fn add_companions( ); config::save(&root, &meta, Scope::Bots, &changes)?; let o = coa_core::process::observe(&root, &layout::read_ports(&root)); - // Large batches while the server is stopped: create them in the database right now, fully equipped. + // Large batches while the server is stopped: create them in the database right now, fully equipped (CoA schema only). if o.world.state == coa_core::process::ServiceState::Stopped && o.auth.state == coa_core::process::ServiceState::Stopped && count >= coa_core::companions::OFFLINE_MIN + && mode == coa_core::realms::Mode::Coa && coa_core::companions::offline_factory(&root).is_some() { let started_db = o.mysql.state != coa_core::process::ServiceState::Running; @@ -1957,10 +2002,10 @@ async fn add_companions( } } let made = (|| -> Result<()> { - coa_core::companions::ensure_templates(&coa_core::db::Db::from_repack( - &root, - coa_core::db::Account::Admin, - )?)?; + coa_core::companions::ensure_templates_for_schema( + &coa_core::db::Db::from_repack(&root, coa_core::db::Account::Admin)?, + chars_db, + )?; coa_core::companions::offline_create( &root, &meta.join("logs").join("companions-offline.log"), @@ -1982,11 +2027,10 @@ async fn add_companions( let mut baseline = None; let spawned = if o.world.state == coa_core::process::ServiceState::Running { // Older servers have no template characters to copy bots from: add them first. - coa_core::companions::ensure_templates(&coa_core::db::Db::from_repack( - &root, - coa_core::db::Account::Admin, - )?)?; - baseline = coa_core::population::query(&root) + if let Ok(admin_db) = coa_core::db::Db::from_repack(&root, coa_core::db::Account::Admin) { + let _ = coa_core::companions::ensure_templates_for_schema(&admin_db, chars_db); + } + baseline = coa_core::population::query_for_realm(&root, mode) .ok() .map(|p| p.bots_total); Some(Ra::connect(&root)?.spawn_bots(count)?) @@ -3484,6 +3528,8 @@ pub fn run() { companions_take_offline, companions_delete_all, companions_despawn_some, + companions_login_all, + companions_spawn_fleet, open_link, report_targets, companion_sizes, diff --git a/src/lib/api.ts b/src/lib/api.ts index e26f108..044b046 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -812,15 +812,18 @@ export const api = { invoke<{ txn: UpdateTxn; preexisting_character_count?: number; modules?: ModuleRebuild[] }>("apply_update", { id, source: source ?? null, resolutions }), rollbackUpdate: (id: string, txn: string) => invoke("rollback_update", { id, txn }), retryUpdateValidation: (id: string, txn: string) => invoke("retry_update_validation", { id, txn }), - population: (id: string) => invoke("get_population", { id }), + population: (id: string, realm?: RealmMode) => invoke("get_population", { id, realm: realm ?? null }), stopSpawning: (id: string) => invoke<{ count: number }>("companions_stop_spawning", { id }), takeOffline: (id: string) => invoke<{ count: number }>("companions_take_offline", { id }), despawnSome: (id: string, count: number) => invoke<{ count: number }>("companions_despawn_some", { id, count }), deleteAllCompanions: (id: string) => invoke<{ count: number }>("companions_delete_all", { id }), + loginAllCompanions: (id: string) => invoke("companions_login_all", { id }), + spawnBotFleet: (id: string, race: number, level: number) => invoke("companions_spawn_fleet", { id, race, level }), openLink: (url: string) => invoke("open_link", { url }), performance: (id: string) => invoke("get_performance", { id }), companionSizes: () => invoke("companion_sizes"), - addCompanions: (id: string, count: number) => invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", { id, count }), + addCompanions: (id: string, count: number, realm?: RealmMode) => + invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", { id, count, realm: realm ?? null }), clientGraphics: (id: string) => invoke<{ dxvk_disabled: boolean; enhancements_disabled: boolean }>("client_graphics", { id }), clientSetGraphics: (id: string, name: string, disabled: boolean) => invoke<{ dxvk_disabled: boolean; enhancements_disabled: boolean }>("client_set_graphics", { id, name, disabled }), clientInfo: (id: string) => invoke("client_info", { id }), diff --git a/src/screens/CompanionsCard.tsx b/src/screens/CompanionsCard.tsx index a3f9307..3ca88ac 100644 --- a/src/screens/CompanionsCard.tsx +++ b/src/screens/CompanionsCard.tsx @@ -1,35 +1,88 @@ import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; -import { api, asUiError, type CompanionSizes, type Population, type UiError } from "@/lib/api"; +import { + Loader2, + Bot, + CheckCircle2, + AlertCircle, + RefreshCw, + Power, + Users, + Database, +} from "lucide-react"; +import { + api, + asUiError, + type Population, + type RealmMode, + type RealmProfiles, + type UiError, +} from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { hasKey, useHuman, useT, type Key } from "@/i18n"; +import { useHuman, useT } from "@/i18n"; export function CompanionsCard({ serverId }: { serverId: string }) { const t = useT(); const human = useHuman(); - const [sizes, setSizes] = useState(null); - const [realmMode, setRealmMode] = useState(null); + + // Realm & Environment state + const [profiles, setProfiles] = useState(null); + const [selectedRealm, setSelectedRealm] = useState("coa"); const [pop, setPop] = useState(null); const [running, setRunning] = useState(false); - const [choice, setChoice] = useState("small"); - const [custom, setCustom] = useState("100"); + + // Fleet Configuration state + const [spawnMode, setSpawnMode] = useState<"population" | "fleet">("population"); + const [countChoice, setCountChoice] = useState("50"); + const [customCount, setCustomCount] = useState("50"); + const [targetLevel, setTargetLevel] = useState(80); + const [selectedClass, setSelectedClass] = useState("all"); + const [selectedRole, setSelectedRole] = useState("all"); + const [selectedArchetype, setSelectedArchetype] = useState("all"); + + // Operational state const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [note, setNote] = useState(null); - // Start-up login: how many existing bots the server brings online by itself every time it starts. + const [job, setJob] = useState<{ baseline: number; target: number; since: number } | null>(null); + + // Startup Auto-Login settings const [autoOn, setAutoOn] = useState(true); const [autoMax, setAutoMax] = useState(""); - // A creation that is still in progress on the running server: how many existed before and how many were asked for. - const [job, setJob] = useState<{ baseline: number; target: number; since: number } | null>(null); const [startNote, setStartNote] = useState(null); const [startBusy, setStartBusy] = useState(false); const [despawnN, setDespawnN] = useState("10"); + const activeServerRealm = profiles?.active ?? "coa"; + const isViewingActiveRealm = selectedRealm === activeServerRealm; + + const maxLevelForRealm = selectedRealm === "reborn" ? 60 : 80; + + // Load realm profiles + const loadProfiles = async () => { + try { + const r = await api.realmProfiles(serverId); + setProfiles(r); + if (!selectedRealm) { + setSelectedRealm(r.active); + } + } catch { + // ignore + } + }; + useEffect(() => { - void api.realmProfiles(serverId).then((r) => setRealmMode(r.active)).catch(() => {}); + void loadProfiles(); }, [serverId]); + // Adjust target level if realm changed + useEffect(() => { + if (targetLevel > maxLevelForRealm) { + setTargetLevel(maxLevelForRealm); + } + }, [selectedRealm, maxLevelForRealm, targetLevel]); + + // Load startup settings useEffect(() => { void api .settings(serverId, "bots") @@ -45,6 +98,46 @@ export function CompanionsCard({ serverId }: { serverId: string }) { const maxNum = Number(autoMax); const maxValid = autoMax.trim() !== "" && Number.isInteger(maxNum) && maxNum >= 0 && maxNum <= 5000; + // Polling population + const fetchPopulation = async () => { + const s = await api.status(serverId).catch(() => null); + if (!s) return; + const up = s.observed.world.state === "running"; + setRunning(up); + if (up) { + const p = await api.population(serverId, selectedRealm).catch(() => null); + setPop(p); + } else { + setPop(null); + } + }; + + useEffect(() => { + let alive = true; + const poll = async () => { + if (!alive) return; + await fetchPopulation(); + }; + void poll(); + const interval = setInterval(poll, job ? 1500 : 4000); + return () => { + alive = false; + clearInterval(interval); + }; + }, [serverId, selectedRealm, job !== null]); // eslint-disable-line react-hooks/exhaustive-deps + + const done = job && pop ? Math.max(0, Math.min(job.target, pop.bots_total - job.baseline)) : 0; + useEffect(() => { + if (!job) return; + if ((pop && done >= job.target) || !running) { + if (pop && done >= job.target) setNote(t("comp.progressDone", { n: job.target })); + setJob(null); + } + }, [done, running]); // eslint-disable-line react-hooks/exhaustive-deps + + const count = countChoice === "custom" ? Number(customCount) : Number(countChoice); + const valid = Number.isInteger(count) && count >= 1 && count <= 2000; + async function act(fn: () => Promise) { setBusy(true); setError(null); @@ -57,45 +150,113 @@ export function CompanionsCard({ serverId }: { serverId: string }) { } } + // Primary action: Spawn bots / fleet + const handleSpawn = async () => { + if (!valid) return; + setBusy(true); + setError(null); + setNote(null); + try { + if (spawnMode === "fleet") { + // Fleet spawn: race 1 (Human) or 2 (Orc) + const race = 1; + const out = await api.spawnBotFleet(serverId, race, targetLevel); + setNote(out || `Spawned level ${targetLevel} fleet`); + } else { + const r = await api.addCompanions(serverId, count, selectedRealm); + if (r.spawned && r.baseline !== null) { + setJob({ baseline: r.baseline, target: count, since: Date.now() }); + } + setNote( + r.created + ? t("comp.offlineDone", { n: r.created }) + : r.spawned + ? t("comp.spawned", { n: count }) + : t("comp.saved", { n: count }) + ); + } + await fetchPopulation(); + } catch (e) { + setError(asUiError(e)); + } finally { + setBusy(false); + } + }; + + // Bring offline bots online + const handleLoginAll = () => + act(async () => { + const out = await api.loginAllCompanions(serverId); + await fetchPopulation(); + return out || "All available offline bots queued for login."; + }); + + // Stop queued creations const stopCreating = () => act(async () => { const r = await api.stopSpawning(serverId); setJob(null); + await fetchPopulation(); return t("comp.stopped", { n: r.count }); }); + // Take online bots offline const takeOffline = () => { if (!window.confirm(t("comp.offlineConfirm"))) return; void act(async () => { const r = await api.takeOffline(serverId); setJob(null); + await fetchPopulation(); return t("comp.offlineDone2", { n: r.count }); }); }; + // Despawn batch const dn = Number(despawnN); const dnValid = Number.isInteger(dn) && dn >= 1 && dn <= 5000; const despawnSome = () => act(async () => { const r = await api.despawnSome(serverId, dn); + await fetchPopulation(); return t("comp.despawnSomeDone", { n: r.count }); }); + // Delete all bots in realm const deleteAll = () => { if (!window.confirm(t("comp.deleteConfirm", { n: pop?.bots_total ?? 0 }))) return; void act(async () => { await api.deleteAllCompanions(serverId); setJob(null); + await fetchPopulation(); return t("comp.deleteDone"); }); }; + // Switch server realm + const switchServerRealm = async (mode: RealmMode) => { + if (!window.confirm(`Switch server active realm to ${mode.toUpperCase()} and restart?`)) return; + setBusy(true); + try { + await api.realmSelect(serverId, mode, true); + await loadProfiles(); + setSelectedRealm(mode); + } catch (e) { + setError(asUiError(e)); + } finally { + setBusy(false); + } + }; + + // Save auto startup settings async function saveStart() { setStartBusy(true); setError(null); setStartNote(null); try { - await api.save(serverId, "bots", { "CoaBots.AutoLoginOnStartup": autoOn, "CoaBots.AutoLogin.MaxCount": maxNum }); + await api.save(serverId, "bots", { + "CoaBots.AutoLoginOnStartup": autoOn, + "CoaBots.AutoLogin.MaxCount": maxNum, + }); setStartNote(t("comp.startSaved")); } catch (e) { setError(asUiError(e)); @@ -104,188 +265,507 @@ export function CompanionsCard({ serverId }: { serverId: string }) { } } - useEffect(() => { - void api.companionSizes().then(setSizes); - let alive = true; - const poll = async () => { - const s = await api.status(serverId).catch(() => null); - if (!alive || !s) return; - const up = s.observed.world.state === "running"; - setRunning(up); - setPop(up ? await api.population(serverId).catch(() => null) : null); - }; - void poll(); - // Faster while companions are being created, so the progress bar moves. - const t = setInterval(poll, job ? 1500 : 4000); - return () => { - alive = false; - clearInterval(t); - }; - }, [serverId, job !== null]); // eslint-disable-line react-hooks/exhaustive-deps + // Database schema name for selected realm + const dbSchema = + selectedRealm === "coa" + ? "acore_characters" + : selectedRealm === "wildcard" + ? "acore_characters_wildcard" + : "acore_characters_reborn"; - const done = job && pop ? Math.max(0, Math.min(job.target, pop.bots_total - job.baseline)) : 0; - useEffect(() => { - if (!job) return; - // Finished, or the server went away, or nothing new appeared for two minutes. - if ((pop && done >= job.target) || !running) { - if (pop && done >= job.target) setNote(t("comp.progressDone", { n: job.target })); - setJob(null); - } - }, [done, running]); // eslint-disable-line react-hooks/exhaustive-deps - - // The backend describes a size's warning in English; show the translated text for the two known kinds. - const warningFor = (size?: { bots: number; warning: string | null }) => { - if (!size?.warning) return null; - return size.warning.startsWith(`${size.bots} companions`) - ? t("comp.warnHardware", { n: size.bots, cores: sizes?.hardware.cores ?? "?", ram: sizes ? sizes.hardware.ram_gb.toFixed(0) : "?" }) - : t("comp.warnMemory"); - }; - const count = choice === "custom" ? Number(custom) : (sizes?.sizes.find((s) => s.id === choice)?.bots ?? 0); - const warning = choice === "custom" ? (count > 500 ? t("comp.largeWarning") : null) : (warningFor(sizes?.sizes.find((s) => s.id === choice))); - const valid = Number.isInteger(count) && count >= 1 && count <= 2000; - - async function add() { - setBusy(true); - setError(null); - setNote(null); - try { - const r = await api.addCompanions(serverId, count); - if (r.spawned && r.baseline !== null) setJob({ baseline: r.baseline, target: count, since: Date.now() }); - setNote(r.created ? t("comp.offlineDone", { n: r.created }) : r.spawned ? t("comp.spawned", { n: count }) : t("comp.saved", { n: count })); - } catch (e) { - setError(asUiError(e)); - } finally { - setBusy(false); - } - } + // Binary compatibility check + const isBinaryCompatible = + selectedRealm === "coa" || + (selectedRealm === "wildcard" && (profiles?.supported ?? true)) || + (selectedRealm === "reborn" && (profiles?.reborn_supported ?? true)); return ( - -
-

{t("comp.title")}

- {realmMode && ( - - {realmMode === "coa" ? "Conquest of Azeroth" : realmMode === "wildcard" ? "Wildcard" : "Warcraft Reborn"} - - )} -
-

- {pop - ? t("comp.population", { online: pop.bots_online, total: pop.bots_total }) - : running - ? t("comp.reading") - : t("comp.startToSee")} -

- -
- {t("comp.wantMore")} -
- {sizes?.sizes.map((s) => ( - - ))} - - {choice === "custom" && ( - setCustom(e.target.value)} inputMode="numeric" className="w-24 rounded-md border border-line bg-bg px-3 py-2 text-right text-sm outline-none focus:border-gold" /> - )} + + {/* Header & Realm Switcher */} +
+
+
+ +

{t("comp.title")}

+
+
+ + + DB: {dbSchema} + +
-
- {warning &&

{warning}

} - {sizes &&

{t("comp.hardware", { cores: sizes.hardware.cores, ram: sizes.hardware.ram_gb.toFixed(0) })}

} - -
- - {!running && {t("comp.stoppedHint")}} + {/* Realm Navigation Tabs */} +
+ {(["coa", "wildcard", "reborn"] as RealmMode[]).map((mode) => { + const isSelected = selectedRealm === mode; + const isActive = activeServerRealm === mode; + const label = + mode === "coa" + ? "Conquest of Azeroth" + : mode === "wildcard" + ? "Wildcard (Hero)" + : "Warcraft Reborn"; + return ( + + ); + })} +
- {busy && !running && count >= 100 && ( -
-

{t("comp.progressOffline", { n: count })}

-
-
+ + {/* Binary Compatibility & Realm Mode Warning */} + {!isBinaryCompatible && ( +
+ +
+
Binary Compatibility Warning
+

+ The currently running server core binary does not have full multi-realm support enabled + for {selectedRealm.toUpperCase()}. Automated bot features may be restricted. +

)} - {job && ( -
-
- {t("comp.progress", { done, target: job.target })} - {Math.round((done / job.target) * 100)}% -
-
-
-
- {done === 0 && Date.now() - job.since > 20000 &&

{t("comp.progressStalled")}

} -
)} - {note && !job &&

{note}

} - {running && pop && pop.bots_total > 0 && ( -
-

{t("comp.manageTitle")}

-
- + {/* Realm Population Dashboard */} +
+
+
+ + Bots Online +
+
+ {running && pop ? pop.bots_online : "—"} +
+
+
+
+ + Total Fleet +
+
+ {pop ? pop.bots_total : "—"} +
+
+
+
+ + Players Online +
+
+ {running && pop ? pop.players_online : "—"} +
+
+
+
+ Status + +
+
+ {running ? ( + + Running + + ) : ( + Offline + )} +
+
+
+ + {/* Fleet Configuration & Spawning Section */} +
+
+

+ Fleet Deployment Controls +

+
+ + +
+
+ + {/* Count Selector */} + {spawnMode === "population" && ( +
+ +
+ {["10", "25", "50", "100", "250", "500"].map((n) => ( + + ))} + + {countChoice === "custom" && ( + setCustomCount(e.target.value)} + className="w-24 rounded-md border border-line bg-bg px-3 py-1.5 text-right text-sm outline-none focus:border-gold" + /> + )} +
+
+ )} + + {/* Fleet Specific Settings: Level, Class, Role, Archetype */} +
+ {/* Target Level */} +
+ + setTargetLevel(Math.max(1, Math.min(maxLevelForRealm, Number(e.target.value))))} + className="mt-1.5 w-full rounded-md border border-line bg-bg px-3 py-1.5 text-sm outline-none focus:border-gold" + /> +
+ + {/* Class Filter */} +
+ + +
+ + {/* Role Filter */} +
+ + +
+ + {/* Archetype Filter */} +
+ + +
+
+ + {/* Deployment Actions */} +
+ + + {running && pop && pop.bots_total > pop.bots_online && ( + + )} + + {!running && ( + + Server stopped: bots will be created directly in DB or queued for startup. + + )} +
+ + {/* Progress Bar during active spawn */} + {job && ( +
+
+ + Creating bots: {done} / {job.target} + + + {Math.round((done / job.target) * 100)}% + +
+
+
+
+
+ + Paced creation prevents server stalls and guarantees full talent/gear setup. + + +
+
+ )} + + {note && !job && ( +

+ + {note} +

+ )} +
+ + {/* Online Fleet Management (Despawn, Purge) */} + {running && pop && pop.bots_total > 0 && isViewingActiveRealm && ( +
+

+ Online Fleet Management +

+ +
+ setDespawnN(e.target.value)} inputMode="numeric" aria-invalid={!dnValid} - className="w-20 rounded-md border border-line bg-bg px-3 py-1.5 text-right outline-none focus:border-gold" + className="w-20 rounded-md border border-line bg-bg px-2.5 py-1 text-right text-sm outline-none focus:border-gold" /> - -
-

{t("comp.despawnHint")}

-
+ -
)} -
-

{t("comp.startTitle")}

- -
- - setAutoMax(e.target.value)} - inputMode="numeric" - disabled={!autoOn} - aria-invalid={!maxValid} - className="w-24 rounded-md border border-line bg-bg px-3 py-1.5 text-right outline-none focus:border-gold disabled:opacity-40" - /> - + {/* Startup Auto-Login Configuration */} +
+

+ Persistent Startup Fleet +

+

+ Configure how many bots automatically log into the world whenever the server launches. +

+ +
+ + +
+ + setAutoMax(e.target.value)} + inputMode="numeric" + disabled={!autoOn} + aria-invalid={!maxValid} + className="w-24 rounded-md border border-line bg-bg px-2.5 py-1 text-right text-sm outline-none focus:border-gold disabled:opacity-40" + /> + +
- {autoOn && !maxValid && autoMax !== "" &&

{t("comp.startInvalid")}

} -

{t("comp.startHint")}

- {startNote &&

{startNote}

} + {startNote &&

{startNote}

}
- {error &&

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

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