Messer-Server für die Seitenwahl: 5v5/2v2, im Admin startbar, setzen sich nach der Runde selbst zurück
This commit is contained in:
1 parent
0bfac952b6
commit
c6c39b914f
15 files changed
+717
-14
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
Reference in new issue
Block a user