Companions: stop creating, take all offline, delete all (with a recovery point); bot module gets spawncancel and despawnall

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 23:28:58 +05:00
1 parent dd066f2094
commit 8686990a30
10 files changed
+222

No files matched your search

+9
View File
@@ -26,6 +26,8 @@ pub enum Error {
NetworkUnreachable(String),
#[error("the bot module found no level 80 template characters")]
CompanionTemplatesMissing,
#[error("this server's bot module does not have that command yet")]
CompanionCommandUnsupported,
#[error("invalid values: {}", .0.iter().map(|f| format!("{}: {}", f.key, f.message)).collect::<Vec<_>>().join("; "))]
Validation(Vec<FieldError>),
}
@@ -55,6 +57,7 @@ pub enum ErrorCode {
PackageNotPublished,
NetworkUnreachable,
CompanionTemplatesMissing,
CompanionCommandUnsupported,
Unknown,
}
@@ -150,6 +153,11 @@ impl ErrorCode {
"New companions are copied from level 80 characters of each class. Create one level 80 character per class on any account except the console account, then try again.",
&[ShowDetails],
),
ErrorCode::CompanionCommandUnsupported => (
"This server version can not do that yet",
"The bot module of this server build has no such command. Update the server, or restart it to stop what is running.",
&[ShowDetails],
),
ErrorCode::Unknown => ("Something went wrong", "See the technical details.", &[ShowDetails]),
};
Human { code: self, title, message, actions }
@@ -166,6 +174,7 @@ impl Error {
Error::PackageNotPublished(_) => ErrorCode::PackageNotPublished,
Error::NetworkUnreachable(_) => ErrorCode::NetworkUnreachable,
Error::CompanionTemplatesMissing => ErrorCode::CompanionTemplatesMissing,
Error::CompanionCommandUnsupported => ErrorCode::CompanionCommandUnsupported,
_ => ErrorCode::Unknown,
}
}
+44
View File
@@ -97,6 +97,30 @@ impl Ra {
Ok(parse_server_info(&out))
}
fn bot_command(&mut self, cmd: &str) -> Result<String> {
let out = self.command(cmd)?;
if is_unsupported_command(&out) {
return Err(Error::CompanionCommandUnsupported);
}
Ok(out)
}
/// Drop every bot still waiting to be created. Returns how many were waiting.
pub fn cancel_spawning(&mut self) -> Result<u32> {
Ok(first_number(&self.bot_command("botcmd spawncancel")?))
}
/// Log every bot out without deleting anything. Returns how many were online.
pub fn despawn_all(&mut self) -> Result<u32> {
Ok(first_number(&self.bot_command("botcmd despawnall")?))
}
/// Log every bot out and delete every bot character for good (the caller saves a recovery point first).
pub fn purge_all(&mut self) -> Result<String> {
let out = self.bot_command("botcmd purgeall")?;
Ok(out.lines().last().unwrap_or("").to_string())
}
/// Ask the bot module to create `count` leveling bots (throttled by the module itself). Only a number is sent.
pub fn spawn_bots(&mut self, count: u32) -> Result<String> {
if !(1..=2000).contains(&count) {
@@ -121,6 +145,16 @@ impl Ra {
}
}
/// The console answers an unknown sub-command with the list of the ones it knows.
fn is_unsupported_command(out: &str) -> bool {
let l = out.to_lowercase();
l.contains("possible subcommands") || l.contains("### usage") || l.contains("no such command") || l.contains("unknown command")
}
fn first_number(text: &str) -> u32 {
text.split(|c: char| !c.is_ascii_digit()).find(|t| !t.is_empty()).and_then(|t| t.parse().ok()).unwrap_or(0)
}
/// How fast the world loop is running, read from `server info`.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Performance {
@@ -213,6 +247,16 @@ mod tests {
(port, h)
}
#[test]
fn an_unknown_bot_subcommand_is_recognised_and_numbers_are_read_from_replies() {
assert!(is_unsupported_command("### USAGE: .botcmd ...
Possible subcommands:
|- botcmd despawn"));
assert!(!is_unsupported_command("BotMgr: cancelled 35 queued bot spawn(s)."));
assert_eq!(first_number("BotMgr: cancelled 35 queued bot spawn(s)."), 35);
assert_eq!(first_number("nothing"), 0);
}
#[test]
fn server_info_timing_is_parsed_and_unrelated_text_is_ignored() {
let text = "AzerothCore rev. x
+47
View File
@@ -514,6 +514,50 @@ async fn get_population(state: State<'_, AppState>, id: String) -> std::result::
.unwrap_or(None))
}
#[derive(Serialize)]
struct CompanionAction {
/// Bots affected (cancelled from the queue, or logged out).
count: u32,
}
#[tauri::command]
async fn companions_stop_spawning(state: State<'_, AppState>, id: String) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
blocking(move || Ok(CompanionAction { count: Ra::connect(&root)?.cancel_spawning()? })).await
}
#[tauri::command]
async fn companions_take_offline(state: State<'_, AppState>, id: String) -> 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 _ = ra.cancel_spawning();
Ok(CompanionAction { count: ra.despawn_all()? })
})
.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) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let meta = meta_dir(&root)?;
backup::create(&root, &meta, Kind::Quick, Trigger::BeforeDangerousChange, Some("before deleting all companions".into()), &|step| {
let _ = app.emit("backup-progress", step);
})?;
let before = coa_core::population::query(&root).map(|p| p.bots_total).unwrap_or(0);
let mut ra = Ra::connect(&root)?;
let _ = ra.cancel_spawning();
ra.purge_all()?;
Ok(CompanionAction { count: before })
})
.await
}
#[tauri::command]
async fn get_performance(state: State<'_, AppState>, id: String) -> std::result::Result<Option<coa_core::ra::Performance>, UiError> {
let root = path_of(&state, &id)?;
@@ -929,6 +973,9 @@ pub fn run() {
rollback_update,
get_population,
get_performance,
companions_stop_spawning,
companions_take_offline,
companions_delete_all,
open_link,
companion_sizes,
add_companions,
+13
View File
@@ -549,6 +549,19 @@ const de: Partial<Record<keyof typeof en, string>> = {
"comp.progressDone": "Alle {n} Begleiter sind erstellt. Sie melden sich jetzt an.",
"comp.progressOffline": "{n} Begleiter werden samt Ausrüstung erstellt … das dauert einige Minuten.",
"comp.progressStalled": "Warte darauf, dass der Server weitere erstellt …",
// Companions control
"comp.stop": "Erstellung stoppen",
"comp.stopped": "Gestoppt. {n} noch wartende Begleiter wurden abgebrochen; bereits erstellte bleiben erhalten.",
"comp.manageTitle": "Vorhandene Begleiter verwalten",
"comp.offline": "Alle offline nehmen",
"comp.offlineConfirm": "Alle Begleiter aus dem Spiel abmelden? Sie bleiben gespeichert und kommen beim nächsten Serverstart oder beim erneuten Hinzufügen zurück.",
"comp.offlineDone2": "{n} Begleiter offline genommen. Sie bleiben gespeichert.",
"comp.delete": "Alle Begleiter löschen …",
"comp.deleteConfirm": "Alle {n} Begleiter endgültig löschen? Zuvor wird ein Wiederherstellungspunkt gespeichert. Deine eigenen Charaktere bleiben unberührt.",
"comp.deleteDone": "Alle Begleiter wurden gelöscht. Zuvor wurde ein Wiederherstellungspunkt gespeichert.",
"err.companion_command_unsupported.title": "Diese Serverversion kann das noch nicht",
"err.companion_command_unsupported.message": "Das Bot-Modul dieses Server-Builds kennt diesen Befehl nicht. Aktualisiere den Server oder starte ihn neu, um Laufendes zu stoppen.",
};
export default de;
+13
View File
@@ -549,6 +549,19 @@ const en = {
"comp.progressDone": "All {n} companions are created. They are logging in now.",
"comp.progressOffline": "Creating {n} companions with their equipment… this takes a few minutes.",
"comp.progressStalled": "Waiting for the server to create more…",
// Companions control
"comp.stop": "Stop creating",
"comp.stopped": "Stopped. {n} companions that were still waiting are cancelled; the ones already created stay.",
"comp.manageTitle": "Manage existing companions",
"comp.offline": "Take all offline",
"comp.offlineConfirm": "Log all companions out of the game? They stay saved and come back at the next server start or when you add them again.",
"comp.offlineDone2": "{n} companions taken offline. They stay saved.",
"comp.delete": "Delete all companions…",
"comp.deleteConfirm": "Delete all {n} companions for good? A recovery point is saved first. Your own characters are not touched.",
"comp.deleteDone": "All companions were deleted. A recovery point was saved first.",
"err.companion_command_unsupported.title": "This server version can not do that yet",
"err.companion_command_unsupported.message": "The bot module of this server build has no such command. Update the server, or restart it to stop what is running.",
} as const;
export default en;
+13
View File
@@ -549,6 +549,19 @@ const es: Partial<Record<keyof typeof en, string>> = {
"comp.progressDone": "Se crearon los {n} compañeros. Están iniciando sesión.",
"comp.progressOffline": "Creando {n} compañeros con su equipo… esto tarda unos minutos.",
"comp.progressStalled": "Esperando a que el servidor cree más…",
// Companions control
"comp.stop": "Detener la creación",
"comp.stopped": "Detenido. Se cancelaron {n} compañeros que seguían en espera; los ya creados se quedan.",
"comp.manageTitle": "Gestionar los compañeros existentes",
"comp.offline": "Desconectar a todos",
"comp.offlineConfirm": "¿Desconectar a todos los compañeros del juego? Quedan guardados y vuelven en el próximo inicio del servidor o cuando los añadas de nuevo.",
"comp.offlineDone2": "{n} compañeros desconectados. Quedan guardados.",
"comp.delete": "Eliminar a todos los compañeros…",
"comp.deleteConfirm": "¿Eliminar definitivamente los {n} compañeros? Antes se guarda un punto de recuperación. Tus propios personajes no se tocan.",
"comp.deleteDone": "Se eliminaron todos los compañeros. Antes se guardó un punto de recuperación.",
"err.companion_command_unsupported.title": "Esta versión del servidor aún no puede hacerlo",
"err.companion_command_unsupported.message": "El módulo de bots de esta versión del servidor no tiene ese comando. Actualiza el servidor o reinícialo para detener lo que esté en marcha.",
};
export default es;
+13
View File
@@ -549,6 +549,19 @@ const fr: Partial<Record<keyof typeof en, string>> = {
"comp.progressDone": "Les {n} compagnons sont créés. Ils se connectent maintenant.",
"comp.progressOffline": "Création de {n} compagnons avec leur équipement… cela prend quelques minutes.",
"comp.progressStalled": "En attente de la création des suivants par le serveur…",
// Companions control
"comp.stop": "Arrêter la création",
"comp.stopped": "Arrêté. {n} compagnons encore en attente sont annulés ; ceux déjà créés restent.",
"comp.manageTitle": "Gérer les compagnons existants",
"comp.offline": "Tout mettre hors ligne",
"comp.offlineConfirm": "Déconnecter tous les compagnons du jeu ? Ils restent enregistrés et reviennent au prochain démarrage du serveur ou quand vous les ajoutez de nouveau.",
"comp.offlineDone2": "{n} compagnons mis hors ligne. Ils restent enregistrés.",
"comp.delete": "Supprimer tous les compagnons…",
"comp.deleteConfirm": "Supprimer définitivement les {n} compagnons ? Un point de restauration est d’abord enregistré. Vos propres personnages ne sont pas touchés.",
"comp.deleteDone": "Tous les compagnons ont été supprimés. Un point de restauration a été enregistré d’abord.",
"err.companion_command_unsupported.title": "Cette version du serveur ne sait pas encore le faire",
"err.companion_command_unsupported.message": "Le module de bots de cette version du serveur n’a pas cette commande. Mettez le serveur à jour ou redémarrez-le pour arrêter ce qui est en cours.",
};
export default fr;
+13
View File
@@ -549,6 +549,19 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"comp.progressDone": "Все компаньоны ({n}) созданы. Они входят в игру.",
"comp.progressOffline": "Создаю компаньонов ({n}) со снаряжением… это займёт несколько минут.",
"comp.progressStalled": "Жду, пока сервер создаст остальных…",
// Companions control
"comp.stop": "Остановить создание",
"comp.stopped": "Остановлено. Ожидавшие очереди компаньоны ({n}) отменены; уже созданные остаются.",
"comp.manageTitle": "Управление существующими компаньонами",
"comp.offline": "Вывести всех из игры",
"comp.offlineConfirm": "Вывести всех компаньонов из игры? Они остаются сохранёнными и вернутся при следующем запуске сервера или когда вы их снова добавите.",
"comp.offlineDone2": "Из игры выведено компаньонов: {n}. Они остаются сохранёнными.",
"comp.delete": "Удалить всех компаньонов…",
"comp.deleteConfirm": "Удалить всех компаньонов ({n}) навсегда? Сначала сохраняется точка восстановления. Ваши собственные персонажи не затрагиваются.",
"comp.deleteDone": "Все компаньоны удалены. Сначала была сохранена точка восстановления.",
"err.companion_command_unsupported.title": "Эта версия сервера пока этого не умеет",
"err.companion_command_unsupported.message": "В модуле ботов этой сборки сервера нет такой команды. Обновите сервер или перезапустите его, чтобы остановить текущую работу.",
};
export default ru;
+3
View File
@@ -289,6 +289,9 @@ export const api = {
invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }),
rollbackUpdate: (id: string, txn: string) => invoke<UpdateTxn>("rollback_update", { id, txn }),
population: (id: string) => invoke<Population | null>("get_population", { id }),
stopSpawning: (id: string) => invoke<{ count: number }>("companions_stop_spawning", { id }),
takeOffline: (id: string) => invoke<{ count: number }>("companions_take_offline", { id }),
deleteAllCompanions: (id: string) => invoke<{ count: number }>("companions_delete_all", { id }),
openLink: (url: string) => invoke<void>("open_link", { url }),
performance: (id: string) => invoke<Performance | null>("get_performance", { id }),
companionSizes: () => invoke<CompanionSizes>("companion_sizes"),
+54
View File
@@ -39,6 +39,43 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const maxNum = Number(autoMax);
const maxValid = autoMax.trim() !== "" && Number.isInteger(maxNum) && maxNum >= 0 && maxNum <= 5000;
async function act(fn: () => Promise<string>) {
setBusy(true);
setError(null);
try {
setNote(await fn());
} catch (e) {
setError(asUiError(e));
} finally {
setBusy(false);
}
}
const stopCreating = () =>
act(async () => {
const r = await api.stopSpawning(serverId);
setJob(null);
return t("comp.stopped", { n: r.count });
});
const takeOffline = () => {
if (!window.confirm(t("comp.offlineConfirm"))) return;
void act(async () => {
const r = await api.takeOffline(serverId);
setJob(null);
return t("comp.offlineDone2", { n: r.count });
});
};
const deleteAll = () => {
if (!window.confirm(t("comp.deleteConfirm", { n: pop?.bots_total ?? 0 }))) return;
void act(async () => {
await api.deleteAllCompanions(serverId);
setJob(null);
return t("comp.deleteDone");
});
};
async function saveStart() {
setStartBusy(true);
setError(null);
@@ -166,10 +203,27 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
<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")}
</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 gap-2">
<Button size="sm" variant="secondary" disabled={busy} onClick={takeOffline}>
{t("comp.offline")}
</Button>
<Button size="sm" variant="ghost" className="text-bad hover:text-bad" disabled={busy} onClick={deleteAll}>
{t("comp.delete")}
</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">