625 lines
28 KiB
JavaScript
625 lines
28 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);
|
||
}
|
||
|
||
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
|
||
async hostUsage() {
|
||
if (!this.adapter.hostStats) return [];
|
||
const rows = this.db.prepare(`
|
||
SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name
|
||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||
`).all();
|
||
const knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
|
||
const stats = this.adapter.hostStats();
|
||
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||
return stats.map((h, i) => ({
|
||
...h,
|
||
disk: disks[i],
|
||
matches: [
|
||
...rows
|
||
.filter((r) => r.host === h.publicHost)
|
||
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||
...knives
|
||
.filter((k) => k.host === h.publicHost)
|
||
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
|
||
],
|
||
}));
|
||
}
|
||
|
||
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
|
||
activeServers() {
|
||
return this.db.prepare(`
|
||
SELECT s.match_id, s.status, s.host, s.handle, m.mode
|
||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
|
||
`).all().map((r) => {
|
||
let handle = null;
|
||
try {
|
||
handle = JSON.parse(r.handle);
|
||
} catch {
|
||
// kaputter Handle: Server zählt trotzdem als aktiv
|
||
}
|
||
return { ...r, handle, hostName: handle?.hostName ?? null };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
|
||
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
|
||
*/
|
||
setDockerHosts(hosts) {
|
||
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
|
||
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
|
||
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
|
||
for (const server of this.activeServers()) {
|
||
const name = server.hostName ?? this.adapter.hosts[0]?.name;
|
||
const old = before.get(name);
|
||
const next = nextList.find((h) => h.name === name);
|
||
if (!next) {
|
||
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
|
||
}
|
||
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
|
||
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
|
||
}
|
||
}
|
||
this.adapter.setHosts(hosts);
|
||
}
|
||
|
||
/**
|
||
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
|
||
* aktualisiert wurden und welche nicht erreichbar waren.
|
||
*/
|
||
async applyLiveSettings(changed) {
|
||
const keys = new Set(changed);
|
||
const rows = this.activeServers().filter((r) => r.status === 'configured');
|
||
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
|
||
const m = this.config.match;
|
||
const result = { updated: 0, failed: [] };
|
||
for (const row of rows) {
|
||
const wingman = row.mode === 'wingman';
|
||
const commands = [];
|
||
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
|
||
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
|
||
}
|
||
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
|
||
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
|
||
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
|
||
if (commands.length === 0) continue;
|
||
try {
|
||
await this.adapter.sendCommands(row.handle, commands);
|
||
result.updated += 1;
|
||
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
|
||
} catch (err) {
|
||
result.failed.push(row.match_id);
|
||
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||
usedPortsOn(host) {
|
||
const used = new Set();
|
||
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
|
||
used.add(r.port);
|
||
used.add(r.tv_port);
|
||
}
|
||
// Messer-Server belegen ihre Ports dauerhaft
|
||
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
|
||
used.add(r.port);
|
||
used.add(r.tv_port);
|
||
}
|
||
return used;
|
||
}
|
||
|
||
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
|
||
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE 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,
|
||
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
||
});
|
||
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);
|
||
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
||
if (['server_setup', 'live'].includes(match.status) && 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' && ['server_setup', 'live'].includes(match.status))) {
|
||
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()) {
|
||
this.service.archiveClosed(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;
|
||
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
||
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
||
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, row.started_at ?? 0);
|
||
} else if (webActive) {
|
||
continue;
|
||
}
|
||
if (now - lastUse > timeoutMs) {
|
||
await this.stop(row.match_id, `Automatisch heruntergefahren – ${this.config.idleTimeoutMinutes} Minuten ungenutzt`);
|
||
}
|
||
}
|
||
}
|
||
}
|