Files
Cs2-Mapveto/server/src/servers/knifeServers.js
T
mregosar e8e070dfe6 CSTV: Verzögerung wirkt wieder, Server läuft am Serienende bis zum Ende der Übertragung
- 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).
2026-11-01 22:07:39 +01:00

359 lines
14 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.
// 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;
}
}