- Das Image schrieb TV_DELAY (Standard 0) bei jedem Containerstart in alle gamemode_*.cfg, die bei jedem Laden einer Map laufen: tv_delay stand danach immer auf 0. Die App gibt TV_DELAY jetzt mit und setzt tv_delay zusätzlich nach dem Laden der Map per RCON. - Am Serienende (automatisch oder von Hand abgeschlossen) bleibt der Server die CSTV-Verzögerung plus 15 s an, damit der Stream das Ende zeigt. Abgebrochene Matches stoppen sofort. - Ingame-Chat: Hinweis, dass der Server für CSTV noch online bleibt und sich dann abschaltet; beim Mapwechsel die echte Wartezeit nach der CSTV-Übertragung (tv_delaymapchange).
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
// 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;
|
||
}
|
||
}
|