feat(manager): multi-realm character database routing, RA dispatch & extended bot manager UI

This commit is contained in:
Дмитрий committed 2026-11-07 10:19:13 +05:00
1 parent 3d0de60d84
commit 825aceffec
5 files changed
+1415 -92

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+38 -62
View File
@@ -1771,11 +1771,12 @@ async fn get_population(
async fn companions_login_all(
state: State<'_, AppState>,
id: String,
realm: Option<coa_core::realms::Mode>,
) -> 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)?;
let mut ra = Ra::connect_target(&root, realm)?;
ra.login_all()
})
.await
@@ -1787,11 +1788,12 @@ async fn companions_spawn_fleet(
id: String,
race: u8,
level: u8,
realm: Option<coa_core::realms::Mode>,
) -> 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)?;
let mut ra = Ra::connect_target(&root, realm)?;
ra.spawn_fleet(race, level)
})
.await
@@ -1807,11 +1809,12 @@ struct CompanionAction {
async fn companions_stop_spawning(
state: State<'_, AppState>,
id: String,
realm: Option<coa_core::realms::Mode>,
) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
blocking(move || {
Ok(CompanionAction {
count: Ra::connect(&root)?.cancel_spawning()?,
count: Ra::connect_target(&root, realm)?.cancel_spawning()?,
})
})
.await
@@ -1821,12 +1824,12 @@ async fn companions_stop_spawning(
async fn companions_take_offline(
state: State<'_, AppState>,
id: String,
realm: Option<coa_core::realms::Mode>,
) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let mut ra = Ra::connect(&root)?;
// Anything still waiting to be created must not come back right after.
let mut ra = Ra::connect_target(&root, realm)?;
let _ = ra.cancel_spawning();
Ok(CompanionAction {
count: ra.despawn_all()?,
@@ -1835,13 +1838,12 @@ async fn companions_take_offline(
.await
}
/// Log `count` randomly chosen online companions out to lower the load. Uses the per-bot command that every server
/// build has, so it works on older builds too.
#[tauri::command]
async fn companions_despawn_some(
state: State<'_, AppState>,
id: String,
count: u32,
realm: Option<coa_core::realms::Mode>,
) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
@@ -1849,13 +1851,18 @@ async fn companions_despawn_some(
if !(1..=5000).contains(&count) {
return Err(Error::Invalid("Choose a number between 1 and 5000.".into()));
}
let mode = realm.unwrap_or_else(|| {
coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default()
});
let target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?;
let prefix = coa_core::population::bot_account_prefix(&root).to_uppercase();
let db = coa_core::db::Db::from_repack(&root, coa_core::db::Account::App)?;
let rows = db.query(&format!(
"SELECT c.guid FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND UPPER(a.username) LIKE '{prefix}%' ORDER BY RAND() LIMIT {count};"
"SELECT c.guid FROM {}.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND UPPER(a.username) LIKE '{prefix}%' ORDER BY RAND() LIMIT {count};",
target.chars_db
))?;
let guids: Vec<u64> = rows.lines().filter_map(|l| l.trim().parse().ok()).collect();
let mut ra = Ra::connect(&root)?;
let mut ra = Ra::connect_target(&root, Some(mode))?;
let mut done = 0;
for g in guids {
if ra.despawn_bot(g).unwrap_or(false) {
@@ -1867,31 +1874,35 @@ async fn companions_despawn_some(
.await
}
/// Delete every companion for good. A recovery point of the characters and accounts is saved first.
#[tauri::command]
async fn companions_delete_all(
app: AppHandle,
state: State<'_, AppState>,
id: String,
realm: Option<coa_core::realms::Mode>,
) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let mode = realm.unwrap_or_else(|| {
coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default()
});
let target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?;
let meta = meta_dir(&root)?;
backup::create(
&root,
&meta,
Kind::Quick,
Trigger::BeforeDangerousChange,
Some("before deleting all companions".into()),
Some(format!("before deleting all {} companions", target.realm.label())),
&|step| {
let _ = app.emit("backup-progress", step);
},
)?;
let before = coa_core::population::query(&root)
let before = coa_core::population::query_for_realm(&root, mode)
.map(|p| p.bots_total)
.unwrap_or(0);
let mut ra = Ra::connect(&root)?;
let mut ra = Ra::connect_target(&root, Some(mode))?;
let _ = ra.cancel_spawning();
ra.purge_all()?;
Ok(CompanionAction { count: before })
@@ -1948,6 +1959,10 @@ async fn add_companions(
id: String,
count: u32,
realm: Option<coa_core::realms::Mode>,
level: Option<u8>,
class_id: Option<u8>,
spec_id: Option<u32>,
archetype: Option<String>,
) -> std::result::Result<CompanionsResult, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
@@ -1960,15 +1975,10 @@ async fn add_companions(
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 target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?;
let meta = meta_dir(&root)?;
let mut changes = BTreeMap::new();
changes.insert("CoaBots.AutoLoginOnStartup".to_string(), Value::Bool(true));
// Every companion, old and new, should come back at the next start, so the start-up limit only ever grows here.
let existing_max = config::load(&root, Scope::Bots)
.ok()
.and_then(|v| {
@@ -1986,56 +1996,22 @@ async fn add_companions(
Value::from(existing_max.max(already + count).min(5000)),
);
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 (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;
if started_db {
let out = driver::run(&root, Verb::StartMysql)?;
if !out.ok {
return Err(Error::Invalid("The database could not be started.".into()));
}
}
let made = (|| -> Result<()> {
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"),
count,
)?;
Ok(())
})();
if started_db {
let _ = driver::run(&root, Verb::StopAll);
}
made?;
tracing::info!(count, "companions created offline");
return Ok(CompanionsResult {
spawned: None,
created: Some(count),
baseline: None,
});
}
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.
let spawned = if target.running {
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);
let _ = coa_core::companions::ensure_templates_for_schema(&admin_db, target.chars_db);
}
baseline = coa_core::population::query_for_realm(&root, mode)
.ok()
.map(|p| p.bots_total);
Some(Ra::connect(&root)?.spawn_bots(count)?)
let mut ra = Ra::connect_target(&root, Some(mode))?;
if let Some(lvl) = level {
Some(ra.spawn_custom(count, lvl, class_id, spec_id, archetype.as_deref())?)
} else {
Some(ra.spawn_bots(count)?)
}
} else {
// The server is stopped: creating bots needs it running, so remember the request and do it after the next start.
coa_core::fsx::atomic_write_json(
&meta.join("companions.pending.json"),
&serde_json::json!({ "count": count }),
+24 -8
View File
@@ -813,17 +813,33 @@ export const api = {
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, 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 }),
stopSpawning: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_stop_spawning", { id, realm: realm ?? null }),
takeOffline: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_take_offline", { id, realm: realm ?? null }),
despawnSome: (id: string, count: number, realm?: RealmMode) => invoke<{ count: number }>("companions_despawn_some", { id, count, realm: realm ?? null }),
deleteAllCompanions: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_delete_all", { id, realm: realm ?? null }),
loginAllCompanions: (id: string, realm?: RealmMode) => invoke<string>("companions_login_all", { id, realm: realm ?? null }),
spawnBotFleet: (id: string, race: number, level: number, realm?: RealmMode) => invoke<string>("companions_spawn_fleet", { id, race, level, realm: realm ?? null }),
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, realm?: RealmMode) =>
invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", { id, count, realm: realm ?? null }),
addCompanions: (
id: string,
count: number,
realm?: RealmMode,
level?: number,
classId?: number,
specId?: number,
archetype?: string
) =>
invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", {
id,
count,
realm: realm ?? null,
level: level ?? null,
class_id: classId ?? null,
spec_id: specId ?? null,
archetype: archetype ?? 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 }),
+48 -22
View File
@@ -55,8 +55,9 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const activeServerRealm = profiles?.active ?? "coa";
const isViewingActiveRealm = selectedRealm === activeServerRealm;
const isRealmOperational = isViewingActiveRealm || running;
const maxLevelForRealm = selectedRealm === "reborn" ? 60 : 80;
const maxLevelForRealm = 60;
// Load realm profiles
const loadProfiles = async () => {
@@ -158,12 +159,27 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
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);
const out = await api.spawnBotFleet(serverId, race, targetLevel, selectedRealm);
setNote(out || `Spawned level ${targetLevel} fleet`);
} else {
const r = await api.addCompanions(serverId, count, selectedRealm);
const classNum = selectedClass !== "all" ? parseInt(selectedClass, 10) : undefined;
let specNum: number | undefined = undefined;
if (selectedRole === "tank") {
if (selectedRealm === "reborn") {
specNum = (classNum === 7 || classNum === 9 || classNum === 11) ? 4 : (classNum === 2 ? 2 : classNum === 1 ? 3 : 1);
} else if (selectedRealm === "wildcard") {
specNum = 1;
}
} else if (selectedRole === "healer") {
if (selectedRealm === "reborn") {
specNum = (classNum === 2 || classNum === 5) ? 1 : (classNum === 7 || classNum === 11 ? 3 : 1);
} else if (selectedRealm === "wildcard") {
specNum = 2;
}
}
const archStr = selectedArchetype !== "all" ? selectedArchetype : undefined;
const r = await api.addCompanions(serverId, count, selectedRealm, targetLevel, classNum, specNum, archStr);
if (r.spawned && r.baseline !== null) {
setJob({ baseline: r.baseline, target: count, since: Date.now() });
}
@@ -186,7 +202,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
// Bring offline bots online
const handleLoginAll = () =>
act(async () => {
const out = await api.loginAllCompanions(serverId);
const out = await api.loginAllCompanions(serverId, selectedRealm);
await fetchPopulation();
return out || "All available offline bots queued for login.";
});
@@ -194,7 +210,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
// Stop queued creations
const stopCreating = () =>
act(async () => {
const r = await api.stopSpawning(serverId);
const r = await api.stopSpawning(serverId, selectedRealm);
setJob(null);
await fetchPopulation();
return t("comp.stopped", { n: r.count });
@@ -204,7 +220,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const takeOffline = () => {
if (!window.confirm(t("comp.offlineConfirm"))) return;
void act(async () => {
const r = await api.takeOffline(serverId);
const r = await api.takeOffline(serverId, selectedRealm);
setJob(null);
await fetchPopulation();
return t("comp.offlineDone2", { n: r.count });
@@ -216,7 +232,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const dnValid = Number.isInteger(dn) && dn >= 1 && dn <= 5000;
const despawnSome = () =>
act(async () => {
const r = await api.despawnSome(serverId, dn);
const r = await api.despawnSome(serverId, dn, selectedRealm);
await fetchPopulation();
return t("comp.despawnSomeDone", { n: r.count });
});
@@ -225,7 +241,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const deleteAll = () => {
if (!window.confirm(t("comp.deleteConfirm", { n: pop?.bots_total ?? 0 }))) return;
void act(async () => {
await api.deleteAllCompanions(serverId);
await api.deleteAllCompanions(serverId, selectedRealm);
setJob(null);
await fetchPopulation();
return t("comp.deleteDone");
@@ -514,17 +530,27 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
<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>
<option value="12">Barbarian</option>
<option value="13">Witch Doctor</option>
<option value="14">Felsworn</option>
<option value="15">Witch Hunter</option>
<option value="16">Stormbringer</option>
<option value="17">Knight of Xoroth</option>
<option value="18">Guardian</option>
<option value="19">Templar</option>
<option value="20">Bloodmage</option>
<option value="21">Ranger</option>
<option value="22">Chronomancer</option>
<option value="23">Necromancer</option>
<option value="24">Pyromancer</option>
<option value="25">Cultist</option>
<option value="26">Starcaller</option>
<option value="27">Tinker</option>
<option value="28">Venomancer</option>
<option value="29">Reaper</option>
<option value="30">Sun Cleric</option>
<option value="31">Runemaster</option>
<option value="32">Primalist</option>
</>
)}
{selectedRealm === "wildcard" && (
@@ -605,7 +631,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button
variant="primary"
disabled={busy || !valid || job !== null || !isViewingActiveRealm}
disabled={busy || !valid || job !== null || !isRealmOperational}
onClick={() => void handleSpawn()}
>
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
@@ -617,7 +643,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
{running && pop && pop.bots_total > pop.bots_online && (
<Button
variant="secondary"
disabled={busy || job !== null || !isViewingActiveRealm}
disabled={busy || job !== null || !isRealmOperational}
onClick={() => void handleLoginAll()}
title="Bring all created bots online"
>