// Messer-Server für die Seitenwahl: Gameserver, die dauerhaft laufen und keinem Match gehören. // Die Teams gehen drauf, spielen ihre Messerrunde und gehen wieder. Nach Spielende oder wenn der Server // leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu. import { HttpError } from '../matchService.js'; import { randomPassword, safeErrorMessage } from '../security.js'; const SLOW_POLL_MS = 10_000; // Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet const MAX_RESTARTS = 5; /** * Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel. * `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden. */ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) { return { map, beforeMapChange: [ `game_type ${Number(pool.gameType)}`, `game_mode ${Number(pool.gameMode)}`, `sv_password "${password}"`, // Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach ...(password ? [] : ['tv_enable 0']), `changelevel ${map}`, ], afterMapChange: [ 'bot_kick', 'bot_quota 0', // Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams) 'mp_maxrounds 1', 'mp_match_can_clinch 1', // Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest 'mp_endmatch_votenextmap 0', // Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund 'sv_deltaticks_enforce 0', 'mp_match_end_restart 1', 'mp_match_restart_delay 10', 'mp_halftime 0', 'mp_overtime_enable 0', // Nur Messer: keine Startwaffen, kein Geld, nichts zu kaufen 'mp_ct_default_primary ""', 'mp_t_default_primary ""', 'mp_ct_default_secondary ""', 'mp_t_default_secondary ""', 'mp_ct_default_grenades ""', 'mp_t_default_grenades ""', 'mp_give_player_c4 0', 'mp_startmoney 0', 'mp_maxmoney 0', 'mp_afterroundmoney 0', 'mp_buytime 0', 'mp_free_armor 1', 'mp_weapons_allow_map_placed 0', 'mp_death_drop_gun 0', 'mp_death_drop_grenade 0', 'mp_freezetime 3', 'mp_roundtime 2', 'mp_roundtime_defuse 2', 'mp_roundtime_hostage 2', 'mp_autoteambalance 0', 'mp_limitteams 0', 'mp_autokick 0', // Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet. 'mp_do_warmup_period 1', `mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`, `mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`, 'mp_warmup_start', ], }; } /** * Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird. * Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort. */ export const KNIFE_UNDO_COMMANDS = [ 'mp_ct_default_secondary weapon_hkp2000', 'mp_t_default_secondary weapon_glock', 'mp_ct_default_primary ""', 'mp_t_default_primary ""', 'mp_ct_default_grenades ""', 'mp_t_default_grenades ""', 'mp_give_player_c4 1', 'mp_maxmoney 16000', 'mp_afterroundmoney 0', 'mp_free_armor 0', 'mp_weapons_allow_map_placed 1', 'mp_death_drop_gun 1', 'mp_death_drop_grenade 2', 'mp_match_end_restart 0', 'mp_roundtime_defuse 1.92', 'mp_roundtime_hostage 1.92', ]; /** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */ export const KNIFE_RESET_COMMANDS = ['mp_warmup_start']; export class KnifeServers { constructor({ db, adapter, config, pools = {}, logger = console }) { this.db = db; this.pools = pools; this.adapter = adapter; this.config = config; this.logger = logger; this.busy = new Set(); this.lastPoll = new Map(); this.timer = null; } rows() { return this.db.prepare('SELECT * FROM knife_servers ORDER BY number').all(); } row(id) { return this.db.prepare('SELECT * FROM knife_servers WHERE id = ?').get(id); } update(id, fields) { const keys = Object.keys(fields); if (!keys.length) return; this.db.prepare(`UPDATE knife_servers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @updated_at WHERE id = @id`) .run({ ...fields, updated_at: Date.now(), id }); } /** Ansicht für Admin und Startseite. Ohne RCON-Passwort und Handle. */ view(row) { const pool = this.pools[row.mode]; const size = pool?.teamSize; return { id: row.id, number: row.number, name: row.name, mode: row.mode, modeLabel: pool?.label ?? row.mode, modeShort: size ? `${size}v${size}` : null, map: row.map, mapName: pool?.maps.find((m) => m.id === row.map)?.name ?? row.map, status: row.status, message: row.message, host: row.host, port: row.port, playerCount: row.player_count, restarts: row.restarts, startedAt: row.started_at, connect: row.host && row.port ? `connect ${row.host}:${row.port}` : null, steamUrl: row.host && row.port ? `steam://connect/${row.host}:${row.port}` : null, }; } list() { return this.rows().map((r) => this.view(r)); } /** Ports aller Messer-Server auf einem Host (für die Portvergabe zusammen mit den Match-Servern). */ usedPorts(host) { const rows = host ? this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host) : this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL').all(); return rows; } /** Modi für Messer-Server (5v5, 2v2) mit ihren Maps, aus den Map-Pools. */ modes() { return Object.entries(this.pools).map(([id, pool]) => { const maps = pool.maps.map((m) => ({ id: m.id, name: m.name })); return { id, label: pool.label, teamSize: pool.teamSize, maps, defaultMap: maps.some((m) => m.id === this.config.knife.map) ? this.config.knife.map : maps[0]?.id, }; }); } /** Neuen Messer-Server starten. Liefert die Ansicht des neuen Servers. */ async create({ map, mode = 'competitive' }) { if (this.adapter.name === 'static') throw new HttpError(409, 'Mit festen Servern (static) gibt es keine Messer-Server', 'unsupported'); const info = this.modes().find((m) => m.id === mode); if (!info) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid'); const chosen = String(map || info.defaultMap); if (!info.maps.some((m) => m.id === chosen)) throw new HttpError(400, `Map ${chosen} gibt es nicht für ${info.label}`, 'invalid'); const max = this.config.knife.maxServers; const count = this.db.prepare('SELECT COUNT(*) AS n FROM knife_servers').get().n; if (count >= max) throw new HttpError(409, `Höchstens ${max} Messer-Server`, 'limit'); const used = new Set(this.rows().map((r) => r.number)); let number = 1; while (used.has(number)) number += 1; const id = `knife${number}`; const now = Date.now(); this.db.prepare(` INSERT INTO knife_servers (id, number, name, mode, map, status, message, rcon_password, restarts, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'starting', 'Server wird gestartet', ?, 0, ?, ?) `).run(id, number, `Messer ${number}`, mode, chosen, randomPassword(24), now, now); await this.launch(id); return this.view(this.row(id)); } async launch(id) { const row = this.row(id); this.busy.add(id); try { const prefix = this.config.docker?.serverNamePrefix; const size = this.pools[row.mode]?.teamSize; const result = await this.adapter.start({ matchId: id, // Wingman: 6 Plätze, Competitive: 12 Plätze (wie bei den Match-Servern) mode: row.mode, secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) }, serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`, // Gleicher Wert wie bei den Match-Servern: das Image schreibt ihn in die gemeinsamen Spielmodus-Dateien tvDelaySeconds: this.config.match?.tvDelaySeconds, }); // Während des Starts entfernt: Container gleich wieder abbauen if (!this.row(id)) { await this.adapter.stop(result.handle).catch(() => {}); return; } this.update(id, { status: 'starting', message: 'Container läuft, Spielserver lädt', handle: JSON.stringify(result.handle), host: result.host, port: result.port, tv_port: result.tvPort, player_count: null, started_at: Date.now(), }); } catch (err) { const message = safeErrorMessage(err, [row.rcon_password]); if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` }); throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed'); } finally { this.busy.delete(id); } } async configure(id) { const row = this.row(id); this.busy.add(id); this.update(id, { status: 'configuring', message: `Lade ${row.map}` }); try { await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map: row.map, pool: this.pools[row.mode], warmupSeconds: this.config.knife.warmupSeconds })); this.update(id, { status: 'running', message: 'Bereit für die Messerrunde', restarts: 0 }); } catch (err) { this.update(id, { status: 'config_failed', message: `Konfiguration fehlgeschlagen: ${err.message}` }); } finally { this.busy.delete(id); } } async poll() { for (const row of this.rows()) { if (this.busy.has(row.id) || !row.handle) continue; const last = this.lastPoll.get(row.id) ?? 0; if (row.status === 'running' && Date.now() - last < SLOW_POLL_MS) continue; this.lastPoll.set(row.id, Date.now()); await this.pollOne(row).catch((err) => this.logger.warn?.(`[messer] ${row.name}: ${err.message}`)); } } async pollOne(row) { let status; try { status = await this.adapter.status(JSON.parse(row.handle)); } catch (err) { status = { state: row.status === 'starting' ? 'starting' : 'running', message: err.message }; } if (status.state === 'failed' || status.state === 'stopped') { await this.recover(row, status.message); return; } if (status.state === 'running' && row.status === 'starting') { await this.configure(row.id); return; } if (row.status === 'config_failed' && status.state === 'running') { // Konfiguration noch einmal versuchen, der Server läuft ja await this.configure(row.id); return; } if (row.status !== 'running' || status.playerCount === undefined) return; const players = status.playerCount; if (players !== row.player_count) this.update(row.id, { player_count: players }); // Alle sind gegangen: Server für die nächsten Teams zurücksetzen if (row.player_count > 0 && players === 0) await this.reset(row.id, 'Server leer – für die nächsten Teams zurückgesetzt'); } /** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */ async recover(row, reason) { // Während des Spieldateien-Updates nicht neu starten (zählt auch nicht als Fehlversuch) if (this.adapter.filesStatus?.().running) { if (row.message !== 'Wartet auf das Spieldateien-Update') this.update(row.id, { status: 'starting', message: 'Wartet auf das Spieldateien-Update' }); return; } if (row.restarts >= MAX_RESTARTS) { if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() }); return; } this.logger.warn?.(`[messer] ${row.name} ausgefallen (${reason}), starte neu`); this.update(row.id, { restarts: row.restarts + 1, status: 'starting', message: `Neustart nach Ausfall (${row.restarts + 1}/${MAX_RESTARTS})` }); await this.adapter.stop(JSON.parse(row.handle)).catch(() => {}); await this.launch(row.id).catch(() => {}); } async reset(id, message = 'Spiel zurückgesetzt (Admin)') { const row = this.requireRow(id); if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state'); try { await this.adapter.sendCommands(JSON.parse(row.handle), KNIFE_RESET_COMMANDS); } catch (err) { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.update(id, { message }); } async startRound(id) { const row = this.requireRow(id); if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state'); try { await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_end']); } catch (err) { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.update(id, { message: 'Messerrunde läuft (Admin gestartet)' }); } /** Server komplett neu starten (neuer Container), z. B. wenn er hängt. */ async restart(id) { const row = this.requireRow(id); if (this.busy.has(id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy'); if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {}); this.update(id, { restarts: 0, status: 'starting', message: 'Neustart durch Admin' }); await this.launch(id); } async remove(id) { const row = this.requireRow(id); if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {}); this.db.prepare('DELETE FROM knife_servers WHERE id = ?').run(id); } requireRow(id) { const row = this.row(id); if (!row) throw new HttpError(404, 'Messer-Server nicht gefunden', 'not_found'); return row; } startLoop() { this.timer = setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000); } stopLoop() { clearInterval(this.timer); this.timer = null; } }