Merge feature/messer-server in main

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

No files matched your search

+4
View File
@@ -60,6 +60,10 @@ CS2_START_TIMEOUT_MINUTES=20
CS2_LAN_PORTS=27015-27020
# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115)
CS2_TV_PORT_OFFSET=100
# Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
KNIFE_MAP=de_mirage
KNIFE_WARMUP_SECONDS=90
KNIFE_MAX_SERVERS=4
# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
CS2_SERVERNAME_PREFIX=MaxLAN
DOCKER_BIN=docker
+11
View File
@@ -254,6 +254,17 @@ Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts.
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
### Messer-Server für die Seitenwahl
Im Admin-Bereich (Reiter „Matches“) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`MaxLAN | Messer 1 (Seitenwahl)`) und auf der Startseite mit „Verbinden“.
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
### Server im Steam-Serverbrowser (LAN-Reiter)
Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.
+107
View File
@@ -152,6 +152,7 @@ export default function AdminPage() {
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{tab === 'matches' && <HostUsage hosts={hosts} />}
{tab === 'matches' && <KnifePanel token={token} setNotice={setNotice} />}
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
{tab === 'matches' && (
<MatchToolbar
@@ -798,3 +799,109 @@ function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQue
</div>
);
}
const KNIFE_STATUS = {
starting: { text: 'startet', tone: 'pending' },
configuring: { text: 'wird eingerichtet', tone: 'pending' },
running: { text: 'bereit', tone: 'ok' },
config_failed: { text: 'Einrichtung fehlgeschlagen', tone: 'error' },
failed: { text: 'ausgefallen', tone: 'error' },
};
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
function KnifePanel({ token, setNotice }) {
const [data, setData] = useState(null);
const [mode, setMode] = useState('');
const [map, setMap] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const res = await api('/admin/knife', { token });
setData(res);
setMode((m) => m || res.modes[0]?.id || '');
setMap((m) => m || res.modes[0]?.defaultMap || '');
} catch {
/* Fehler zeigt die Matchliste schon an */
}
}, [token]);
useEffect(() => {
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [load]);
if (!data) return null;
const run = async (fn, success, question) => {
if (question && !window.confirm(question)) return;
setBusy(true);
try {
await fn();
if (success) setNotice({ tone: 'ok', text: success });
load();
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
const post = (path, body) => api(`/admin/knife${path}`, { method: 'POST', body, token });
const full = data.servers.length >= data.maxServers;
const modeInfo = data.modes.find((x) => x.id === mode) ?? data.modes[0];
const chooseMode = (id) => {
setMode(id);
setMap(data.modes.find((x) => x.id === id)?.defaultMap ?? '');
};
return (
<section className="admin-settings knife-panel">
<h2>Messer-Server (Seitenwahl)</h2>
<p className="small muted">
Laufen dauerhaft und ohne Passwort, sichtbar im LAN-Reiter von Steam. Teams verbinden sich, spielen eine Messerrunde
und gehen wieder. Nach dem Spiel oder wenn der Server leer ist, setzt er sich selbst zurück.
</p>
{data.servers.length > 0 && (
<div className="knife-grid">
{data.servers.map((k) => {
const st = KNIFE_STATUS[k.status] ?? { text: k.status, tone: 'pending' };
const ready = k.status === 'running';
return (
<div key={k.id} className="knife-card">
<div className="knife-head">
<strong>{k.name}</strong>
{k.modeShort && <span className="admin-badge knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<span className={`tone-${st.tone} small knife-state`}>{st.text}</span>
</div>
{k.connect && <code className="knife-addr">{k.host}:{k.port}</code>}
<div className="small muted">
{k.playerCount != null ? `${k.playerCount} Spieler` : 'Spieler unbekannt'}
{k.message ? ` · ${k.message}` : ''}
</div>
<div className="admin-actions">
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/round`), 'Messerrunde gestartet')}>Runde starten</button>
<button className="btn btn-sm" disabled={busy || !ready} onClick={() => run(() => post(`/${k.id}/reset`), 'Server zurückgesetzt')}>Zurücksetzen</button>
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => post(`/${k.id}/restart`), 'Server wird neu gestartet', `${k.name} komplett neu starten?`)}>Neu starten</button>
<button className="btn btn-sm btn-ghost danger" disabled={busy} onClick={() => run(() => api(`/admin/knife/${k.id}`, { method: 'DELETE', token }), `${k.name} entfernt`, `${k.name} stoppen und entfernen?`)}>Entfernen</button>
</div>
</div>
);
})}
</div>
)}
<div className="admin-actions knife-new">
<select value={modeInfo?.id ?? ''} onChange={(e) => chooseMode(e.target.value)} aria-label="Modus für den neuen Messer-Server" disabled={busy || full}>
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
<select value={map} onChange={(e) => setMap(e.target.value)} aria-label="Map für den neuen Messer-Server" disabled={busy || full}>
{modeInfo?.maps.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<button className="btn btn-sm btn-primary" disabled={busy || full} onClick={() => run(() => api('/admin/knife', { method: 'POST', body: { mode: modeInfo?.id, map }, token }), 'Messer-Server wird gestartet')}>
Messer-Server starten
</button>
<span className="small muted">{data.servers.length} von {data.maxServers}</span>
</div>
</section>
);
}
+36 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
@@ -24,6 +24,8 @@ export default function Overview() {
<CodeEntry />
</section>
<KnifeServers />
{matches === null ? (
<p className="muted">Lade Matches …</p>
) : (
@@ -137,3 +139,36 @@ function CodeEntry() {
</form>
);
}
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
function KnifeServers() {
const [servers, setServers] = useState([]);
useEffect(() => {
let alive = true;
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => {});
load();
const t = setInterval(load, 10_000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
if (servers.length === 0) return null;
return (
<section className="group knife-public">
<h2>Messer-Server für die Seitenwahl <span className="count">{servers.length}</span></h2>
<p className="muted small">Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.</p>
<ul className="knife-list">
{servers.map((k) => (
<li key={k.id}>
<strong>{k.name}</strong>
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<span className="small">{k.playerCount ? `${k.playerCount} Spieler` : 'frei'}</span>
<code>{k.connect}</code>
<a className="btn btn-sm" href={k.steamUrl}>Verbinden</a>
</li>
))}
</ul>
</section>
);
}
+13
View File
@@ -599,3 +599,16 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.admin-filter.has-errors .admin-filter-count { color: var(--error); font-weight: 700; opacity: 1; }
.admin-error { margin: 8px 0 0; color: var(--error); }
.admin-badge.is-archived { background: transparent; color: var(--muted); border: 1px solid var(--line); }
.knife-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
.knife-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
.knife-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
.knife-state { margin-left: auto; }
.knife-addr { font: 500 .9rem ui-monospace, SFMono-Regular, Consolas, monospace; align-self: flex-start; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; }
.knife-new select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; }
.knife-new { align-items: center; }
.knife-public { margin-bottom: 24px; }
.knife-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.knife-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
.knife-list .btn { margin-left: auto; }
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
+13 -1
View File
@@ -15,7 +15,7 @@ function bearer(req) {
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
*/
export function createAdminRouter({ service, manager, config, settings }) {
export function createAdminRouter({ service, manager, config, settings, knife }) {
const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
@@ -125,6 +125,18 @@ export function createAdminRouter({ service, manager, config, settings }) {
return hostView();
}));
// Messer-Server für die Seitenwahl
router.get('/knife', requireAdmin, wrap(() => ({
servers: knife.list(),
modes: knife.modes(),
maxServers: config.knife.maxServers,
})));
router.post('/knife', requireAdmin, wrap((req) => knife.create({ map: req.body?.map, mode: req.body?.mode })));
router.post('/knife/:id/reset', requireAdmin, wrap((req) => knife.reset(req.params.id)));
router.post('/knife/:id/round', requireAdmin, wrap((req) => knife.startRound(req.params.id)));
router.post('/knife/:id/restart', requireAdmin, wrap((req) => knife.restart(req.params.id)));
router.delete('/knife/:id', requireAdmin, wrap((req) => knife.remove(req.params.id)));
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
router.post('/notifications/test', requireAdmin, wrap(async () => {
+6 -2
View File
@@ -11,6 +11,7 @@ import { MatchService } from './matchService.js';
import { attachRealtime } from './realtime.js';
import { createServerAdapter } from './servers/adapter.js';
import { ServerManager } from './servers/serverManager.js';
import { KnifeServers } from './servers/knifeServers.js';
import { ServerSettings } from './serverSettings.js';
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
@@ -29,6 +30,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
});
manager = new ServerManager({ db, service, adapter, config, logger });
manager.init();
const knife = new KnifeServers({ db, adapter, config, pools, logger });
service.startVetoTimer();
const app = express();
@@ -39,7 +41,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
res.set('Referrer-Policy', 'no-referrer');
next();
});
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings }));
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings, knife }));
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
const dist = path.join(REPO_ROOT, 'client', 'dist');
@@ -52,15 +54,17 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const io = attachRealtime(server, { service, config });
return {
app, server, io, db, service, manager, adapter,
app, server, io, db, service, manager, adapter, knife,
async start() {
await manager.resume();
manager.startLoops();
knife.startLoop();
await new Promise((resolve) => server.listen(config.port, resolve));
return server.address().port;
},
async close() {
manager.stopLoops();
knife.stopLoop();
service.stopVetoTimer();
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
await manager.idle(5000);
+7
View File
@@ -109,6 +109,13 @@ export function loadConfig(overrides = {}) {
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
// Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
knife: {
map: str('KNIFE_MAP', 'de_mirage'),
// Zeit zum Verbinden, bevor die Messerrunde beginnt
warmupSeconds: int('KNIFE_WARMUP_SECONDS', 90),
maxServers: int('KNIFE_MAX_SERVERS', 4),
},
// Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie
archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3),
+22
View File
@@ -97,6 +97,27 @@ CREATE TABLE IF NOT EXISTS events (
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
-- Messer-Server für die Seitenwahl: laufen dauerhaft, gehören keinem Match
CREATE TABLE IF NOT EXISTS knife_servers (
id TEXT PRIMARY KEY,
number INTEGER NOT NULL,
name TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'competitive',
map TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
handle TEXT,
host TEXT,
port INTEGER,
tv_port INTEGER,
rcon_password TEXT NOT NULL,
player_count INTEGER,
restarts INTEGER NOT NULL DEFAULT 0,
started_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -132,4 +153,5 @@ function migrate(db) {
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
}
+5 -2
View File
@@ -14,7 +14,7 @@ function accessTokens(req) {
return [bearer(req), req.get('x-player-token')?.trim() || null];
}
export function createApiRouter({ service, manager, config, pools, adapter, settings }) {
export function createApiRouter({ service, manager, config, pools, adapter, settings, knife }) {
const router = express.Router();
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
});
router.use(express.json({ limit: '32kb' }));
router.use('/admin', createAdminRouter({ service, manager, config, settings }));
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
@@ -71,6 +71,9 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
// Messer-Server für die Seitenwahl: offen, ohne Passwort, deshalb für alle sichtbar
router.get('/knife-servers', wrap(() => ({ servers: knife.list().filter((k) => k.status === 'running') })));
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
+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}`);
+29
View File
@@ -545,3 +545,32 @@ test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist',
await close();
}
});
test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
try {
assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401);
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const info = (await call('GET', '/admin/knife', { token })).body;
assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']);
const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } });
assert.equal(created.status, 200, JSON.stringify(created.body));
// Noch nicht bereit: Startseite zeigt ihn nicht
assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0);
await app.knife.poll();
await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' });
const pub = (await call('GET', '/knife-servers')).body.servers;
assert.equal(pub.length, 1);
assert.equal(pub[0].map, 'de_inferno');
assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password));
// Ports des Messer-Servers sind für Matches belegt
const row = app.knife.row(created.body.id);
assert.ok(app.manager.usedPortsOn(row.host).has(row.port));
assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200);
assert.equal(app.knife.list().length, 0);
} finally {
await close();
}
});
+125
View File
@@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config.js';
import { openDatabase } from '../src/db.js';
import { buildKnifeCommands, KnifeServers } from '../src/servers/knifeServers.js';
const pools = {
competitive: { label: 'Competitive 5 gegen 5', gameType: 0, gameMode: 1, teamSize: 5, maps: [{ id: 'de_mirage', name: 'Mirage' }, { id: 'de_inferno', name: 'Inferno' }] },
wingman: { label: 'Wingman 2 gegen 2', gameType: 0, gameMode: 2, teamSize: 2, maps: [{ id: 'de_vertigo', name: 'Vertigo' }] },
};
const quiet = { log() {}, warn() {}, error() {} };
/** Adapter, dessen Zustand der Test steuert. */
function fakeAdapter() {
const a = {
name: 'docker',
starts: [],
configures: [],
commands: [],
stops: 0,
state: 'starting',
players: null,
async start(args) {
a.starts.push(args);
return { handle: { container: `cs2mv-${args.matchId}`, n: a.starts.length }, host: '10.0.0.5', port: 27015 + a.starts.length - 1, tvPort: 27115 };
},
async status() { return { state: a.state, playerCount: a.players }; },
async configure(handle, plan) { a.configures.push(plan); },
async sendCommands(handle, cmds) { a.commands.push(...cmds); },
async stop() { a.stops += 1; },
};
return a;
}
function setup() {
const config = loadConfig({ knife: { map: 'de_mirage', warmupSeconds: 60, maxServers: 2 }, docker: { serverNamePrefix: 'MaxLAN' } });
const adapter = fakeAdapter();
const knife = new KnifeServers({ db: openDatabase(':memory:'), adapter, config, pools, logger: quiet });
return { knife, adapter };
}
const poll = async (knife) => { knife.lastPoll.clear(); await knife.poll(); };
test('Messerrunde: nur Messer, eine Runde, danach automatisch neues Spiel', () => {
const plan = buildKnifeCommands({ map: 'de_inferno', warmupSeconds: 45 });
assert.equal(plan.beforeMapChange.at(-1), 'changelevel de_inferno');
assert.ok(plan.beforeMapChange.includes('sv_password ""'), 'offen, ohne Passwort');
for (const c of ['mp_maxrounds 1', 'mp_match_end_restart 1', 'mp_ct_default_secondary ""', 'mp_startmoney 0', 'mp_warmuptime 45']) {
assert.ok(plan.afterMapChange.includes(c), `${c} fehlt`);
}
});
test('Start, Konfiguration und Ansicht ohne Geheimnisse', async () => {
const { knife, adapter } = setup();
const created = await knife.create({});
assert.equal(created.name, 'Messer 1');
assert.equal(created.status, 'starting');
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 5v5 (Seitenwahl)');
assert.equal(adapter.starts[0].mode, 'competitive');
assert.equal(adapter.starts[0].secrets.serverPassword, '');
adapter.state = 'running';
await poll(knife);
assert.equal(knife.row('knife1').status, 'running');
assert.equal(adapter.configures[0].map, 'de_mirage');
const [view] = knife.list();
assert.equal(view.connect, 'connect 10.0.0.5:27015');
assert.ok(!JSON.stringify(view).includes(knife.row('knife1').rcon_password), 'kein RCON-Passwort in der Ansicht');
});
test('Wingman 2v2: eigener Spielmodus, Maps nur aus dem Wingman-Pool', async () => {
const { knife, adapter } = setup();
const k = await knife.create({ mode: 'wingman' });
assert.equal(k.modeShort, '2v2');
assert.equal(k.map, 'de_vertigo', 'Standard-Map gibt es im Wingman-Pool nicht, also die erste');
assert.equal(adapter.starts[0].mode, 'wingman');
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 2v2 (Seitenwahl)');
adapter.state = 'running';
await poll(knife);
assert.ok(adapter.configures[0].beforeMapChange.includes('game_mode 2'));
await assert.rejects(knife.create({ mode: 'wingman', map: 'de_mirage' }), /gibt es nicht für Wingman/);
await assert.rejects(knife.create({ mode: 'deathmatch' }), /Unbekannter Modus/);
});
test('Grenzen: nur Maps aus den Pools, höchstens maxServers', async () => {
const { knife } = setup();
await assert.rejects(knife.create({ map: 'de_unbekannt' }), /gibt es nicht/);
await knife.create({ map: 'de_inferno' });
await knife.create({});
await assert.rejects(knife.create({}), /Höchstens 2/);
// Freie Nummer wird wiederverwendet
await knife.remove('knife1');
assert.equal((await knife.create({})).name, 'Messer 1');
});
test('Teams gehen runter: Server setzt sich für die nächsten zurück', async () => {
const { knife, adapter } = setup();
await knife.create({});
adapter.state = 'running';
await poll(knife);
adapter.players = 4;
await poll(knife);
assert.equal(adapter.commands.length, 0);
adapter.players = 0;
await poll(knife);
assert.deepEqual(adapter.commands, ['mp_warmup_start']);
assert.match(knife.row('knife1').message, /zurückgesetzt/);
});
test('Ausfall: Server startet neu, nach zu vielen Ausfällen gibt die App auf', async () => {
const { knife, adapter } = setup();
await knife.create({});
adapter.state = 'failed';
await poll(knife);
assert.equal(adapter.starts.length, 2, 'nach Ausfall neu gestartet');
assert.equal(knife.row('knife1').restarts, 1);
for (let i = 0; i < 6; i++) await poll(knife);
assert.equal(adapter.starts.length, 6, 'höchstens 5 Neustarts');
assert.equal(knife.row('knife1').status, 'failed');
// Neustart durch den Admin setzt den Zähler zurück
adapter.state = 'starting';
await knife.restart('knife1');
assert.equal(knife.row('knife1').restarts, 0);
assert.equal(adapter.starts.length, 7);
});