diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index 277f85a..4755335 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -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::>().join("; "))] Validation(Vec), } @@ -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, } } diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index d7a01b3..464abee 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -97,6 +97,30 @@ impl Ra { Ok(parse_server_info(&out)) } + fn bot_command(&mut self, cmd: &str) -> Result { + 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 { + 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 { + 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 { + 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 { 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc6f37d..6733d30 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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 { + 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 { + 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, 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, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9dc589c..77db165 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -549,6 +549,19 @@ const de: Partial> = { "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; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c1fd4bb..e552878 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 5078feb..b80b19f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -549,6 +549,19 @@ const es: Partial> = { "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; diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 0989617..144320f 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -549,6 +549,19 @@ const fr: Partial> = { "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; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9743a1a..c1b5a6f 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -549,6 +549,19 @@ const ru: Partial> = { "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; diff --git a/src/lib/api.ts b/src/lib/api.ts index aaf0d37..64ff23b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -289,6 +289,9 @@ export const api = { invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }), rollbackUpdate: (id: string, txn: string) => invoke("rollback_update", { id, txn }), population: (id: string) => invoke("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("open_link", { url }), performance: (id: string) => invoke("get_performance", { id }), companionSizes: () => invoke("companion_sizes"), diff --git a/src/screens/CompanionsCard.tsx b/src/screens/CompanionsCard.tsx index 935bdb1..57d3c03 100644 --- a/src/screens/CompanionsCard.tsx +++ b/src/screens/CompanionsCard.tsx @@ -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) { + 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 }) {
{done === 0 && Date.now() - job.since > 20000 &&

{t("comp.progressStalled")}

} + )} {note && !job &&

{note}

} + {running && pop && pop.bots_total > 0 && ( +
+

{t("comp.manageTitle")}

+
+ + +
+
+ )} +

{t("comp.startTitle")}