318 lines
12 KiB
JavaScript
318 lines
12 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 } 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;
|
||
}
|
||
}
|