diff --git a/.env.example b/.env.example
index db102ec..d02de84 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index 49a8dce..3568beb 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx
index a6fcd46..3cf3580 100644
--- a/client/src/pages/AdminPage.jsx
+++ b/client/src/pages/AdminPage.jsx
@@ -152,6 +152,7 @@ export default function AdminPage() {
{notice &&
{notice.text}
}
{error && {error}
}
{tab === 'matches' && }
+ {tab === 'matches' && }
{tab === 'matches' && matches === null && !error && Lade Matches …
}
{tab === 'matches' && (
);
}
+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 (
+
+ Messer-Server (Seitenwahl)
+
+ 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.
+
+ {data.servers.length > 0 && (
+
+ {data.servers.map((k) => {
+ const st = KNIFE_STATUS[k.status] ?? { text: k.status, tone: 'pending' };
+ const ready = k.status === 'running';
+ return (
+
+
+ {k.name}
+ {k.modeShort && {k.modeShort} }
+ {k.mapName}
+ {st.text}
+
+ {k.connect &&
{k.host}:{k.port}}
+
+ {k.playerCount != null ? `${k.playerCount} Spieler` : 'Spieler unbekannt'}
+ {k.message ? ` · ${k.message}` : ''}
+
+
+ run(() => post(`/${k.id}/round`), 'Messerrunde gestartet')}>Runde starten
+ run(() => post(`/${k.id}/reset`), 'Server zurückgesetzt')}>Zurücksetzen
+ run(() => post(`/${k.id}/restart`), 'Server wird neu gestartet', `${k.name} komplett neu starten?`)}>Neu starten
+ run(() => api(`/admin/knife/${k.id}`, { method: 'DELETE', token }), `${k.name} entfernt`, `${k.name} stoppen und entfernen?`)}>Entfernen
+
+
+ );
+ })}
+
+ )}
+
+ chooseMode(e.target.value)} aria-label="Modus für den neuen Messer-Server" disabled={busy || full}>
+ {data.modes.map((m) => {m.label} )}
+
+ setMap(e.target.value)} aria-label="Map für den neuen Messer-Server" disabled={busy || full}>
+ {modeInfo?.maps.map((m) => {m.name} )}
+
+ run(() => api('/admin/knife', { method: 'POST', body: { mode: modeInfo?.id, map }, token }), 'Messer-Server wird gestartet')}>
+ Messer-Server starten
+
+ {data.servers.length} von {data.maxServers}
+
+
+ );
+}
diff --git a/client/src/pages/Overview.jsx b/client/src/pages/Overview.jsx
index c1abd8b..8db33dc 100644
--- a/client/src/pages/Overview.jsx
+++ b/client/src/pages/Overview.jsx
@@ -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() {
+
+
{matches === null ? (
Lade Matches …
) : (
@@ -137,3 +139,36 @@ function CodeEntry() {
);
}
+/** 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 (
+
+ Messer-Server für die Seitenwahl {servers.length}
+ Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.
+
+ {servers.map((k) => (
+
+ {k.name}
+ {k.modeShort && {k.modeShort} }
+ {k.mapName}
+ {k.playerCount ? `${k.playerCount} Spieler` : 'frei'}
+ {k.connect}
+ Verbinden
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/styles.css b/client/src/styles.css
index 69580ea..fd1367b 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -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; }
diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js
index e262551..c65b8b5 100644
--- a/server/src/adminRoutes.js
+++ b/server/src/adminRoutes.js
@@ -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 () => {
diff --git a/server/src/app.js b/server/src/app.js
index 87762f4..f15f0a7 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -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);
diff --git a/server/src/config.js b/server/src/config.js
index f33d547..d145ca0 100644
--- a/server/src/config.js
+++ b/server/src/config.js
@@ -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),
diff --git a/server/src/db.js b/server/src/db.js
index 4767eaf..50c369d 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -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'");
}
diff --git a/server/src/http.js b/server/src/http.js
index c52a963..8d9807d 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -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)) {
diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js
index dc2c294..7305a7a 100644
--- a/server/src/servers/dockerAdapter.js
+++ b/server/src/servers/dockerAdapter.js
@@ -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 {
diff --git a/server/src/servers/knifeServers.js b/server/src/servers/knifeServers.js
new file mode 100644
index 0000000..969cac8
--- /dev/null
+++ b/server/src/servers/knifeServers.js
@@ -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;
+ }
+}
diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js
index 994676b..1e9ad6c 100644
--- a/server/src/servers/serverManager.js
+++ b/server/src/servers/serverManager.js
@@ -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}`);
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 36d1c9f..f157f4f 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -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();
+ }
+});
diff --git a/server/test/knifeServers.test.js b/server/test/knifeServers.test.js
new file mode 100644
index 0000000..82ae1fa
--- /dev/null
+++ b/server/test/knifeServers.test.js
@@ -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);
+});