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}` : ''} +
+
+ + + + +
+
+ ); + })} +
+ )} +
+ + + + {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); +});