Messer-Server für die Seitenwahl: 5v5/2v2, im Admin startbar, setzen sich nach der Runde selbst zurück

This commit is contained in:
mregosar committed 2026-10-29 20:33:15 +01:00
1 parent 0bfac952b6
commit c6c39b914f
15 files changed
+717 -14

No files matched your search

+4 -4
View File
@@ -164,16 +164,16 @@ export class DockerServerAdapter {
}
}
async launch({ matchId, mode, secrets, teamNames }, blocked = new Set()) {
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
const { port, tvPort } = this.allocatePorts(blocked);
const lan = (this.options.lanPorts ?? []).includes(port);
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
const env = {
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
CS2_SERVERNAME: this.serverName(matchId, teamNames),
CS2_SERVERNAME: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
CS2_PORT: String(port),
CS2_RCONPW: secrets.rconPassword,
CS2_PW: secrets.serverPassword,
@@ -216,7 +216,7 @@ export class DockerServerAdapter {
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
await this.docker(['rm', '-f', container]).catch(() => {});
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
}
return {
+317
View File
@@ -0,0 +1,317 @@
// 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 } 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 }) {
return {
map,
beforeMapChange: [
`game_type ${Number(pool.gameType)}`,
`game_mode ${Number(pool.gameMode)}`,
'sv_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',
'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
'mp_do_warmup_period 1',
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
'mp_warmup_pausetimer 0',
'mp_warmup_start',
],
};
}
/** 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)`,
});
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) {
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.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) {
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;
}
}
+18 -4
View File
@@ -168,14 +168,20 @@ export class ServerManager {
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 })),
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 })),
],
}));
}
@@ -258,12 +264,20 @@ export class ServerManager {
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`).all()) {
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}`);