Files
Cs2-Mapveto/server/src/servers/serverManager.js
T
SPP UserandClaude Haiku 5.5 3e682d7fbc Admin: Pausieren und Fortsetzen des laufenden Matches
Neue Admin-Aktionen server/pause und server/resume senden
mp_pause_match bzw. mp_unpause_match an den CS2-Server. Nur bei
konfiguriertem Server verfügbar.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-26 00:48:25 +01:00

485 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
} 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;
this.update(matchId, { status: 'configuring', 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');
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.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
this.service.changed(viewer.matchId);
}
/** 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 });
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`);
}
}
}
}