Merge feature/messer-server in main
This commit is contained in:
commit
0ba60dd207
15 files changed
+717
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -164,16 +164,16 @@ export class DockerServerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async launch({ matchId, mode, secrets, teamNames }, blocked = new Set()) {
|
||||
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
|
||||
const { port, tvPort } = this.allocatePorts(blocked);
|
||||
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||
}
|
||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||
const env = {
|
||||
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
||||
CS2_SERVERNAME: this.serverName(matchId, teamNames),
|
||||
CS2_SERVERNAME: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
|
||||
CS2_PORT: String(port),
|
||||
CS2_RCONPW: secrets.rconPassword,
|
||||
CS2_PW: secrets.serverPassword,
|
||||
@@ -216,7 +216,7 @@ export class DockerServerAdapter {
|
||||
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
||||
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// Messer-Server für die Seitenwahl: Gameserver, die dauerhaft laufen und keinem Match gehören.
|
||||
// Die Teams gehen drauf, spielen ihre Messerrunde und gehen wieder. Nach Spielende oder wenn der Server
|
||||
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
|
||||
|
||||
import { HttpError } from '../matchService.js';
|
||||
import { randomPassword } from '../security.js';
|
||||
|
||||
const SLOW_POLL_MS = 10_000;
|
||||
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
|
||||
const MAX_RESTARTS = 5;
|
||||
|
||||
/**
|
||||
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
||||
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
||||
*/
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
||||
return {
|
||||
map,
|
||||
beforeMapChange: [
|
||||
`game_type ${Number(pool.gameType)}`,
|
||||
`game_mode ${Number(pool.gameMode)}`,
|
||||
'sv_password ""',
|
||||
'tv_enable 0',
|
||||
`changelevel ${map}`,
|
||||
],
|
||||
afterMapChange: [
|
||||
'bot_kick',
|
||||
'bot_quota 0',
|
||||
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
|
||||
'mp_maxrounds 1',
|
||||
'mp_match_can_clinch 1',
|
||||
'mp_match_end_restart 1',
|
||||
'mp_match_restart_delay 10',
|
||||
'mp_halftime 0',
|
||||
'mp_overtime_enable 0',
|
||||
// Nur Messer: keine Startwaffen, kein Geld, nichts zu kaufen
|
||||
'mp_ct_default_primary ""',
|
||||
'mp_t_default_primary ""',
|
||||
'mp_ct_default_secondary ""',
|
||||
'mp_t_default_secondary ""',
|
||||
'mp_ct_default_grenades ""',
|
||||
'mp_t_default_grenades ""',
|
||||
'mp_give_player_c4 0',
|
||||
'mp_startmoney 0',
|
||||
'mp_maxmoney 0',
|
||||
'mp_afterroundmoney 0',
|
||||
'mp_buytime 0',
|
||||
'mp_free_armor 1',
|
||||
'mp_weapons_allow_map_placed 0',
|
||||
'mp_death_drop_gun 0',
|
||||
'mp_death_drop_grenade 0',
|
||||
'mp_freezetime 3',
|
||||
'mp_roundtime 2',
|
||||
'mp_roundtime_defuse 2',
|
||||
'mp_roundtime_hostage 2',
|
||||
'mp_autoteambalance 0',
|
||||
'mp_limitteams 0',
|
||||
'mp_autokick 0',
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
||||
'mp_do_warmup_period 1',
|
||||
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||
'mp_warmup_pausetimer 0',
|
||||
'mp_warmup_start',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
||||
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
||||
|
||||
export class KnifeServers {
|
||||
constructor({ db, adapter, config, pools = {}, logger = console }) {
|
||||
this.db = db;
|
||||
this.pools = pools;
|
||||
this.adapter = adapter;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
this.busy = new Set();
|
||||
this.lastPoll = new Map();
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
rows() {
|
||||
return this.db.prepare('SELECT * FROM knife_servers ORDER BY number').all();
|
||||
}
|
||||
|
||||
row(id) {
|
||||
return this.db.prepare('SELECT * FROM knife_servers WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
update(id, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
this.db.prepare(`UPDATE knife_servers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @updated_at WHERE id = @id`)
|
||||
.run({ ...fields, updated_at: Date.now(), id });
|
||||
}
|
||||
|
||||
/** Ansicht für Admin und Startseite. Ohne RCON-Passwort und Handle. */
|
||||
view(row) {
|
||||
const pool = this.pools[row.mode];
|
||||
const size = pool?.teamSize;
|
||||
return {
|
||||
id: row.id,
|
||||
number: row.number,
|
||||
name: row.name,
|
||||
mode: row.mode,
|
||||
modeLabel: pool?.label ?? row.mode,
|
||||
modeShort: size ? `${size}v${size}` : null,
|
||||
map: row.map,
|
||||
mapName: pool?.maps.find((m) => m.id === row.map)?.name ?? row.map,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
playerCount: row.player_count,
|
||||
restarts: row.restarts,
|
||||
startedAt: row.started_at,
|
||||
connect: row.host && row.port ? `connect ${row.host}:${row.port}` : null,
|
||||
steamUrl: row.host && row.port ? `steam://connect/${row.host}:${row.port}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.rows().map((r) => this.view(r));
|
||||
}
|
||||
|
||||
/** Ports aller Messer-Server auf einem Host (für die Portvergabe zusammen mit den Match-Servern). */
|
||||
usedPorts(host) {
|
||||
const rows = host
|
||||
? this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)
|
||||
: this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL').all();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Modi für Messer-Server (5v5, 2v2) mit ihren Maps, aus den Map-Pools. */
|
||||
modes() {
|
||||
return Object.entries(this.pools).map(([id, pool]) => {
|
||||
const maps = pool.maps.map((m) => ({ id: m.id, name: m.name }));
|
||||
return {
|
||||
id,
|
||||
label: pool.label,
|
||||
teamSize: pool.teamSize,
|
||||
maps,
|
||||
defaultMap: maps.some((m) => m.id === this.config.knife.map) ? this.config.knife.map : maps[0]?.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Neuen Messer-Server starten. Liefert die Ansicht des neuen Servers. */
|
||||
async create({ map, mode = 'competitive' }) {
|
||||
if (this.adapter.name === 'static') throw new HttpError(409, 'Mit festen Servern (static) gibt es keine Messer-Server', 'unsupported');
|
||||
const info = this.modes().find((m) => m.id === mode);
|
||||
if (!info) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
|
||||
const chosen = String(map || info.defaultMap);
|
||||
if (!info.maps.some((m) => m.id === chosen)) throw new HttpError(400, `Map ${chosen} gibt es nicht für ${info.label}`, 'invalid');
|
||||
const max = this.config.knife.maxServers;
|
||||
const count = this.db.prepare('SELECT COUNT(*) AS n FROM knife_servers').get().n;
|
||||
if (count >= max) throw new HttpError(409, `Höchstens ${max} Messer-Server`, 'limit');
|
||||
const used = new Set(this.rows().map((r) => r.number));
|
||||
let number = 1;
|
||||
while (used.has(number)) number += 1;
|
||||
const id = `knife${number}`;
|
||||
const now = Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO knife_servers (id, number, name, mode, map, status, message, rcon_password, restarts, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'starting', 'Server wird gestartet', ?, 0, ?, ?)
|
||||
`).run(id, number, `Messer ${number}`, mode, chosen, randomPassword(24), now, now);
|
||||
await this.launch(id);
|
||||
return this.view(this.row(id));
|
||||
}
|
||||
|
||||
async launch(id) {
|
||||
const row = this.row(id);
|
||||
this.busy.add(id);
|
||||
try {
|
||||
const prefix = this.config.docker?.serverNamePrefix;
|
||||
const size = this.pools[row.mode]?.teamSize;
|
||||
const result = await this.adapter.start({
|
||||
matchId: id,
|
||||
// Wingman: 6 Plätze, Competitive: 12 Plätze (wie bei den Match-Servern)
|
||||
mode: row.mode,
|
||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
||||
});
|
||||
this.update(id, {
|
||||
status: 'starting',
|
||||
message: 'Container läuft, Spielserver lädt',
|
||||
handle: JSON.stringify(result.handle),
|
||||
host: result.host,
|
||||
port: result.port,
|
||||
tv_port: result.tvPort,
|
||||
player_count: null,
|
||||
started_at: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
||||
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(id) {
|
||||
const row = this.row(id);
|
||||
this.busy.add(id);
|
||||
this.update(id, { status: 'configuring', message: `Lade ${row.map}` });
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map: row.map, pool: this.pools[row.mode], warmupSeconds: this.config.knife.warmupSeconds }));
|
||||
this.update(id, { status: 'running', message: 'Bereit für die Messerrunde', restarts: 0 });
|
||||
} catch (err) {
|
||||
this.update(id, { status: 'config_failed', message: `Konfiguration fehlgeschlagen: ${err.message}` });
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async poll() {
|
||||
for (const row of this.rows()) {
|
||||
if (this.busy.has(row.id) || !row.handle) continue;
|
||||
const last = this.lastPoll.get(row.id) ?? 0;
|
||||
if (row.status === 'running' && Date.now() - last < SLOW_POLL_MS) continue;
|
||||
this.lastPoll.set(row.id, Date.now());
|
||||
await this.pollOne(row).catch((err) => this.logger.warn?.(`[messer] ${row.name}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async pollOne(row) {
|
||||
let status;
|
||||
try {
|
||||
status = await this.adapter.status(JSON.parse(row.handle));
|
||||
} catch (err) {
|
||||
status = { state: row.status === 'starting' ? 'starting' : 'running', message: err.message };
|
||||
}
|
||||
if (status.state === 'failed' || status.state === 'stopped') {
|
||||
await this.recover(row, status.message);
|
||||
return;
|
||||
}
|
||||
if (status.state === 'running' && row.status === 'starting') {
|
||||
await this.configure(row.id);
|
||||
return;
|
||||
}
|
||||
if (row.status === 'config_failed' && status.state === 'running') {
|
||||
// Konfiguration noch einmal versuchen, der Server läuft ja
|
||||
await this.configure(row.id);
|
||||
return;
|
||||
}
|
||||
if (row.status !== 'running' || status.playerCount === undefined) return;
|
||||
const players = status.playerCount;
|
||||
if (players !== row.player_count) this.update(row.id, { player_count: players });
|
||||
// Alle sind gegangen: Server für die nächsten Teams zurücksetzen
|
||||
if (row.player_count > 0 && players === 0) await this.reset(row.id, 'Server leer – für die nächsten Teams zurückgesetzt');
|
||||
}
|
||||
|
||||
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
|
||||
async recover(row, reason) {
|
||||
if (row.restarts >= MAX_RESTARTS) {
|
||||
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
|
||||
return;
|
||||
}
|
||||
this.logger.warn?.(`[messer] ${row.name} ausgefallen (${reason}), starte neu`);
|
||||
this.update(row.id, { restarts: row.restarts + 1, status: 'starting', message: `Neustart nach Ausfall (${row.restarts + 1}/${MAX_RESTARTS})` });
|
||||
await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
await this.launch(row.id).catch(() => {});
|
||||
}
|
||||
|
||||
async reset(id, message = 'Spiel zurückgesetzt (Admin)') {
|
||||
const row = this.requireRow(id);
|
||||
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), KNIFE_RESET_COMMANDS);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(id, { message });
|
||||
}
|
||||
|
||||
async startRound(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_end']);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(id, { message: 'Messerrunde läuft (Admin gestartet)' });
|
||||
}
|
||||
|
||||
/** Server komplett neu starten (neuer Container), z. B. wenn er hängt. */
|
||||
async restart(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (this.busy.has(id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
this.update(id, { restarts: 0, status: 'starting', message: 'Neustart durch Admin' });
|
||||
await this.launch(id);
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
this.db.prepare('DELETE FROM knife_servers WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
requireRow(id) {
|
||||
const row = this.row(id);
|
||||
if (!row) throw new HttpError(404, 'Messer-Server nicht gefunden', 'not_found');
|
||||
return row;
|
||||
}
|
||||
|
||||
startLoop() {
|
||||
this.timer = setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
@@ -168,14 +168,20 @@ export class ServerManager {
|
||||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||||
`).all();
|
||||
const knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
|
||||
const stats = this.adapter.hostStats();
|
||||
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||
return stats.map((h, i) => ({
|
||||
...h,
|
||||
disk: disks[i],
|
||||
matches: rows
|
||||
.filter((r) => r.host === h.publicHost)
|
||||
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||
matches: [
|
||||
...rows
|
||||
.filter((r) => r.host === h.publicHost)
|
||||
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||
...knives
|
||||
.filter((k) => k.host === h.publicHost)
|
||||
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -258,12 +264,20 @@ export class ServerManager {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
}
|
||||
// Messer-Server belegen ihre Ports dauerhaft
|
||||
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
usedPorts() {
|
||||
const used = new Set();
|
||||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||
for (const r of this.db.prepare(`
|
||||
SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL
|
||||
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL
|
||||
`).all()) {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
used.add(`${r.host}:${r.port}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user