mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-13 04:21:02 +00:00
feat(manager): expand multi-realm bot fleet controls, DB routing, and compatibility guards
This commit is contained in:
1 parent
b28aba2e3b
commit
3d0de60d84
7 files changed
+799
-222
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user