feat(manager): expand multi-realm bot fleet controls, DB routing, and compatibility guards

This commit is contained in:
Дмитрий committed 2026-11-06 22:20:54 +05:00
1 parent b28aba2e3b
commit 3d0de60d84
7 files changed
+799 -222

No files matched your search

+16 -7
View File
@@ -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<u32> {
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<u32> {
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<bool> {
if template_count(db)? > 0 {
ensure_templates_for_schema(db, "acore_characters")
}
pub fn ensure_templates_for_schema(db: &Db, chars_db: &str) -> Result<bool> {
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)
}
+27 -10
View File
@@ -49,17 +49,19 @@ fn parse_counts(out: &str) -> Result<Population> {
})
}
/// 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<Population> {
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<String> {
counts_sql_for_db("acore_characters", prefix, squid_prefix)
}
pub fn counts_sql_for_db(chars_db: &str, prefix: &str, squid_prefix: &str) -> Result<String> {
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<String> {
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<Populatio
parse_counts(&db.query(&counts_sql(prefix, squid_prefix)?)?)
}
pub fn query_for_schema(db: &Db, chars_db: &str, prefix: &str, squid_prefix: &str) -> Result<Population> {
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<Population> {
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,
+12
View File
@@ -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 <race> <level>).
pub fn spawn_fleet(&mut self, race: u8, level: u8) -> Result<String> {
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<String> {
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)?;
+37 -27
View File
@@ -185,8 +185,21 @@ pub fn view(root: &Path) -> Result<View> {
}
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<Files> {
}
}
}
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<Files> {
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(),
+58 -12
View File
@@ -1749,6 +1749,7 @@ async fn retry_update_validation(
async fn get_population(
state: State<'_, AppState>,
id: String,
realm: Option<coa_core::realms::Mode>,
) -> std::result::Result<Option<coa_core::population::Population>, 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<String, UiError> {
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<String, UiError> {
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<coa_core::realms::Mode>,
) -> std::result::Result<CompanionsResult, UiError> {
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,
+5 -2
View File
@@ -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<UpdateTxn>("rollback_update", { id, txn }),
retryUpdateValidation: (id: string, txn: string) => invoke<UpdateTxn>("retry_update_validation", { id, txn }),
population: (id: string) => invoke<Population | null>("get_population", { id }),
population: (id: string, realm?: RealmMode) => invoke<Population | null>("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<string>("companions_login_all", { id }),
spawnBotFleet: (id: string, race: number, level: number) => invoke<string>("companions_spawn_fleet", { id, race, level }),
openLink: (url: string) => invoke<void>("open_link", { url }),
performance: (id: string) => invoke<Performance | null>("get_performance", { id }),
companionSizes: () => invoke<CompanionSizes>("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<ClientInfo | null>("client_info", { id }),
+644 -164
View File
@@ -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<CompanionSizes | null>(null);
const [realmMode, setRealmMode] = useState<string | null>(null);
// Realm & Environment state
const [profiles, setProfiles] = useState<RealmProfiles | null>(null);
const [selectedRealm, setSelectedRealm] = useState<RealmMode>("coa");
const [pop, setPop] = useState<Population | null>(null);
const [running, setRunning] = useState(false);
const [choice, setChoice] = useState<string>("small");
const [custom, setCustom] = useState("100");
// Fleet Configuration state
const [spawnMode, setSpawnMode] = useState<"population" | "fleet">("population");
const [countChoice, setCountChoice] = useState<string>("50");
const [customCount, setCustomCount] = useState<string>("50");
const [targetLevel, setTargetLevel] = useState<number>(80);
const [selectedClass, setSelectedClass] = useState<string>("all");
const [selectedRole, setSelectedRole] = useState<string>("all");
const [selectedArchetype, setSelectedArchetype] = useState<string>("all");
// Operational state
const [busy, setBusy] = useState(false);
const [error, setError] = useState<UiError | null>(null);
const [note, setNote] = useState<string | null>(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<string | null>(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<string>) {
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 (
<Card className="mt-6 p-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{t("comp.title")}</h2>
{realmMode && (
<span className="rounded bg-gold/15 px-2 py-0.5 text-xs font-medium text-gold uppercase tracking-wider">
{realmMode === "coa" ? "Conquest of Azeroth" : realmMode === "wildcard" ? "Wildcard" : "Warcraft Reborn"}
</span>
)}
</div>
<p className="mt-1 text-sm text-muted">
{pop
? t("comp.population", { online: pop.bots_online, total: pop.bots_total })
: running
? t("comp.reading")
: t("comp.startToSee")}
</p>
<fieldset className="mt-4">
<legend className="text-sm text-muted">{t("comp.wantMore")}</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)} />
{hasKey(`comp.size.${s.id}`) ? t(`comp.size.${s.id}` as Key) : 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")} />
{t("comp.custom")}
</label>
{choice === "custom" && (
<input aria-label={t("comp.numberLabel")} 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" />
)}
<Card className="mt-6 p-6 space-y-6">
{/* Header & Realm Switcher */}
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Bot className="h-6 w-6 text-gold" />
<h2 className="text-xl font-bold tracking-tight">{t("comp.title")}</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted flex items-center gap-1">
<Database className="h-3.5 w-3.5" />
DB: <code className="text-gold">{dbSchema}</code>
</span>
</div>
</div>
</fieldset>
{warning && <p className="mt-3 text-sm text-warn" role="status">{warning}</p>}
{sizes && <p className="mt-2 text-xs text-muted">{t("comp.hardware", { cores: sizes.hardware.cores, ram: sizes.hardware.ram_gb.toFixed(0) })}</p>}
<div className="mt-4 flex items-center gap-3">
<Button variant="primary" disabled={busy || !valid || job !== null} onClick={() => void add()}>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{valid ? t("comp.add", { n: count }) : t("comp.addPlain")}
</Button>
{!running && <span className="text-xs text-muted">{t("comp.stoppedHint")}</span>}
{/* Realm Navigation Tabs */}
<div className="mt-4 flex flex-wrap gap-2 border-b border-line pb-3">
{(["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 (
<button
key={mode}
type="button"
onClick={() => setSelectedRealm(mode)}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
isSelected
? "bg-gold/20 text-gold border border-gold/40 shadow-sm"
: "border border-line hover:bg-white/5 text-muted hover:text-white"
}`}
>
<span>{label}</span>
{isActive && (
<span className="rounded bg-ok/20 px-1.5 py-0.5 text-[10px] font-semibold text-ok uppercase">
Active Server
</span>
)}
</button>
);
})}
</div>
</div>
{busy && !running && count >= 100 && (
<div className="mt-3">
<p className="text-sm text-muted" role="status">{t("comp.progressOffline", { n: count })}</p>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10" role="progressbar" aria-busy="true">
<div className="h-full w-full animate-pulse rounded-full bg-gold/70" />
{/* Binary Compatibility & Realm Mode Warning */}
{!isBinaryCompatible && (
<div className="rounded-lg border border-bad/40 bg-bad/10 p-4 text-sm text-bad flex items-start gap-3">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<div>
<div className="font-semibold">Binary Compatibility Warning</div>
<p className="mt-1 text-xs text-bad/90">
The currently running server core binary does not have full multi-realm support enabled
for <strong>{selectedRealm.toUpperCase()}</strong>. Automated bot features may be restricted.
</p>
</div>
</div>
)}
{job && (
<div className="mt-3">
<div className="flex justify-between text-sm">
<span role="status">{t("comp.progress", { done, target: job.target })}</span>
<span className="text-muted">{Math.round((done / job.target) * 100)}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10" role="progressbar" aria-valuenow={done} aria-valuemin={0} aria-valuemax={job.target}>
<div className="h-full rounded-full bg-gold transition-[width] duration-500" style={{ width: `${(done / job.target) * 100}%` }} />
</div>
{done === 0 && Date.now() - job.since > 20000 && <p className="mt-1 text-xs text-muted">{t("comp.progressStalled")}</p>}
<Button className="mt-2" size="sm" variant="ghost" disabled={busy} onClick={() => void stopCreating()}>
{t("comp.stop")}
{/* Active Realm Switch Helper */}
{!isViewingActiveRealm && (
<div className="rounded-lg border border-warn/40 bg-warn/10 p-3 text-sm flex items-center justify-between gap-3">
<span className="text-warn text-xs">
Viewing <strong>{selectedRealm.toUpperCase()}</strong> bots, but the worldserver is currently running in{" "}
<strong>{activeServerRealm.toUpperCase()}</strong> mode.
</span>
<Button
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void switchServerRealm(selectedRealm)}
>
Switch Server to {selectedRealm.toUpperCase()}
</Button>
</div>
)}
{note && !job && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
{running && pop && pop.bots_total > 0 && (
<div className="mt-5 border-t border-line pt-4">
<h3 className="text-sm font-semibold">{t("comp.manageTitle")}</h3>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<label htmlFor="despawn-n" className="text-muted">{t("comp.despawnCount")}</label>
{/* Realm Population Dashboard */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-lg border border-line bg-bg/50 p-3">
<div className="text-xs text-muted flex items-center gap-1.5">
<Bot className="h-3.5 w-3.5 text-gold" />
Bots Online
</div>
<div className="mt-1 text-2xl font-bold text-white">
{running && pop ? pop.bots_online : "—"}
</div>
</div>
<div className="rounded-lg border border-line bg-bg/50 p-3">
<div className="text-xs text-muted flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-gold" />
Total Fleet
</div>
<div className="mt-1 text-2xl font-bold text-white">
{pop ? pop.bots_total : "—"}
</div>
</div>
<div className="rounded-lg border border-line bg-bg/50 p-3">
<div className="text-xs text-muted flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-ok" />
Players Online
</div>
<div className="mt-1 text-2xl font-bold text-white">
{running && pop ? pop.players_online : "—"}
</div>
</div>
<div className="rounded-lg border border-line bg-bg/50 p-3 flex flex-col justify-between">
<div className="text-xs text-muted flex items-center justify-between">
<span>Status</span>
<button
onClick={() => void fetchPopulation()}
title="Refresh"
className="text-muted hover:text-white"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
<div className="text-sm font-medium">
{running ? (
<span className="text-ok flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-ok animate-pulse" /> Running
</span>
) : (
<span className="text-muted">Offline</span>
)}
</div>
</div>
</div>
{/* Fleet Configuration & Spawning Section */}
<div className="border-t border-line pt-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm tracking-wide uppercase text-muted">
Fleet Deployment Controls
</h3>
<div className="flex rounded-md border border-line p-0.5 text-xs">
<button
type="button"
onClick={() => setSpawnMode("population")}
className={`px-2.5 py-1 rounded transition-colors ${
spawnMode === "population" ? "bg-gold/20 text-gold font-medium" : "text-muted hover:text-white"
}`}
>
Batch Population
</button>
<button
type="button"
onClick={() => setSpawnMode("fleet")}
className={`px-2.5 py-1 rounded transition-colors ${
spawnMode === "fleet" ? "bg-gold/20 text-gold font-medium" : "text-muted hover:text-white"
}`}
>
Synchronous Fleet
</button>
</div>
</div>
{/* Count Selector */}
{spawnMode === "population" && (
<div>
<label className="text-xs text-muted">Fleet Size (Number of Bots)</label>
<div className="mt-2 flex flex-wrap gap-2">
{["10", "25", "50", "100", "250", "500"].map((n) => (
<button
key={n}
type="button"
onClick={() => setCountChoice(n)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
countChoice === n
? "border-gold bg-gold/15 text-gold font-medium"
: "border-line hover:bg-white/5 text-muted"
}`}
>
{n}
</button>
))}
<button
type="button"
onClick={() => setCountChoice("custom")}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
countChoice === "custom"
? "border-gold bg-gold/15 text-gold font-medium"
: "border-line hover:bg-white/5 text-muted"
}`}
>
Custom
</button>
{countChoice === "custom" && (
<input
type="number"
min="1"
max="2000"
value={customCount}
onChange={(e) => 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"
/>
)}
</div>
</div>
)}
{/* Fleet Specific Settings: Level, Class, Role, Archetype */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Target Level */}
<div>
<label className="text-xs text-muted">
{spawnMode === "fleet" ? "Fleet Level" : "Max Level Cap"} (1 - {maxLevelForRealm})
</label>
<input
type="number"
min="1"
max={maxLevelForRealm}
value={targetLevel}
onChange={(e) => 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"
/>
</div>
{/* Class Filter */}
<div>
<label className="text-xs text-muted">Class Focus</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(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"
>
<option value="all">All Realm Classes</option>
{selectedRealm === "coa" && (
<>
<option value="12">Guardian</option>
<option value="13">Starcaller</option>
<option value="14">Demon Hunter</option>
<option value="15">Necromancer</option>
<option value="16">Monk</option>
<option value="17">Pyromancer</option>
<option value="18">Chronomancer</option>
<option value="19">Cultist</option>
<option value="20">Runemaster</option>
<option value="21">Primalist</option>
<option value="22">Tinker</option>
</>
)}
{selectedRealm === "wildcard" && (
<option value="10">Hero (Freepick)</option>
)}
{selectedRealm === "reborn" && (
<>
<option value="1">Warrior</option>
<option value="2">Paladin</option>
<option value="3">Hunter</option>
<option value="4">Rogue</option>
<option value="5">Priest</option>
<option value="6">Death Knight</option>
<option value="7">Shaman</option>
<option value="8">Mage</option>
<option value="9">Warlock</option>
<option value="11">Druid</option>
</>
)}
</select>
</div>
{/* Role Filter */}
<div>
<label className="text-xs text-muted">Combat Role</label>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(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"
>
<option value="all">All Roles</option>
<option value="tank">Tank</option>
<option value="healer">Healer</option>
<option value="melee">Melee DPS</option>
<option value="ranged">Ranged Physical DPS</option>
<option value="caster">Caster DPS</option>
</select>
</div>
{/* Archetype Filter */}
<div>
<label className="text-xs text-muted">Archetype / Build</label>
<select
value={selectedArchetype}
onChange={(e) => setSelectedArchetype(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"
>
<option value="all">Auto / Integral Archetype</option>
{selectedRealm === "wildcard" && (
<>
<option value="bear">Bear Tank (Guardian)</option>
<option value="murlock">Mur-Lock (Unholy/Frost)</option>
<option value="firesage">Fire Sage (Fire/Ele)</option>
<option value="forestwarden">Forest Warden (Nature/Feral)</option>
<option value="bleedrogue">Bleed Rogue / Berserker</option>
<option value="holypal">Holy Paladin / Healer</option>
</>
)}
{selectedRealm === "reborn" && (
<>
<option value="spiritguard">Spirit Guard Shaman (Tank)</option>
<option value="darkapoth">Dark Apotheosis Warlock (Tank)</option>
<option value="guardianbear">Guardian Bear Druid (Tank)</option>
<option value="protpal">Protection Paladin (Tank)</option>
<option value="protwar">Protection Warrior (Tank)</option>
<option value="holypriest">Holy Priest (Healer)</option>
<option value="restodruid">Restoration Druid / Shaman</option>
</>
)}
{selectedRealm === "coa" && (
<option value="class_spec">Class Mastery Specialization</option>
)}
</select>
</div>
</div>
{/* Deployment Actions */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button
variant="primary"
disabled={busy || !valid || job !== null || !isViewingActiveRealm}
onClick={() => void handleSpawn()}
>
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{spawnMode === "fleet"
? `Spawn Level ${targetLevel} Fleet`
: `Deploy ${valid ? count : ""} Bots`}
</Button>
{running && pop && pop.bots_total > pop.bots_online && (
<Button
variant="secondary"
disabled={busy || job !== null || !isViewingActiveRealm}
onClick={() => void handleLoginAll()}
title="Bring all created bots online"
>
<Power className="h-4 w-4 mr-1.5 text-ok" />
Bring All Online ({pop.bots_total - pop.bots_online} idle)
</Button>
)}
{!running && (
<span className="text-xs text-muted">
Server stopped: bots will be created directly in DB or queued for startup.
</span>
)}
</div>
{/* Progress Bar during active spawn */}
{job && (
<div className="rounded-lg border border-line bg-bg/40 p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">
Creating bots: {done} / {job.target}
</span>
<span className="text-gold font-bold">
{Math.round((done / job.target) * 100)}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gold transition-[width] duration-300"
style={{ width: `${(done / job.target) * 100}%` }}
/>
</div>
<div className="flex justify-between items-center pt-1">
<span className="text-xs text-muted">
Paced creation prevents server stalls and guarantees full talent/gear setup.
</span>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void stopCreating()}>
Cancel Queue
</Button>
</div>
</div>
)}
{note && !job && (
<p className="text-sm text-ok flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{note}
</p>
)}
</div>
{/* Online Fleet Management (Despawn, Purge) */}
{running && pop && pop.bots_total > 0 && isViewingActiveRealm && (
<div className="border-t border-line pt-5 space-y-3">
<h3 className="font-semibold text-sm tracking-wide uppercase text-muted">
Online Fleet Management
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<label htmlFor="despawn-n" className="text-muted text-xs">
Despawn Batch:
</label>
<input
id="despawn-n"
value={despawnN}
onChange={(e) => 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"
/>
<Button size="sm" variant="secondary" disabled={busy || !dnValid} onClick={() => void despawnSome()}>
{t("comp.despawnSome", { n: dnValid ? dn : "…" })}
<Button
size="sm"
variant="secondary"
disabled={busy || !dnValid}
onClick={() => void despawnSome()}
>
Despawn {dnValid ? dn : "…"} Bots
</Button>
</div>
<p className="mt-1 text-xs text-muted">{t("comp.despawnHint")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="secondary" disabled={busy} onClick={takeOffline}>
{t("comp.offline")}
Take All Offline
</Button>
<Button size="sm" variant="ghost" className="text-bad hover:text-bad" disabled={busy} onClick={deleteAll}>
{t("comp.delete")}
<Button
size="sm"
variant="ghost"
className="text-bad hover:text-bad ml-auto"
disabled={busy}
onClick={deleteAll}
>
Purge Entire Fleet ({pop.bots_total})
</Button>
</div>
</div>
)}
<div className="mt-5 border-t border-line pt-4">
<h3 className="text-sm font-semibold">{t("comp.startTitle")}</h3>
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={autoOn} onChange={(e) => setAutoOn(e.target.checked)} />
{t("comp.startAuto")}
</label>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<label htmlFor="start-max" className="text-muted">{t("comp.startCount")}</label>
<input
id="start-max"
value={autoMax}
onChange={(e) => 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"
/>
<Button size="sm" variant="secondary" disabled={startBusy || (autoOn && !maxValid)} onClick={() => void saveStart()}>
{t("comp.startSave")}
</Button>
{/* Startup Auto-Login Configuration */}
<div className="border-t border-line pt-5 space-y-3">
<h3 className="font-semibold text-sm tracking-wide uppercase text-muted">
Persistent Startup Fleet
</h3>
<p className="text-xs text-muted">
Configure how many bots automatically log into the world whenever the server launches.
</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={autoOn}
onChange={(e) => setAutoOn(e.target.checked)}
className="rounded border-line text-gold focus:ring-gold"
/>
<span>Enable Auto-Login on Server Startup</span>
</label>
<div className="flex items-center gap-2">
<label htmlFor="start-max" className="text-xs text-muted">
Startup Limit:
</label>
<input
id="start-max"
value={autoMax}
onChange={(e) => 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"
/>
<Button
size="sm"
variant="secondary"
disabled={startBusy || (autoOn && !maxValid)}
onClick={() => void saveStart()}
>
Save Setting
</Button>
</div>
</div>
{autoOn && !maxValid && autoMax !== "" && <p className="mt-1 text-xs text-warn">{t("comp.startInvalid")}</p>}
<p className="mt-1 text-xs text-muted">{t("comp.startHint")}</p>
{startNote && <p className="mt-2 text-sm text-ok" role="status">{startNote}</p>}
{startNote && <p className="text-xs text-ok">{startNote}</p>}
</div>
{error && <p className="mt-3 text-sm text-bad" role="alert">{error.human.code === "unknown" ? error.technical : human(error.human).message}</p>}
{error && (
<div className="rounded-md border border-bad/40 bg-bad/10 p-3 text-sm text-bad flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error.human.code === "unknown" ? error.technical : human(error.human).message}</span>
</div>
)}
</Card>
);
}