Pausierte Matches zeigen ein Badge und den Knopf Fortsetzen. Laufende Matches zeigen Map, Stand und Runde. Der Pausenzustand wird gespeichert und bei Mapwechsel oder Serverstopp zurückgesetzt.
502 lines
23 KiB
JavaScript
502 lines
23 KiB
JavaScript
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||
import { randomPassword } from '../security.js';
|
||
|
||
export const SERVER_STATUS = {
|
||
starting: 'Server startet',
|
||
ready: 'Server bereit – wartet auf Veto',
|
||
configuring: 'Server wird konfiguriert',
|
||
configured: 'Server konfiguriert',
|
||
start_failed: 'Serverstart fehlgeschlagen',
|
||
config_failed: 'Konfiguration fehlgeschlagen',
|
||
failed: 'Server ausgefallen',
|
||
stopped: 'Server gestoppt',
|
||
};
|
||
|
||
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
||
const SLOW_POLL_MS = 30_000;
|
||
|
||
/**
|
||
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
|
||
* (start/status/configure/stop) und speichert alle Zugangsdaten ausschließlich in der Datenbank.
|
||
*/
|
||
export class ServerManager {
|
||
constructor({ db, service, adapter, config, logger = console }) {
|
||
Object.assign(this, { db, service, adapter, config, logger });
|
||
this.busy = new Set();
|
||
this.simulator = new DemoMatchSimulator();
|
||
this.autoAdvance = new Map();
|
||
this.lastPoll = new Map();
|
||
this.timers = [];
|
||
}
|
||
|
||
init() {
|
||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
||
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||
}
|
||
|
||
/**
|
||
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
|
||
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
|
||
*/
|
||
onMapEnded({ matchId, number, decided, hasNext }) {
|
||
const seconds = this.config.autoAdvanceSeconds;
|
||
if (seconds < 0) return;
|
||
clearTimeout(this.autoAdvance.get(matchId));
|
||
const finish = Boolean(decided) || !hasNext;
|
||
this.service.log(matchId, 'series', finish
|
||
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
|
||
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
|
||
this.service.changed(matchId);
|
||
this.autoAdvance.set(matchId, setTimeout(() => {
|
||
this.autoAdvance.delete(matchId);
|
||
const match = this.service.getMatch(matchId);
|
||
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
|
||
try {
|
||
if (finish) {
|
||
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
|
||
} else if (this.row(matchId)?.status === 'configured') {
|
||
this.service.advanceSeriesFor(matchId);
|
||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
|
||
}
|
||
}, seconds * 1000));
|
||
}
|
||
|
||
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
|
||
async resume() {
|
||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN ('starting','configuring')`).all();
|
||
for (const row of rows) {
|
||
if (row.status === 'starting' && !row.handle) {
|
||
this.update(row.match_id, { status: 'start_failed', message: 'Start wurde durch einen Neustart des Backends unterbrochen' });
|
||
} else if (row.status === 'configuring') {
|
||
this.update(row.match_id, { status: 'ready', message: 'Konfiguration wurde unterbrochen und wird wiederholt' });
|
||
this.maybeConfigure(row.match_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
startLoops() {
|
||
this.timers.push(setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000));
|
||
this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000));
|
||
}
|
||
|
||
/** Admin: Rücksprungpunkte der aktuellen Map, neueste Runde zuerst. */
|
||
async rollbackPoints(viewer) {
|
||
this.service.require(viewer, 'organizer');
|
||
const { match, seriesMap, handle } = this.rollbackContext(viewer.matchId);
|
||
const all = await this.adapter.listRoundBackups(handle, match.id, seriesMap.map);
|
||
return pointsForMap(all, seriesMap.map);
|
||
}
|
||
|
||
/** Admin: Match auf den Stand nach einer Runde zurückspielen. */
|
||
async rollback(viewer, file) {
|
||
this.service.require(viewer, 'organizer');
|
||
const { match, handle, seriesMap } = this.rollbackContext(viewer.matchId);
|
||
if (!isValidBackupFile(file, match.id)) throw new HttpError(400, 'Ungültige Sicherungsdatei', 'invalid_input');
|
||
const point = describeBackup(file, match.id);
|
||
if (point.map !== seriesMap.map) throw new HttpError(409, 'Die Sicherung gehört zu einer anderen Map', 'wrong_map');
|
||
if (this.busy.has(match.id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||
this.busy.add(match.id);
|
||
try {
|
||
await this.adapter.loadRoundBackup(handle, file);
|
||
} catch (err) {
|
||
throw new HttpError(502, `Zurückspielen fehlgeschlagen: ${err.message}`, 'server_error');
|
||
} finally {
|
||
this.busy.delete(match.id);
|
||
}
|
||
this.service.rewindLiveScore(match.id, point.round, file);
|
||
}
|
||
|
||
rollbackContext(matchId) {
|
||
const match = this.service.getMatch(matchId);
|
||
const row = this.row(matchId);
|
||
if (!row?.handle || row.status !== 'configured' || match.status !== 'live') {
|
||
throw new HttpError(409, 'Zurückspielen ist nur bei einem laufenden Match mit bereitem Server möglich', 'wrong_state');
|
||
}
|
||
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||
return { match, row, seriesMap, handle: JSON.parse(row.handle) };
|
||
}
|
||
|
||
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||
async deleteMatch(matchId) {
|
||
this.service.getMatch(matchId);
|
||
clearTimeout(this.autoAdvance.get(matchId));
|
||
this.autoAdvance.delete(matchId);
|
||
this.simulator.stop(matchId);
|
||
await this.stop(matchId, 'Match vom Admin gelöscht');
|
||
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||
this.service.emit('changed', matchId);
|
||
}
|
||
|
||
/** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */
|
||
async idle(timeoutMs = 5000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (this.busy.size > 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
|
||
}
|
||
|
||
stopLoops() {
|
||
this.simulator.stopAll();
|
||
for (const t of this.autoAdvance.values()) clearTimeout(t);
|
||
this.autoAdvance.clear();
|
||
this.timers.forEach(clearInterval);
|
||
this.timers = [];
|
||
}
|
||
|
||
row(matchId) {
|
||
return this.db.prepare('SELECT * FROM servers WHERE match_id = ?').get(matchId) ?? null;
|
||
}
|
||
|
||
update(matchId, fields) {
|
||
const entries = { ...fields, updated_at: Date.now() };
|
||
const sets = Object.keys(entries).map((k) => `${k} = @${k}`).join(', ');
|
||
this.db.prepare(`UPDATE servers SET ${sets} WHERE match_id = @match_id`).run({ ...entries, match_id: matchId });
|
||
this.service.changed(matchId);
|
||
}
|
||
|
||
usedPorts() {
|
||
const used = new Set();
|
||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||
used.add(r.port);
|
||
used.add(r.tv_port);
|
||
used.add(`${r.host}:${r.port}`);
|
||
}
|
||
return used;
|
||
}
|
||
|
||
// ---------- Start ----------
|
||
|
||
async provision(matchId) {
|
||
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||
this.busy.add(matchId);
|
||
try {
|
||
const previous = this.row(matchId);
|
||
const secrets = {
|
||
rconPassword: randomPassword(24),
|
||
serverPassword: randomPassword(10),
|
||
tvPassword: randomPassword(10),
|
||
};
|
||
const now = Date.now();
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO servers (match_id, adapter, simulated, status, message, handle, host, port, tv_port,
|
||
rcon_password, server_password, tv_password, config_key, attempts, started_at, updated_at)
|
||
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
|
||
@rcon, @server, @tv, @key, @attempts, @now, @now)
|
||
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
|
||
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
|
||
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
|
||
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
|
||
player_count = NULL, last_player_seen_at = NULL, updated_at = excluded.updated_at`,
|
||
)
|
||
.run({
|
||
match_id: matchId,
|
||
adapter: this.adapter.name,
|
||
simulated: this.adapter.simulated ? 1 : 0,
|
||
message: this.adapter.simulated ? 'Demo-Modus: es wird kein echter Server gestartet' : 'Server wird angefordert',
|
||
rcon: secrets.rconPassword,
|
||
server: secrets.serverPassword,
|
||
tv: secrets.tvPassword,
|
||
key: randomPassword(24),
|
||
attempts: (previous?.attempts ?? 0) + 1,
|
||
now,
|
||
});
|
||
this.service.changed(matchId);
|
||
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
|
||
|
||
const match = this.service.getMatch(matchId);
|
||
try {
|
||
const result = await this.adapter.start({ matchId, mode: match.mode, secrets });
|
||
this.update(matchId, {
|
||
handle: JSON.stringify(result.handle),
|
||
host: result.host,
|
||
port: result.port,
|
||
tv_port: result.tvPort,
|
||
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
||
});
|
||
} catch (err) {
|
||
this.update(matchId, { status: 'start_failed', message: err.message });
|
||
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
|
||
}
|
||
} finally {
|
||
this.busy.delete(matchId);
|
||
}
|
||
}
|
||
|
||
// ---------- Status ----------
|
||
|
||
async poll() {
|
||
const rows = this.db
|
||
.prepare(`SELECT * FROM servers WHERE status IN ('starting','ready','configured','config_failed') AND handle IS NOT NULL`)
|
||
.all();
|
||
await Promise.all(rows.map((row) => this.pollOne(row)));
|
||
}
|
||
|
||
async pollOne(row) {
|
||
const matchId = row.match_id;
|
||
if (this.busy.has(matchId)) return;
|
||
const last = this.lastPoll.get(matchId) ?? 0;
|
||
if (row.status !== 'starting' && Date.now() - last < SLOW_POLL_MS) return;
|
||
this.lastPoll.set(matchId, Date.now());
|
||
|
||
let status;
|
||
try {
|
||
status = await this.adapter.status(JSON.parse(row.handle));
|
||
} catch (err) {
|
||
status = { state: row.status === 'starting' ? 'starting' : 'running', message: `Statusabfrage fehlgeschlagen: ${err.message}` };
|
||
}
|
||
const current = this.row(matchId);
|
||
if (!current || current.status !== row.status) return; // zwischenzeitlich geändert
|
||
|
||
const fields = {};
|
||
if (status.playerCount !== undefined && status.playerCount !== current.player_count) fields.player_count = status.playerCount;
|
||
if (status.playerCount > 0) fields.last_player_seen_at = Date.now();
|
||
|
||
if (status.state === 'running' && current.status === 'starting') {
|
||
Object.assign(fields, { status: 'ready', ready_at: Date.now(), message: status.message ?? 'Server ist erreichbar' });
|
||
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) bereit' : 'Server ist hochgefahren');
|
||
} else if (status.state === 'failed') {
|
||
Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message });
|
||
this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`);
|
||
this.service.notify('server_error', matchId, {
|
||
title: '⚠️ Serverfehler',
|
||
description: `**${this.service.matchTitle(matchId)}**\n${status.message}`,
|
||
color: 0xdc2626,
|
||
});
|
||
} else if (status.state === 'stopped') {
|
||
Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' });
|
||
this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet');
|
||
} else if (status.message && status.message !== current.message && current.status === 'starting') {
|
||
fields.message = status.message;
|
||
}
|
||
|
||
if (Object.keys(fields).length) this.update(matchId, fields);
|
||
if (fields.status === 'ready') this.maybeConfigure(matchId);
|
||
}
|
||
|
||
// ---------- Konfiguration ----------
|
||
|
||
maybeConfigure(matchId) {
|
||
const match = this.service.getMatch(matchId);
|
||
const row = this.row(matchId);
|
||
if (match.status === 'server_setup' && row?.status === 'ready') {
|
||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||
}
|
||
}
|
||
|
||
async configure(matchId) {
|
||
const row = this.row(matchId);
|
||
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
|
||
if (!['ready', 'configured', 'config_failed'].includes(row.status)) {
|
||
throw new HttpError(409, `Server kann im Zustand „${SERVER_STATUS[row.status]}" nicht konfiguriert werden`, 'wrong_state');
|
||
}
|
||
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||
this.busy.add(matchId);
|
||
try {
|
||
const match = this.service.getMatch(matchId);
|
||
const pool = this.service.poolFor(match);
|
||
const seriesMap = this.service
|
||
.seriesMaps(matchId)
|
||
.find((m) => m.number === match.current_map_number);
|
||
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
||
|
||
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
||
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||
|
||
const plan = buildMapCommands({
|
||
match,
|
||
pool,
|
||
seriesMap: { map: seriesMap.map, ctTeam: seriesMap.ct_team },
|
||
secrets: { serverPassword: row.server_password, tvPassword: row.tv_password },
|
||
settings: this.config.match,
|
||
logUrl: `${this.config.logReceiverUrl}/api/logs/${matchId}/${row.config_key}`,
|
||
backupPrefix: backupPrefix(matchId),
|
||
});
|
||
this.simulator.stop(matchId);
|
||
this.service.resetLiveScore(matchId, seriesMap.number);
|
||
try {
|
||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||
} catch (err) {
|
||
this.update(matchId, { status: 'config_failed', message: err.message });
|
||
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
|
||
return;
|
||
}
|
||
this.update(matchId, {
|
||
status: 'configured',
|
||
configured_at: Date.now(),
|
||
message: this.adapter.simulated
|
||
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
|
||
: `${mapName} geladen – Aufwärmphase läuft`,
|
||
});
|
||
this.service.markCurrentMapLive(matchId);
|
||
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
|
||
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
|
||
} finally {
|
||
this.busy.delete(matchId);
|
||
}
|
||
}
|
||
|
||
// ---------- Aktionen des Organisators ----------
|
||
|
||
async retry(viewer) {
|
||
this.service.require(viewer, 'organizer');
|
||
const matchId = viewer.matchId;
|
||
const match = this.service.getMatch(matchId);
|
||
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
|
||
const row = this.row(matchId);
|
||
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||
|
||
if (!row || ['start_failed', 'failed', 'stopped'].includes(row.status)) {
|
||
if (row?.handle) await this.safeStop(row);
|
||
this.service.log(matchId, 'server', 'Organisator startet den Server neu');
|
||
await this.provision(matchId);
|
||
return;
|
||
}
|
||
if (row.status === 'config_failed' || (row.status === 'ready' && match.status === 'server_setup')) {
|
||
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||
return;
|
||
}
|
||
throw new HttpError(409, `Im Zustand „${SERVER_STATUS[row.status]}" ist kein erneuter Versuch nötig`, 'wrong_state');
|
||
}
|
||
|
||
async loadNextMap(viewer) {
|
||
this.service.require(viewer, 'organizer');
|
||
const row = this.row(viewer.matchId);
|
||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
|
||
clearTimeout(this.autoAdvance.get(viewer.matchId));
|
||
this.autoAdvance.delete(viewer.matchId);
|
||
this.service.advanceSeries(viewer);
|
||
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
|
||
}
|
||
|
||
async endWarmup(viewer) {
|
||
this.service.require(viewer, 'organizer');
|
||
const row = this.row(viewer.matchId);
|
||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist noch nicht konfiguriert', 'wrong_state');
|
||
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||
// Vor dem Befehl markieren: CS2 schickt Match_Start sofort nach mp_warmup_end, oft bevor RCON antwortet.
|
||
this.service.markWarmupEnded(viewer.matchId, true);
|
||
try {
|
||
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_pausetimer 0', 'mp_warmup_end']);
|
||
} catch (err) {
|
||
this.service.markWarmupEnded(viewer.matchId, false);
|
||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||
}
|
||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
||
const fields = [];
|
||
if (this.config.match.tvEnabled && row.tv_port) {
|
||
fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` });
|
||
}
|
||
this.service.notify('match_start', viewer.matchId, {
|
||
title: '🔴 Match läuft',
|
||
description: `**${this.service.matchTitle(viewer.matchId)}**`,
|
||
color: 0xef4444,
|
||
fields,
|
||
});
|
||
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||
this.service.changed(viewer.matchId);
|
||
}
|
||
|
||
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
|
||
async setPaused(viewer, paused) {
|
||
this.service.require(viewer, 'organizer');
|
||
const row = this.row(viewer.matchId);
|
||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
|
||
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||
try {
|
||
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
|
||
} catch (err) {
|
||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||
}
|
||
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
|
||
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
||
}
|
||
|
||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||
startDemoSimulation(matchId) {
|
||
const match = this.service.getMatch(matchId);
|
||
const seriesMap = this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||
if (!seriesMap) return;
|
||
this.service.log(matchId, 'server', 'Demo: simulierter Spielverlauf startet (keine echten Ergebnisse)');
|
||
this.simulator.start(matchId, {
|
||
map: seriesMap.map,
|
||
teamNames: { A: sanitizeConsoleText(match.team_a_name), B: sanitizeConsoleText(match.team_b_name) },
|
||
ctTeam: seriesMap.ct_team ?? 'A',
|
||
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||
roundMs: this.config.demo.roundSeconds * 1000,
|
||
onLines: (lines) => {
|
||
try {
|
||
this.service.applyLiveLog(matchId, lines);
|
||
} catch (err) {
|
||
this.logger.warn(`[demo] Simulation für ${matchId}: ${err.message}`);
|
||
this.simulator.stop(matchId);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
async stopByOrganizer(viewer) {
|
||
this.service.require(viewer, 'organizer');
|
||
await this.stop(viewer.matchId, 'Vom Organisator gestoppt');
|
||
}
|
||
|
||
async safeStop(row) {
|
||
try {
|
||
await this.adapter.stop(JSON.parse(row.handle), { resetPassword: randomPassword(16) });
|
||
} catch (err) {
|
||
this.logger.warn(`[server] Stopp für ${row.match_id} fehlgeschlagen: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
async stop(matchId, reason) {
|
||
this.simulator.stop(matchId);
|
||
const row = this.row(matchId);
|
||
if (!row || row.status === 'stopped') return;
|
||
if (row.handle) await this.safeStop(row);
|
||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||
}
|
||
|
||
// ---------- Automatisches Herunterfahren ----------
|
||
|
||
async reap(now = Date.now()) {
|
||
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
||
for (const row of rows) {
|
||
if (this.busy.has(row.match_id)) continue;
|
||
const match = this.service.getMatch(row.match_id);
|
||
if (CLOSED_STATUSES.includes(match.status)) {
|
||
await this.stop(row.match_id, 'Match beendet');
|
||
continue;
|
||
}
|
||
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
|
||
await this.stop(row.match_id, `Maximale Laufzeit von ${this.config.maxLifetimeHours} h erreicht`);
|
||
continue;
|
||
}
|
||
const webActive = this.service.presence(row.match_id).size > 0;
|
||
let lastUse = match.last_activity_at;
|
||
if (row.status === 'configured' && row.player_count !== null) {
|
||
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
||
if (row.player_count > 0) continue;
|
||
lastUse = Math.max(row.configured_at ?? 0, row.last_player_seen_at ?? 0);
|
||
} else if (webActive) {
|
||
continue;
|
||
}
|
||
if (now - lastUse > timeoutMs) {
|
||
await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`);
|
||
}
|
||
}
|
||
}
|
||
}
|