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); } /** 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`); } } } }