diff --git a/crates/coa-core/examples/pop.rs b/crates/coa-core/examples/pop.rs new file mode 100644 index 0000000..d59b1e3 --- /dev/null +++ b/crates/coa-core/examples/pop.rs @@ -0,0 +1,10 @@ +fn main() { + let root = std::env::args().nth(1).expect("server folder"); + let root = std::path::Path::new(&root); + println!("{:?}", coa_core::population::query(root)); + let h = coa_core::population::hardware(); + println!("{h:?}"); + for s in coa_core::population::sizes(&h) { + println!("{} {} {:?}", s.title, s.bots, s.warning); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 9680217..799baa5 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod install; pub mod layout; pub mod logging; pub mod pkgsource; +pub mod population; pub mod process; pub mod manifest; pub mod migrations; diff --git a/crates/coa-core/src/population.rs b/crates/coa-core/src/population.rs new file mode 100644 index 0000000..aca46da --- /dev/null +++ b/crates/coa-core/src/population.rs @@ -0,0 +1,137 @@ +//! Who is online (real players vs bots) and what the machine can carry. + +use std::path::Path; + +use serde::Serialize; + +use crate::config::parser::{unquote, ConfFile}; +use crate::db::{Account, Db}; +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Serialize)] +pub struct Population { + pub online_total: u32, + pub bots_online: u32, + pub players_online: u32, + /// Bot characters that exist (online or not). + pub bots_total: u32, +} + +/// Bot accounts share a name prefix (`CoaBots.RandomSpawn.AccountPrefix`, default `CoaBotHost`). +pub fn bot_account_prefix(root: &Path) -> String { + std::fs::read(root.join("Core/configs/modules/mod_coa_playerbots.conf")) + .ok() + .and_then(|b| ConfFile::parse_bytes(&b).ok()) + .and_then(|c| c.get("CoaBots.RandomSpawn.AccountPrefix").map(|v| unquote(v).to_string())) + .filter(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or_else(|| "CoaBotHost".into()) +} + +fn parse_counts(out: &str) -> Result { + let f: Vec = out.split_whitespace().filter_map(|x| x.parse().ok()).collect(); + if f.len() != 3 { + return Err(Error::Invalid(format!("unexpected population reply: {out:?}"))); + } + let (online_total, bots_online, bots_total) = (f[0], f[1], f[2]); + Ok(Population { online_total, bots_online, players_online: online_total.saturating_sub(bots_online), bots_total }) +} + +/// Read-only query with the game's own database account. Requires the database to be running. +pub fn query(root: &Path) -> Result { + let prefix = bot_account_prefix(root).to_uppercase(); + let db = Db::from_repack(root, Account::App)?; + let sql = 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 UPPER(a.username) LIKE '{prefix}%'), \ + (SELECT COUNT(*) FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE UPPER(a.username) LIKE '{prefix}%');" + ); + parse_counts(&db.query(&sql)?) +} + +#[derive(Debug, Clone, Serialize)] +pub struct Hardware { + pub cores: u32, + pub ram_gb: f64, + pub free_ram_gb: f64, +} + +#[cfg(windows)] +pub fn hardware() -> Hardware { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + let mut st: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + st.dwLength = std::mem::size_of::() as u32; + let ok = unsafe { GlobalMemoryStatusEx(&mut st) } != 0; + let gb = |b: u64| b as f64 / (1u64 << 30) as f64; + Hardware { + cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), + ram_gb: if ok { gb(st.ullTotalPhys) } else { 8.0 }, + free_ram_gb: if ok { gb(st.ullAvailPhys) } else { 4.0 }, + } +} + +#[cfg(not(windows))] +pub fn hardware() -> Hardware { + Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: 8.0, free_ram_gb: 4.0 } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SizeOption { + pub id: &'static str, + pub title: &'static str, + pub bots: u32, + /// None = comfortable on this machine; Some = why to think twice. + pub warning: Option, +} + +/// Recommended sizes with a hardware-aware note. Nothing is forbidden: advanced users may exceed them. +pub fn sizes(h: &Hardware) -> Vec { + let need = |bots: u32, cores: u32, ram: f64| -> Option { + if h.cores < cores || h.ram_gb < ram { + Some(format!("{bots} companions may need more CPU and memory than this computer has ({} cores, {:.0} GB).", h.cores, h.ram_gb)) + } else if h.free_ram_gb < ram / 2.0 { + Some("Free some memory first; other programs are using most of it.".into()) + } else { + None + } + }; + vec![ + SizeOption { id: "small", title: "Small", bots: 50, warning: None }, + SizeOption { id: "medium", title: "Medium", bots: 250, warning: need(250, 4, 8.0) }, + SizeOption { id: "large", title: "Large", bots: 500, warning: need(500, 8, 16.0) }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_counts_and_never_goes_negative() { + let p = parse_counts("12\t9\t400").unwrap(); + assert_eq!((p.online_total, p.bots_online, p.players_online, p.bots_total), (12, 9, 3, 400)); + assert_eq!(parse_counts("5\t7\t7").unwrap().players_online, 0); + assert!(parse_counts("garbage").is_err()); + } + + #[test] + fn prefix_comes_from_the_bot_config_and_is_sanitised() { + let d = tempfile::tempdir().unwrap(); + assert_eq!(bot_account_prefix(d.path()), "CoaBotHost"); + let f = d.path().join("Core/configs/modules"); + std::fs::create_dir_all(&f).unwrap(); + std::fs::write(f.join("mod_coa_playerbots.conf"), "CoaBots.RandomSpawn.AccountPrefix = \"MyBots\"\n").unwrap(); + assert_eq!(bot_account_prefix(d.path()), "MyBots"); + std::fs::write(f.join("mod_coa_playerbots.conf"), "CoaBots.RandomSpawn.AccountPrefix = \"x' OR 1=1 --\"\n").unwrap(); + assert_eq!(bot_account_prefix(d.path()), "CoaBotHost", "anything that is not letters/digits is ignored (SQL safety)"); + } + + #[test] + fn sizes_warn_on_weak_hardware_only() { + let weak = sizes(&Hardware { cores: 2, ram_gb: 4.0, free_ram_gb: 2.0 }); + assert!(weak[0].warning.is_none() && weak[1].warning.is_some() && weak[2].warning.is_some()); + let strong = sizes(&Hardware { cores: 16, ram_gb: 32.0, free_ram_gb: 20.0 }); + assert!(strong.iter().all(|s| s.warning.is_none())); + let busy = sizes(&Hardware { cores: 16, ram_gb: 32.0, free_ram_gb: 2.0 }); + assert!(busy[2].warning.as_deref().unwrap().contains("memory")); + } +} diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index b20bbaf..9019e55 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -85,6 +85,15 @@ impl Ra { } } + /// Ask the bot module to create `count` leveling bots (throttled by the module itself). Only a number is sent. + pub fn spawn_bots(&mut self, count: u32) -> Result { + if !(1..=2000).contains(&count) { + return Err(Error::Invalid("Choose between 1 and 2000 companions.".into())); + } + let out = self.command(&format!("botcmd spawnleveled {count}"))?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + /// Give `name` administrator rights on all realms (GM level 3). pub fn make_administrator(&mut self, name: &str) -> Result<()> { validate_account(name, "placeholder")?; @@ -150,6 +159,16 @@ mod tests { assert_eq!(h.join().unwrap(), ["local", "secretra", "account create Player1 hunter22"]); } + #[test] + fn spawn_bots_sends_only_a_bounded_number() { + let (port, h) = fake_ra("Queued 50 bots"); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + assert_eq!(ra.spawn_bots(50).unwrap(), "Queued 50 bots"); + assert!(ra.spawn_bots(0).is_err() && ra.spawn_bots(5000).is_err()); + drop(ra); + assert_eq!(h.join().unwrap()[2], "botcmd spawnleveled 50"); + } + #[test] fn duplicate_account_and_input_validation() { let (port, _h) = fake_ra("Account already exist."); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ab7b59..4d5ed9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -463,6 +463,64 @@ async fn rollback_update(state: State<'_, AppState>, id: String, txn: String) -> .await } +#[tauri::command] +async fn get_population(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + Ok(tauri::async_runtime::spawn_blocking(move || { + let o = coa_core::process::observe(&root, &layout::read_ports(&root)); + if o.mysql.state != coa_core::process::ServiceState::Running { + return None; + } + coa_core::population::query(&root).ok() + }) + .await + .unwrap_or(None)) +} + +#[derive(Serialize)] +struct CompanionSizes { + hardware: coa_core::population::Hardware, + sizes: Vec, +} + +#[tauri::command] +fn companion_sizes() -> CompanionSizes { + let hardware = coa_core::population::hardware(); + let sizes = coa_core::population::sizes(&hardware); + CompanionSizes { hardware, sizes } +} + +#[derive(Serialize)] +struct CompanionsResult { + spawned: Option, +} + +/// Turn on automatic bot login for `count` bots and, if the server is running, ask it to create them. +#[tauri::command] +async fn add_companions(state: State<'_, AppState>, id: String, count: u32) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + if !(1..=2000).contains(&count) { + return Err(Error::Invalid("Choose between 1 and 2000 companions.".into())); + } + let meta = meta_dir(&root)?; + let mut changes = BTreeMap::new(); + changes.insert("CoaBots.AutoLoginOnStartup".to_string(), Value::Bool(true)); + changes.insert("CoaBots.AutoLogin.MaxCount".to_string(), Value::from(count)); + config::save(&root, &meta, Scope::Bots, &changes)?; + let o = coa_core::process::observe(&root, &layout::read_ports(&root)); + let spawned = if o.world.state == coa_core::process::ServiceState::Running { + Some(Ra::connect(&root)?.spawn_bots(count)?) + } else { + None + }; + tracing::info!(count, spawned = spawned.is_some(), "companions requested"); + Ok(CompanionsResult { spawned }) + }) + .await +} + pub fn run() { let dir = data_dir(); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); @@ -497,7 +555,10 @@ pub fn run() { check_update, pending_update, apply_update, - rollback_update + rollback_update, + get_population, + companion_sizes, + add_companions ]) .run(tauri::generate_context!()) .expect("error while running CoA Server Manager"); diff --git a/src/lib/api.ts b/src/lib/api.ts index 9a0053f..686eb87 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -197,6 +197,18 @@ export interface UpdateTxn { message: string | null; } +export interface Population { + online_total: number; + bots_online: number; + players_online: number; + bots_total: number; +} + +export interface CompanionSizes { + hardware: { cores: number; ram_gb: number; free_ram_gb: number }; + sizes: { id: string; title: string; bots: number; warning: string | null }[]; +} + export const api = { defaultInstallDir: () => invoke("default_install_dir"), scan: (path: string) => invoke("scan_server", { path }), @@ -225,6 +237,9 @@ export const api = { applyUpdate: (id: string, resolutions: Record, source?: string) => invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }), rollbackUpdate: (id: string, txn: string) => invoke("rollback_update", { id, txn }), + population: (id: string) => invoke("get_population", { id }), + companionSizes: () => invoke("companion_sizes"), + addCompanions: (id: string, count: number) => invoke<{ spawned: string | null }>("add_companions", { id, count }), previewPreset: (id: string, scope: Scope, preset: string) => invoke("preview_preset", { id, scope, preset }), }; diff --git a/src/screens/CompanionsCard.tsx b/src/screens/CompanionsCard.tsx new file mode 100644 index 0000000..bfad808 --- /dev/null +++ b/src/screens/CompanionsCard.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { api, asUiError, type CompanionSizes, type Population, type UiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export function CompanionsCard({ serverId }: { serverId: string }) { + const [sizes, setSizes] = useState(null); + const [pop, setPop] = useState(null); + const [running, setRunning] = useState(false); + const [choice, setChoice] = useState("small"); + const [custom, setCustom] = useState("100"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + + 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(); + const t = setInterval(poll, 4000); + return () => { + alive = false; + clearInterval(t); + }; + }, [serverId]); + + const count = choice === "custom" ? Number(custom) : (sizes?.sizes.find((s) => s.id === choice)?.bots ?? 0); + const warning = choice === "custom" ? (count > 500 ? "Large populations may need more CPU and memory. Increase gradually." : null) : (sizes?.sizes.find((s) => s.id === choice)?.warning ?? null); + 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); + setNote(r.spawned ? `Creating ${count} companions. They appear gradually over the next minutes.` : `Saved. ${count} companions will log in when the server starts.`); + } catch (e) { + setError(asUiError(e)); + } finally { + setBusy(false); + } + } + + return ( + +

AI companions

+

+ {pop + ? `${pop.bots_online} online now · ${pop.bots_total} created in total` + : running + ? "Reading the current population…" + : "Start the server to see how many companions are online."} +

+ +
+ Would you like more companions? +
+ {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" /> + )} +
+
+ + {warning &&

{warning}

} + {sizes &&

This computer: {sizes.hardware.cores} cores, {sizes.hardware.ram_gb.toFixed(0)} GB memory.

} + +
+ + {!running && The server is stopped: they will be created the next time it runs.} +
+ {note &&

{note}

} + {error &&

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

} +
+ ); +} diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index ac03d7f..a8973e9 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; -import { api, asUiError, type Human, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api"; +import { api, asUiError, type Human, type Population, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api"; import { cn, formatUptime } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -36,12 +36,17 @@ export function Overview({ server, onForget }: { server: ServerSummary; onForget const [action, setAction] = useState(null); const [failure, setFailure] = useState(null); const [showDetails, setShowDetails] = useState(false); + const [pop, setPop] = useState(null); const alive = useRef(true); const poll = useCallback(async () => { try { const s = await api.status(server.id); if (alive.current) setStatus(s); + if (s.observed.world.state === "running") { + const p = await api.population(server.id).catch(() => null); + if (alive.current) setPop(p); + } else if (alive.current) setPop(null); } catch { /* transient; next poll retries */ } @@ -139,7 +144,7 @@ export function Overview({ server, onForget }: { server: ServerSummary; onForget
Players online
-
—
+
{pop ? `${pop.players_online}` : "—"}{pop && pop.bots_online > 0 ? + {pop.bots_online} companions : null}
diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx index fd81603..f069c4a 100644 --- a/src/screens/SettingsPage.tsx +++ b/src/screens/SettingsPage.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { CompanionsCard } from "@/screens/CompanionsCard"; const RESTART_LABEL = { runtime: "Applies immediately", world: "Needs a world server restart", full: "Needs a full server restart" } as const; @@ -242,6 +243,8 @@ export function SettingsPage(props: { serverId: string; scope: Scope; title: str

{props.title}

{props.question}

+ {scope === "bots" && } + {view.drift_keys.length > 0 && (

{view.drift_keys.length} setting(s) were edited by hand