Phase 7a: real population (players vs companions), hardware-aware companion sizes, RA bot spawn, Bots page card

Verified: fixture received 'botcmd spawnleveled 12' over RA from the UI and the counts moved;
population query also read-only verified against the live server (33 companions online).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 09:35:24 +05:00
1 parent 981ee4d4d9
commit 9504e9133b
9 files changed
+351 -3

No files matched your search

+10
View File
@@ -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);
}
}
+1
View File
@@ -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;
+137
View File
@@ -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<Population> {
let f: Vec<u32> = 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<Population> {
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::<MEMORYSTATUSEX>() 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<String>,
}
/// Recommended sizes with a hardware-aware note. Nothing is forbidden: advanced users may exceed them.
pub fn sizes(h: &Hardware) -> Vec<SizeOption> {
let need = |bots: u32, cores: u32, ram: f64| -> Option<String> {
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"));
}
}
+19
View File
@@ -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<String> {
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.");
+62 -1
View File
@@ -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<Option<coa_core::population::Population>, 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<coa_core::population::SizeOption>,
}
#[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<String>,
}
/// 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<CompanionsResult, UiError> {
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");
+15
View File
@@ -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<string>("default_install_dir"),
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
@@ -225,6 +237,9 @@ export const api = {
applyUpdate: (id: string, resolutions: Record<string, "keep" | "replace">, source?: string) =>
invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }),
rollbackUpdate: (id: string, txn: string) => invoke<UpdateTxn>("rollback_update", { id, txn }),
population: (id: string) => invoke<Population | null>("get_population", { id }),
companionSizes: () => invoke<CompanionSizes>("companion_sizes"),
addCompanions: (id: string, count: number) => invoke<{ spawned: string | null }>("add_companions", { id, count }),
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
};
+97
View File
@@ -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<CompanionSizes | null>(null);
const [pop, setPop] = useState<Population | null>(null);
const [running, setRunning] = useState(false);
const [choice, setChoice] = useState<string>("small");
const [custom, setCustom] = useState("100");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<UiError | null>(null);
const [note, setNote] = useState<string | null>(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 (
<Card className="mt-6 p-6">
<h2 className="font-semibold">AI companions</h2>
<p className="mt-1 text-sm text-muted">
{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."}
</p>
<fieldset className="mt-4">
<legend className="text-sm text-muted">Would you like more companions?</legend>
<div className="mt-2 flex flex-wrap gap-2">
{sizes?.sizes.map((s) => (
<label key={s.id} className={`cursor-pointer rounded-md border px-3 py-2 text-sm ${choice === s.id ? "border-gold bg-gold/10" : "border-line hover:bg-white/5"}`}>
<input type="radio" name="size" className="sr-only" checked={choice === s.id} onChange={() => setChoice(s.id)} />
{s.title} <span className="text-muted">· {s.bots}</span>
</label>
))}
<label className={`cursor-pointer rounded-md border px-3 py-2 text-sm ${choice === "custom" ? "border-gold bg-gold/10" : "border-line hover:bg-white/5"}`}>
<input type="radio" name="size" className="sr-only" checked={choice === "custom"} onChange={() => setChoice("custom")} />
Custom
</label>
{choice === "custom" && (
<input aria-label="Number of companions" value={custom} onChange={(e) => 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" />
)}
</div>
</fieldset>
{warning && <p className="mt-3 text-sm text-warn" role="status">{warning}</p>}
{sizes && <p className="mt-2 text-xs text-muted">This computer: {sizes.hardware.cores} cores, {sizes.hardware.ram_gb.toFixed(0)} GB memory.</p>}
<div className="mt-4 flex items-center gap-3">
<Button variant="primary" disabled={busy || !valid} onClick={() => void add()}>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Add {valid ? count : ""} companions
</Button>
{!running && <span className="text-xs text-muted">The server is stopped: they will be created the next time it runs.</span>}
</div>
{note && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
{error && <p className="mt-3 text-sm text-bad" role="alert">{error.human.code === "unknown" ? error.technical : error.human.message}</p>}
</Card>
);
}
+7 -2
View File
@@ -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<Action>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [pop, setPop] = useState<Population | null>(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
</div>
<div>
<dt className="text-muted">Players online</dt>
<dd className="mt-0.5 text-lg text-muted">—</dd>
<dd className="mt-0.5 text-lg">{pop ? `${pop.players_online}` : "—"}{pop && pop.bots_online > 0 ? <span className="ml-2 text-sm text-muted">+ {pop.bots_online} companions</span> : null}</dd>
</div>
</dl>
+3
View File
@@ -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
<h1 className="text-2xl font-semibold">{props.title}</h1>
<p className="mt-1 text-muted">{props.question}</p>
{scope === "bots" && <CompanionsCard serverId={serverId} />}
{view.drift_keys.length > 0 && (
<Card className="mt-5 border-warn/40 p-4" role="status">
<p className="font-medium text-warn">{view.drift_keys.length} setting(s) were edited by hand</p>