mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
1 parent
dd066f2094
commit
8686990a30
10 files changed
+222
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user