Compare commits
14
Commits
35bdfc1f67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7272d09f | ||
|
|
e8e070dfe6 | ||
|
|
123c2f11d7 | ||
|
|
4bce16d084 | ||
|
|
1fddf4d20c | ||
|
|
52c07c88b8 | ||
|
|
69fcf6d15a | ||
|
|
ece21db78a | ||
|
|
7e17a33c3f | ||
|
|
e384279491 | ||
|
|
da54150df6 | ||
|
|
ea37c23350 | ||
|
|
48241f0784 | ||
|
|
e18b8b4430 |
No files matched your search
@@ -394,7 +394,7 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
|||||||
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
||||||
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
||||||
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
||||||
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt.
|
||||||
|
|
||||||
### Beamer-Diashow
|
### Beamer-Diashow
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,6 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<title>CS2 Mapveto – Maxlan e.V.</title>
|
<title>CS2 Mapveto – Maxlan e.V.</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23000'/><path d='M8 6l16 20M24 6L8 26' stroke='%23a72a1c' stroke-width='5' stroke-linecap='round'/></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23000'/><path d='M8 6l16 20M24 6L8 26' stroke='%23a72a1c' stroke-width='5' stroke-linecap='round'/></svg>" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/barlow": "5.3.0",
|
||||||
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
// Schriften aus dem eigenen Build statt von Google Fonts: keine Verbindung zu Google, funktioniert auch ohne Internet
|
||||||
|
import '@fontsource/barlow/latin-400.css';
|
||||||
|
import '@fontsource/barlow/latin-500.css';
|
||||||
|
import '@fontsource/barlow/latin-600.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-500.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-600.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-700.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default function Overview() {
|
|||||||
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
|
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
|
||||||
const running = (matches ?? []).filter((m) => m.group === 'running');
|
const running = (matches ?? []).filter((m) => m.group === 'running');
|
||||||
const open = (matches ?? []).filter((m) => m.group === 'open');
|
const open = (matches ?? []).filter((m) => m.group === 'open');
|
||||||
const closed = (matches ?? []).filter((m) => m.group === 'closed');
|
// Nur echte Ergebnisse: abgebrochene Matches haben keins und stehen nicht auf der Startseite
|
||||||
|
const closed = (matches ?? []).filter((m) => m.group === 'closed' && m.status === 'finished');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview home">
|
<div className="overview home">
|
||||||
|
|||||||
Generated
+20
@@ -23,6 +23,8 @@
|
|||||||
"name": "@cs2-mapveto/client",
|
"name": "@cs2-mapveto/client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/barlow": "5.3.0",
|
||||||
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
@@ -725,6 +727,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/barlow": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/barlow-condensed": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.33.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
}
|
}
|
||||||
const token = randomToken();
|
const token = randomToken();
|
||||||
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||||||
|
// Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst
|
||||||
|
for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t);
|
||||||
sessions.set(token, expiresAt);
|
sessions.set(token, expiresAt);
|
||||||
return res.json({ token, expiresAt });
|
return res.json({ token, expiresAt });
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
|||||||
|
|
||||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||||
router.get('/stream/overview', wrap((req) => {
|
router.get('/stream/overview', wrap((req) => {
|
||||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
// Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
|
||||||
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : '');
|
||||||
}
|
if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||||
return service.streamOverview();
|
return service.streamOverview({ matchId: access.matchId });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/matches', limit('create'), wrap((req) => {
|
router.post('/matches', limit('create'), wrap((req) => {
|
||||||
|
|||||||
+18
-7
@@ -9,16 +9,26 @@
|
|||||||
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
||||||
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
||||||
|
|
||||||
|
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
|
||||||
|
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
|
||||||
const RE = {
|
const RE = {
|
||||||
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
|
||||||
warmupStart: /World triggered "Warmup_Start"/,
|
warmupStart: /^World triggered "Warmup_Start"$/,
|
||||||
worldRoundEnd: /World triggered "Round_End"/,
|
worldRoundEnd: /^World triggered "Round_End"$/,
|
||||||
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||||
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/,
|
||||||
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
||||||
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
|
gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
|
||||||
|
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
|
||||||
|
|
||||||
|
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
|
||||||
|
export function logPayload(line) {
|
||||||
|
return String(line).trim().replace(PREFIX, '');
|
||||||
|
}
|
||||||
|
|
||||||
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||||
const REASONS = {
|
const REASONS = {
|
||||||
Target_Bombed: 'bomb',
|
Target_Bombed: 'bomb',
|
||||||
@@ -85,7 +95,8 @@ export function applyLogLines(state, lines, ctx) {
|
|||||||
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const raw of lines) {
|
||||||
|
const line = logPayload(raw);
|
||||||
let m;
|
let m;
|
||||||
if ((m = RE.matchStart.exec(line))) {
|
if ((m = RE.matchStart.exec(line))) {
|
||||||
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
import { trimLiveState } from './rounds.js';
|
import { trimLiveState } from './rounds.js';
|
||||||
@@ -696,6 +697,10 @@ export class MatchService extends EventEmitter {
|
|||||||
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
|
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
|
||||||
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
|
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
|
||||||
this.chatRate.set(who, [...recent, now]);
|
this.chatRate.set(who, [...recent, now]);
|
||||||
|
// Gelegentlich alte Einträge anderer Spieler entfernen
|
||||||
|
if (this.chatRate.size > 500) {
|
||||||
|
for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
|
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
@@ -823,8 +828,30 @@ export class MatchService extends EventEmitter {
|
|||||||
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
||||||
* keine Codes, keine Verbindungsdaten.
|
* keine Codes, keine Verbindungsdaten.
|
||||||
*/
|
*/
|
||||||
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
/** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
|
||||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
matchStreamKey(matchId) {
|
||||||
|
const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22);
|
||||||
|
return `${matchId}.${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang.
|
||||||
|
* Ohne STREAM_KEY ist die Übersicht offen.
|
||||||
|
*/
|
||||||
|
streamAccess(key) {
|
||||||
|
if (!this.config.streamKey) return { matchId: null };
|
||||||
|
const value = String(key ?? '');
|
||||||
|
if (safeEqual(value, this.config.streamKey)) return { matchId: null };
|
||||||
|
const dot = value.lastIndexOf('.');
|
||||||
|
const matchId = dot > 0 ? value.slice(0, dot) : '';
|
||||||
|
if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) {
|
||||||
|
const rows = matchId
|
||||||
|
? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId)
|
||||||
|
: this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const active = [];
|
const active = [];
|
||||||
const upcoming = [];
|
const upcoming = [];
|
||||||
@@ -1047,7 +1074,7 @@ export class MatchService extends EventEmitter {
|
|||||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
||||||
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-11
@@ -1,5 +1,8 @@
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import { safeEqual } from './security.js';
|
// Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde
|
||||||
|
const SOCKET_BURST = 20;
|
||||||
|
const SOCKET_REFILL = 2;
|
||||||
|
const SOCKET_REFILL_MS = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
||||||
@@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
let streamTimer = null;
|
let streamTimer = null;
|
||||||
const broadcastStream = () => {
|
const broadcastStream = () => {
|
||||||
clearTimeout(streamTimer);
|
clearTimeout(streamTimer);
|
||||||
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
|
streamTimer = setTimeout(async () => {
|
||||||
|
io.to('stream-overview').emit('stream:overview', service.streamOverview());
|
||||||
|
// Zugänge mit Match-Schlüssel bekommen nur ihr Match
|
||||||
|
for (const s of await io.in('stream-match').fetchSockets()) {
|
||||||
|
s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch }));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
service.on('changed', (matchId) => {
|
service.on('changed', (matchId) => {
|
||||||
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('overview:subscribe', () => {
|
// Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden,
|
||||||
|
// und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand).
|
||||||
|
let budget = SOCKET_BURST;
|
||||||
|
const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS);
|
||||||
|
const on = (event, handler) => socket.on(event, (payload) => {
|
||||||
|
if (budget <= 0) {
|
||||||
|
socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
budget -= 1;
|
||||||
|
try {
|
||||||
|
handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[socket] ${event}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on('overview:subscribe', () => {
|
||||||
socket.join('overview');
|
socket.join('overview');
|
||||||
socket.emit('overview', { matches: service.listMatches() });
|
socket.emit('overview', { matches: service.listMatches() });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('stream:subscribe', ({ key } = {}) => {
|
on('stream:subscribe', ({ key }) => {
|
||||||
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
const access = service.streamAccess(typeof key === 'string' ? key : '');
|
||||||
|
if (!access) {
|
||||||
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket.join('stream-overview');
|
socket.leave('stream-overview');
|
||||||
socket.emit('stream:overview', service.streamOverview());
|
socket.leave('stream-match');
|
||||||
|
// Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches
|
||||||
|
socket.data.streamMatch = access.matchId ?? null;
|
||||||
|
socket.join(access.matchId ? 'stream-match' : 'stream-overview');
|
||||||
|
socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
on('match:subscribe', ({ matchId, token, playerToken }) => {
|
||||||
if (typeof matchId !== 'string') return;
|
if (typeof matchId !== 'string' || matchId.length > 64) return;
|
||||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||||
const str = (v) => (typeof v === 'string' ? v : null);
|
const str = (v) => (typeof v === 'string' ? v : null);
|
||||||
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
|
socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
|
||||||
try {
|
try {
|
||||||
service.getMatch(matchId);
|
service.getMatch(matchId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
if (socket.data?.matchId) leave(socket.data.matchId);
|
clearInterval(refill);
|
||||||
|
try {
|
||||||
|
if (socket.data?.matchId) leave(socket.data.matchId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[socket] disconnect: ${err.message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+28
-6
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
|
|||||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile
|
||||||
|
* in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die
|
||||||
|
* Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt,
|
||||||
|
* ebenso alle in `secrets` genannten Werte.
|
||||||
|
*/
|
||||||
|
export function safeErrorMessage(err, secrets = []) {
|
||||||
|
const stderr = String(err?.stderr ?? '').trim();
|
||||||
|
let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler');
|
||||||
|
text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen';
|
||||||
|
text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***');
|
||||||
|
for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***');
|
||||||
|
return text.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
|
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
|
||||||
export function createRateLimiter({ windowMs, max }) {
|
export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
|
||||||
const hits = new Map();
|
const hits = new Map();
|
||||||
return function check(key) {
|
let lastSweep = Date.now();
|
||||||
|
function check(key) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist
|
||||||
|
if (now - lastSweep > windowMs) {
|
||||||
|
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
||||||
|
lastSweep = now;
|
||||||
|
}
|
||||||
const entry = hits.get(key);
|
const entry = hits.get(key);
|
||||||
if (!entry || now - entry.start > windowMs) {
|
if (!entry || now - entry.start > windowMs) {
|
||||||
|
// Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge)
|
||||||
|
if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value);
|
||||||
hits.set(key, { start: now, count: 1 });
|
hits.set(key, { start: now, count: 1 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
if (hits.size > 10_000) {
|
|
||||||
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
|
||||||
}
|
|
||||||
return entry.count <= max;
|
return entry.count <= max;
|
||||||
};
|
}
|
||||||
|
check.size = () => hits.size;
|
||||||
|
return check;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Hinweise für den Ingame-Chat (RCON "say"). Absender ist im Spiel "Console".
|
||||||
|
// Reine Textbausteine ohne Server-Zugriff, damit sie sich mit Beispielen testen lassen.
|
||||||
|
|
||||||
|
const MAX_LENGTH = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbcodes für den CS2-Chat (am Server geprüft). Ein Code färbt alles bis zum nächsten Code.
|
||||||
|
* Sie kommen ausschließlich aus den Textbausteinen hier, nie aus Eingaben.
|
||||||
|
*/
|
||||||
|
export const COLOR = {
|
||||||
|
white: '\x01',
|
||||||
|
red: '\x02',
|
||||||
|
green: '\x04',
|
||||||
|
yellow: '\x09',
|
||||||
|
blue: '\x0B',
|
||||||
|
gold: '\x10',
|
||||||
|
};
|
||||||
|
const ALLOWED_CODES = new Set(Object.values(COLOR));
|
||||||
|
// CS2 färbt wie im Spiel: CT blau, T gold
|
||||||
|
const SIDE_COLOR = { CT: COLOR.blue, T: COLOR.gold };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben (Teamnamen, Map-Namen) für den Chat: ohne Anführungszeichen, Semikolon, Zeilenumbrüche und ohne
|
||||||
|
* Steuerzeichen, sodass sich weder Befehle anhängen noch eigene Farben einbauen lassen.
|
||||||
|
* Doppelpunkt und Komma bleiben für Spielstände erlaubt.
|
||||||
|
*/
|
||||||
|
export function chatText(text, maxLength = MAX_LENGTH) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/["';\\\r\n\t]/g, '')
|
||||||
|
.replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dauer für den Chat: unter zwei Minuten in Sekunden, sonst gerundet in Minuten. */
|
||||||
|
function duration(seconds) {
|
||||||
|
const s = Math.max(0, Math.round(seconds));
|
||||||
|
return s < 120 ? `${s} s` : `etwa ${Math.round(s / 60)} Minuten`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gefärbter Teil einer Zeile; danach geht es in Weiß weiter. */
|
||||||
|
const paint = (color, text) => `${color}${text}${COLOR.white}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konsolenbefehle für eine Liste von Hinweisen. Erlaubt sind nur die Farbcodes aus COLOR; alles, was einen
|
||||||
|
* weiteren Befehl einleiten könnte, fällt weg. Das Leerzeichen am Anfang braucht CS2, damit der erste Farbcode wirkt.
|
||||||
|
*/
|
||||||
|
export function sayCommands(lines) {
|
||||||
|
return lines
|
||||||
|
// Kein trim(): Gelb (\x09) und Blau (\x0B) zählen für JavaScript als Leerraum und gingen sonst verloren
|
||||||
|
.map((line) => [...String(line ?? '')]
|
||||||
|
.filter((ch) => ALLOWED_CODES.has(ch) || !/[\x00-\x1F\x7F"';\\]/.test(ch))
|
||||||
|
.join(''))
|
||||||
|
.filter((line) => line.replace(/[\x00-\x1F]/g, '').trim())
|
||||||
|
.map((line) => `say ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const other = (team) => (team === 'A' ? 'B' : 'A');
|
||||||
|
const name = (teams, team) => chatText(teams[team], 32) || 'Team';
|
||||||
|
|
||||||
|
/** Teamname in der Farbe seiner Startseite. */
|
||||||
|
const sided = (teams, team, ctTeam) => (ctTeam ? paint(SIDE_COLOR[team === ctTeam ? 'CT' : 'T'], name(teams, team)) : name(teams, team));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweise nach dem Laden einer Map: welche Map, wer sie gewählt hat, wer welche Seite startet,
|
||||||
|
* Serienstand und dass die Aufwärmphase bis zur Freigabe läuft.
|
||||||
|
* @param {object} p
|
||||||
|
* @param {{A: string, B: string}} p.teams Teamnamen
|
||||||
|
* @param {{number: number, mapName: string, pickedBy: 'A'|'B'|null, sideChosenBy: 'A'|'B'|null, ctTeam: 'A'|'B'|null}} p.map
|
||||||
|
* @param {number} p.total Maps in der Serie
|
||||||
|
* @param {{A: number, B: number}} p.series Serienstand vor dieser Map
|
||||||
|
*/
|
||||||
|
export function mapLoadedLines({ teams, map, total, series }) {
|
||||||
|
const lines = [];
|
||||||
|
const mapName = chatText(map.mapName, 32);
|
||||||
|
const head = total > 1 ? `Map ${map.number} von ${total}: ${mapName}` : `Map: ${mapName}`;
|
||||||
|
lines.push(map.pickedBy ? `${head} - gewählt von ${name(teams, map.pickedBy)}` : `${head} - Entscheidungsmap`);
|
||||||
|
if (map.ctTeam) {
|
||||||
|
const t = other(map.ctTeam);
|
||||||
|
const chooser = map.sideChosenBy ? `${name(teams, map.sideChosenBy)} hat die Seite gewählt: ` : '';
|
||||||
|
lines.push(`${chooser}${sided(teams, map.ctTeam, map.ctTeam)} startet als ${paint(COLOR.blue, 'CT')}, ${sided(teams, t, map.ctTeam)} als ${paint(COLOR.gold, 'T')}`);
|
||||||
|
}
|
||||||
|
if (total > 1) lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
|
||||||
|
lines.push(paint(COLOR.yellow, 'Aufwärmphase - das Match startet, sobald der Organisator es freigibt'));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hinweis beim Start des Matches (Aufwärmphase beendet). */
|
||||||
|
export function matchStartLines({ teams, ctTeam }) {
|
||||||
|
const live = paint(COLOR.green, 'Match live!');
|
||||||
|
if (!ctTeam) return [`${live} ${name(teams, 'A')} gegen ${name(teams, 'B')} - viel Glück, have fun`];
|
||||||
|
return [`${live} ${sided(teams, ctTeam, ctTeam)} (CT) gegen ${sided(teams, other(ctTeam), ctTeam)} (T) - viel Glück, have fun`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweise am Ende einer Map: Sieger mit Stand, Serienstand und was als Nächstes passiert.
|
||||||
|
* @param {object} p
|
||||||
|
* @param {{mapName: string, number: number, winner: 'A'|'B'|null, score: {A: number, B: number}}} p.map
|
||||||
|
* @param {{A: number, B: number}} p.series Serienstand nach dieser Map
|
||||||
|
* @param {'A'|'B'|null} p.decided Sieger der Serie, wenn entschieden
|
||||||
|
* @param {string|null} p.nextMapName nächste Map, wenn es eine gibt
|
||||||
|
* @param {number} p.autoAdvanceSeconds Wartezeit bis zum automatischen Laden (< 0 = aus)
|
||||||
|
* @param {number} p.total Maps in der Serie
|
||||||
|
* @param {number} [p.tvDelaySeconds] CSTV-Verzögerung (0 = keine). CS2 wechselt die Map erst, wenn CSTV durch ist
|
||||||
|
* (tv_delaymapchange), und am Serienende läuft der Server so lange weiter.
|
||||||
|
* @param {number} [p.shutdownSeconds] am Serienende: so lange läuft der Server noch (null = unbekannt)
|
||||||
|
*/
|
||||||
|
export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total, tvDelaySeconds = 0, shutdownSeconds = null }) {
|
||||||
|
const lines = [];
|
||||||
|
const score = `${map.score.A}:${map.score.B}`;
|
||||||
|
const mapName = chatText(map.mapName, 32);
|
||||||
|
lines.push(map.winner
|
||||||
|
? `Map ${map.number} (${mapName}) geht an ${paint(COLOR.green, name(teams, map.winner))} mit ${score}`
|
||||||
|
: `Map ${map.number} (${mapName}) endet ${score}`);
|
||||||
|
if (decided) {
|
||||||
|
const s = decided === 'A' ? `${series.A}:${series.B}` : `${series.B}:${series.A}`;
|
||||||
|
lines.push(total > 1
|
||||||
|
? `${paint(COLOR.green, `${name(teams, decided)} gewinnt die Serie ${s}`)} - danke fürs Spielen!`
|
||||||
|
: 'Danke fürs Spielen!');
|
||||||
|
if (tvDelaySeconds > 0) {
|
||||||
|
lines.push(shutdownSeconds
|
||||||
|
? `Der Server bleibt noch ${duration(shutdownSeconds)} für die CSTV-Übertragung online und schaltet sich dann ab.`
|
||||||
|
: 'Der Server bleibt für die CSTV-Übertragung noch online und schaltet sich dann ab.');
|
||||||
|
}
|
||||||
|
} else if (nextMapName) {
|
||||||
|
const next = chatText(nextMapName, 32);
|
||||||
|
lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
|
||||||
|
// Die Map wechselt frühestens nach der Wartezeit der App und erst, wenn CSTV das Ende übertragen hat
|
||||||
|
const wait = Math.max(autoAdvanceSeconds, tvDelaySeconds > 0 ? tvDelaySeconds : 0);
|
||||||
|
const viaTv = tvDelaySeconds > autoAdvanceSeconds ? ' (nach der CSTV-Übertragung)' : '';
|
||||||
|
lines.push(autoAdvanceSeconds >= 0
|
||||||
|
? `Nächste Map: ${next}, wird in ${viaTv && wait < 120 ? 'etwa ' : ''}${duration(wait)} geladen${viaTv} - bleibt auf dem Server`
|
||||||
|
: `Nächste Map: ${next} - bleibt auf dem Server`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
|||||||
'mp_match_can_clinch 1',
|
'mp_match_can_clinch 1',
|
||||||
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||||
'mp_endmatch_votenextmap 0',
|
'mp_endmatch_votenextmap 0',
|
||||||
|
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
|
||||||
|
'sv_deltaticks_enforce 0',
|
||||||
|
// Nach dem Laden noch einmal: die Spielmodus-Datei der Map setzt tv_delay beim Laden zurück
|
||||||
|
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
||||||
'mp_autoteambalance 0',
|
'mp_autoteambalance 0',
|
||||||
'mp_limitteams 0',
|
'mp_limitteams 0',
|
||||||
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
|
|||||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
import { sanitizeConsoleText } from './cs2Config.js';
|
import { sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
import { safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
@@ -149,8 +150,16 @@ export class DockerServerAdapter {
|
|||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
if (this.runDocker) return this.runDocker(args, this.options.name);
|
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
try {
|
||||||
return stdout.trim();
|
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
||||||
|
return stdout.trim();
|
||||||
|
} catch (err) {
|
||||||
|
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
|
||||||
|
const clean = new Error(safeErrorMessage(err));
|
||||||
|
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
|
||||||
|
clean.code = err.code;
|
||||||
|
throw clean;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allocatePorts(blocked = new Set()) {
|
allocatePorts(blocked = new Set()) {
|
||||||
@@ -239,11 +248,11 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
|
async launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, blocked = new Set()) {
|
||||||
const { port, tvPort } = this.allocatePorts(blocked);
|
const { port, tvPort } = this.allocatePorts(blocked);
|
||||||
const lan = (this.options.lanPorts ?? []).includes(port);
|
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||||
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
|
||||||
}
|
}
|
||||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||||
const env = {
|
const env = {
|
||||||
@@ -257,9 +266,13 @@ export class DockerServerAdapter {
|
|||||||
CS2_GAMETYPE: '0',
|
CS2_GAMETYPE: '0',
|
||||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||||
CS2_SERVER_HIBERNATE: '0',
|
CS2_SERVER_HIBERNATE: '0',
|
||||||
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
// -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
|
||||||
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
// sv_deltaticks_enforce 0: sonst trennt CS2 Spieler und CSTV mit NETWORK_DISCONNECT_BADDELTATICK
|
||||||
|
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files +sv_deltaticks_enforce 0',
|
||||||
TV_ENABLE: '1',
|
TV_ENABLE: '1',
|
||||||
|
// Das Image schreibt TV_DELAY beim Start in alle gamemode_*.cfg (Standard 0), und die laufen bei jedem
|
||||||
|
// Laden einer Map. Ohne den Wert hier stünde CSTV nach jedem Mapwechsel wieder ohne Verzögerung da.
|
||||||
|
TV_DELAY: String(Math.max(0, Number(tvDelaySeconds) || 0)),
|
||||||
TV_PORT: String(tvPort),
|
TV_PORT: String(tvPort),
|
||||||
TV_PW: secrets.tvPassword,
|
TV_PW: secrets.tvPassword,
|
||||||
TV_RELAY_PW: secrets.tvPassword,
|
TV_RELAY_PW: secrets.tvPassword,
|
||||||
@@ -291,7 +304,7 @@ export class DockerServerAdapter {
|
|||||||
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
// 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;
|
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
|
// 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 { HttpError } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
const SLOW_POLL_MS = 10_000;
|
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
|
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
|
||||||
@@ -32,6 +32,8 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
|||||||
'mp_match_can_clinch 1',
|
'mp_match_can_clinch 1',
|
||||||
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||||
'mp_endmatch_votenextmap 0',
|
'mp_endmatch_votenextmap 0',
|
||||||
|
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
|
||||||
|
'sv_deltaticks_enforce 0',
|
||||||
'mp_match_end_restart 1',
|
'mp_match_end_restart 1',
|
||||||
'mp_match_restart_delay 10',
|
'mp_match_restart_delay 10',
|
||||||
'mp_halftime 0',
|
'mp_halftime 0',
|
||||||
@@ -207,7 +209,14 @@ export class KnifeServers {
|
|||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||||
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
||||||
|
// Gleicher Wert wie bei den Match-Servern: das Image schreibt ihn in die gemeinsamen Spielmodus-Dateien
|
||||||
|
tvDelaySeconds: this.config.match?.tvDelaySeconds,
|
||||||
});
|
});
|
||||||
|
// Während des Starts entfernt: Container gleich wieder abbauen
|
||||||
|
if (!this.row(id)) {
|
||||||
|
await this.adapter.stop(result.handle).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.update(id, {
|
this.update(id, {
|
||||||
status: 'starting',
|
status: 'starting',
|
||||||
message: 'Container läuft, Spielserver lädt',
|
message: 'Container läuft, Spielserver lädt',
|
||||||
@@ -219,8 +228,9 @@ export class KnifeServers {
|
|||||||
started_at: Date.now(),
|
started_at: Date.now(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
const message = safeErrorMessage(err, [row.rcon_password]);
|
||||||
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` });
|
||||||
|
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed');
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(id);
|
this.busy.delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ export class RconClient {
|
|||||||
|
|
||||||
onData(chunk) {
|
onData(chunk) {
|
||||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
const { packets, rest } = decodePackets(this.buffer);
|
let decoded;
|
||||||
this.buffer = rest;
|
try {
|
||||||
for (const packet of packets) for (const listener of this.listeners) listener(packet);
|
decoded = decodePackets(this.buffer);
|
||||||
|
} catch (err) {
|
||||||
|
// Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.socket?.destroy(new RconError(err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffer = decoded.rest;
|
||||||
|
for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate() {
|
authenticate() {
|
||||||
@@ -105,15 +113,18 @@ export class RconClient {
|
|||||||
const id = this.nextId++;
|
const id = this.nextId++;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
let answered = false;
|
||||||
let quietTimer = null;
|
let quietTimer = null;
|
||||||
const timer = setTimeout(() => finish(), this.timeoutMs);
|
// Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg
|
||||||
|
const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs);
|
||||||
const listener = (packet) => {
|
const listener = (packet) => {
|
||||||
if (packet.id !== id) return;
|
if (packet.id !== id) return;
|
||||||
|
answered = true;
|
||||||
body += packet.body;
|
body += packet.body;
|
||||||
clearTimeout(quietTimer);
|
clearTimeout(quietTimer);
|
||||||
quietTimer = setTimeout(finish, quietMs);
|
quietTimer = setTimeout(finish, quietMs);
|
||||||
};
|
};
|
||||||
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
|
const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen'));
|
||||||
const finish = (err) => {
|
const finish = (err) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
clearTimeout(quietTimer);
|
clearTimeout(quietTimer);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
|
|||||||
try {
|
try {
|
||||||
await runRcon(target, plan.beforeMapChange);
|
await runRcon(target, plan.beforeMapChange);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
|
// Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
|
||||||
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
|
// beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten.
|
||||||
|
if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadline = Date.now() + loadTimeoutMs;
|
const deadline = Date.now() + loadTimeoutMs;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||||
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
||||||
|
import { mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from './chatAnnouncements.js';
|
||||||
|
import { seriesScore } from '../liveScore.js';
|
||||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
export const SERVER_STATUS = {
|
export const SERVER_STATUS = {
|
||||||
starting: 'Server startet',
|
starting: 'Server startet',
|
||||||
@@ -21,6 +23,11 @@ const SLOW_POLL_MS = 30_000;
|
|||||||
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
|
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
|
||||||
const KNIFE_RETRIES = 3;
|
const KNIFE_RETRIES = 3;
|
||||||
const KNIFE_RETRY_DELAY_MS = 10_000;
|
const KNIFE_RETRY_DELAY_MS = 10_000;
|
||||||
|
// Hinweise im Ingame-Chat während der Aufwärmphase wiederholen (für Spieler, die später verbinden)
|
||||||
|
const WARMUP_REPEAT_MS = 60_000;
|
||||||
|
const WARMUP_REPEATS = 10;
|
||||||
|
// Nach der CSTV-Verzögerung noch so lange laufen lassen, damit auch der Endstand übertragen wird
|
||||||
|
const CSTV_GRACE_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
|
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
|
||||||
@@ -35,6 +42,11 @@ export class ServerManager {
|
|||||||
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
|
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
|
||||||
this.knifeAttempts = new Map();
|
this.knifeAttempts = new Map();
|
||||||
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
|
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
|
||||||
|
this.warmupRepeatMs = WARMUP_REPEAT_MS;
|
||||||
|
this.warmupTimers = new Map();
|
||||||
|
// Nach Match-Ende: Server laufen bis zum Ende der CSTV-Übertragung weiter (matchId -> Timer)
|
||||||
|
this.closeTimers = new Map();
|
||||||
|
this.cstvGraceMs = CSTV_GRACE_MS;
|
||||||
this.lastPoll = new Map();
|
this.lastPoll = new Map();
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -42,7 +54,7 @@ export class ServerManager {
|
|||||||
init() {
|
init() {
|
||||||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||||||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||||||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
this.service.on('closed', (id) => this.stopAfterClose(id));
|
||||||
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +64,7 @@ export class ServerManager {
|
|||||||
*/
|
*/
|
||||||
onMapEnded({ matchId, number, decided, hasNext }) {
|
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||||
const seconds = this.config.autoAdvanceSeconds;
|
const seconds = this.config.autoAdvanceSeconds;
|
||||||
|
this.announceMapEnd(matchId, number, decided, hasNext);
|
||||||
if (seconds < 0) return;
|
if (seconds < 0) return;
|
||||||
clearTimeout(this.autoAdvance.get(matchId));
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
const finish = Boolean(decided) || !hasNext;
|
const finish = Boolean(decided) || !hasNext;
|
||||||
@@ -155,6 +168,10 @@ export class ServerManager {
|
|||||||
this.autoAdvance.clear();
|
this.autoAdvance.clear();
|
||||||
this.timers.forEach(clearInterval);
|
this.timers.forEach(clearInterval);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
for (const t of this.warmupTimers.values()) clearInterval(t);
|
||||||
|
this.warmupTimers.clear();
|
||||||
|
for (const t of this.closeTimers.values()) clearTimeout(t);
|
||||||
|
this.closeTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
row(matchId) {
|
row(matchId) {
|
||||||
@@ -343,13 +360,23 @@ export class ServerManager {
|
|||||||
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
|
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
|
||||||
|
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
|
const attempt = (previous?.attempts ?? 0) + 1;
|
||||||
try {
|
try {
|
||||||
const result = await this.adapter.start({
|
const result = await this.adapter.start({
|
||||||
matchId,
|
matchId,
|
||||||
mode: match.mode,
|
mode: match.mode,
|
||||||
secrets,
|
secrets,
|
||||||
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
||||||
|
tvDelaySeconds: this.config.match.tvDelaySeconds,
|
||||||
});
|
});
|
||||||
|
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
|
||||||
|
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
|
||||||
|
const current = this.row(matchId);
|
||||||
|
if (!current || current.status !== 'starting' || current.attempts !== attempt) {
|
||||||
|
this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`);
|
||||||
|
await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
handle: JSON.stringify(result.handle),
|
handle: JSON.stringify(result.handle),
|
||||||
host: result.host,
|
host: result.host,
|
||||||
@@ -358,8 +385,9 @@ export class ServerManager {
|
|||||||
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(matchId, { status: 'start_failed', message: err.message });
|
const message = safeErrorMessage(err, Object.values(secrets));
|
||||||
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
|
if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message });
|
||||||
|
if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(matchId);
|
this.busy.delete(matchId);
|
||||||
@@ -433,6 +461,82 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Hinweise im Ingame-Chat ----------
|
||||||
|
|
||||||
|
teamNames(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
return { A: match.team_a_name, B: match.team_b_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSeriesMap(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
return this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schickt Hinweise per "say" an den eingerichteten Server. Fehler stören den Ablauf nie. */
|
||||||
|
announce(matchId, lines) {
|
||||||
|
const row = this.row(matchId);
|
||||||
|
if (!row?.handle || row.status !== 'configured' || !this.adapter.sendCommands || !lines.length) return;
|
||||||
|
this.adapter.sendCommands(JSON.parse(row.handle), sayCommands(lines))
|
||||||
|
.catch((err) => this.logger.warn?.(`[chat] Hinweis an ${matchId} nicht gesendet: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach dem Laden einer Map: Map, Wahl, Seiten, Serienstand. Wiederholt sich, solange die Aufwärmphase läuft. */
|
||||||
|
announceMapLoaded(matchId, number) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const maps = this.service.seriesMaps(matchId);
|
||||||
|
const map = maps.find((m) => m.number === number);
|
||||||
|
if (!map) return;
|
||||||
|
const lines = mapLoadedLines({
|
||||||
|
teams: this.teamNames(matchId),
|
||||||
|
map: { number, mapName: this.service.mapName(match, map.map), pickedBy: map.picked_by, sideChosenBy: map.side_chosen_by, ctTeam: map.ct_team },
|
||||||
|
total: maps.length,
|
||||||
|
series: seriesScore(maps),
|
||||||
|
});
|
||||||
|
this.announce(matchId, lines);
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
let left = WARMUP_REPEATS;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const current = this.currentSeriesMap(matchId);
|
||||||
|
const live = current?.live ? JSON.parse(current.live) : null;
|
||||||
|
if (left-- <= 0 || current?.number !== number || live?.warmupEnded || this.row(matchId)?.status !== 'configured') {
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.announce(matchId, lines);
|
||||||
|
}, this.warmupRepeatMs);
|
||||||
|
this.warmupTimers.set(matchId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWarmupRepeat(matchId) {
|
||||||
|
clearInterval(this.warmupTimers.get(matchId));
|
||||||
|
this.warmupTimers.delete(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Am Map-Ende: Sieger, Serienstand, nächste Map oder Seriensieger. */
|
||||||
|
announceMapEnd(matchId, number, decided, hasNext) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const maps = this.service.seriesMaps(matchId);
|
||||||
|
const map = maps.find((m) => m.number === number);
|
||||||
|
if (!map) return;
|
||||||
|
const next = hasNext && !decided ? maps.find((m) => m.number === number + 1) : null;
|
||||||
|
const tvDelay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
|
||||||
|
this.announce(matchId, mapEndLines({
|
||||||
|
teams: this.teamNames(matchId),
|
||||||
|
map: { number, mapName: this.service.mapName(match, map.map), winner: map.winner, score: { A: map.score_a, B: map.score_b } },
|
||||||
|
series: seriesScore(maps),
|
||||||
|
decided,
|
||||||
|
nextMapName: next ? this.service.mapName(match, next.map) : null,
|
||||||
|
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||||
|
total: maps.length,
|
||||||
|
tvDelaySeconds: tvDelay,
|
||||||
|
// Serienende: Match schließt nach der Wartezeit, der Server läuft dann noch CSTV-Verzögerung + Puffer
|
||||||
|
shutdownSeconds: decided && this.config.autoAdvanceSeconds >= 0 && tvDelay > 0
|
||||||
|
? this.config.autoAdvanceSeconds + tvDelay + this.cstvGraceMs / 1000
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
|
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
|
||||||
wantsKnife(match, row) {
|
wantsKnife(match, row) {
|
||||||
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
||||||
@@ -536,8 +640,9 @@ export class ServerManager {
|
|||||||
try {
|
try {
|
||||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(matchId, { status: 'config_failed', message: err.message });
|
const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
|
||||||
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
|
this.update(matchId, { status: 'config_failed', message });
|
||||||
|
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
@@ -552,6 +657,7 @@ export class ServerManager {
|
|||||||
this.service.markCurrentMapLive(matchId);
|
this.service.markCurrentMapLive(matchId);
|
||||||
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
|
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
|
||||||
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
|
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
|
||||||
|
this.announceMapLoaded(matchId, seriesMap.number);
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(matchId);
|
this.busy.delete(matchId);
|
||||||
}
|
}
|
||||||
@@ -611,6 +717,9 @@ export class ServerManager {
|
|||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
}
|
}
|
||||||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||||||
|
this.stopWarmupRepeat(viewer.matchId);
|
||||||
|
const current = this.currentSeriesMap(viewer.matchId);
|
||||||
|
this.announce(viewer.matchId, matchStartLines({ teams: this.teamNames(viewer.matchId), ctTeam: current?.ct_team ?? null }));
|
||||||
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
||||||
const fields = [];
|
const fields = [];
|
||||||
if (this.config.match.tvEnabled && row.tv_port) {
|
if (this.config.match.tvEnabled && row.tv_port) {
|
||||||
@@ -677,8 +786,32 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match beendet: Lief es bis zum Ende und ist CSTV mit Verzögerung an, bleibt der Server so lange stehen,
|
||||||
|
* bis die Zuschauer das Ende gesehen haben. CSTV läuft im selben Container und endet mit ihm.
|
||||||
|
*/
|
||||||
|
stopAfterClose(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const row = this.row(matchId);
|
||||||
|
const delay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
|
||||||
|
if (match.status !== 'finished' || delay <= 0 || row?.status !== 'configured') {
|
||||||
|
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const waitMs = delay * 1000 + this.cstvGraceMs;
|
||||||
|
this.service.log(matchId, 'server', `Server läuft noch ${Math.round(waitMs / 1000)} s weiter, damit CSTV das Ende überträgt`);
|
||||||
|
clearTimeout(this.closeTimers.get(matchId));
|
||||||
|
this.closeTimers.set(matchId, setTimeout(() => {
|
||||||
|
this.closeTimers.delete(matchId);
|
||||||
|
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
|
||||||
|
}, waitMs));
|
||||||
|
}
|
||||||
|
|
||||||
async stop(matchId, reason) {
|
async stop(matchId, reason) {
|
||||||
this.simulator.stop(matchId);
|
this.simulator.stop(matchId);
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
clearTimeout(this.closeTimers.get(matchId));
|
||||||
|
this.closeTimers.delete(matchId);
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (!row || row.status === 'stopped') return;
|
if (!row || row.status === 'stopped') return;
|
||||||
if (row.handle) await this.safeStop(row);
|
if (row.handle) await this.safeStop(row);
|
||||||
@@ -710,7 +843,7 @@ export class ServerManager {
|
|||||||
if (this.busy.has(row.match_id)) continue;
|
if (this.busy.has(row.match_id)) continue;
|
||||||
const match = this.service.getMatch(row.match_id);
|
const match = this.service.getMatch(row.match_id);
|
||||||
if (CLOSED_STATUSES.includes(match.status)) {
|
if (CLOSED_STATUSES.includes(match.status)) {
|
||||||
await this.stop(row.match_id, 'Match beendet');
|
if (!this.closeTimers.has(row.match_id)) await this.stop(row.match_id, 'Match beendet');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
|
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
return s.status === 'live' && s.currentMapNumber === 2;
|
return s.status === 'live' && s.currentMapNumber === 2;
|
||||||
}, { label: 'Map 2' });
|
}, { label: 'Map 2' });
|
||||||
|
|
||||||
|
// Abschließen von Hand: der Server wartet auf das Ende der CSTV-Übertragung (hier kurz gehalten)
|
||||||
|
app.manager.config.match.tvDelaySeconds = 1;
|
||||||
|
app.manager.cstvGraceMs = 200;
|
||||||
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
|
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
|
||||||
|
assert.notEqual(app.service.serverRow(id).status, 'stopped', 'läuft für CSTV noch');
|
||||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
||||||
const list = (await call('GET', '/matches')).body.matches;
|
const list = (await call('GET', '/matches')).body.matches;
|
||||||
assert.equal(list.find((m) => m.id === id).group, 'closed');
|
assert.equal(list.find((m) => m.id === id).group, 'closed');
|
||||||
@@ -339,9 +343,15 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
|
|||||||
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
||||||
assert.equal(app.service.getMatch(id).status, 'live');
|
assert.equal(app.service.getMatch(id).status, 'live');
|
||||||
|
|
||||||
|
// CSTV-Verzögerung kurz halten: der Server soll erst danach gestoppt werden
|
||||||
|
app.manager.config.match.tvDelaySeconds = 1;
|
||||||
|
app.manager.cstvGraceMs = 300;
|
||||||
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
||||||
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
||||||
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'configured', 'Server läuft für CSTV noch weiter');
|
||||||
|
await app.manager.reap();
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'configured', 'auch der Aufräum-Lauf wartet auf CSTV');
|
||||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
||||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
||||||
@@ -831,3 +841,39 @@ test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende
|
|||||||
await close();
|
await close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Ingame-Chat: Hinweise nach dem Laden, beim Start und am Map-Ende', async () => {
|
||||||
|
const { app, call, close } = await setup();
|
||||||
|
try {
|
||||||
|
const said = [];
|
||||||
|
const send = app.adapter.sendCommands.bind(app.adapter);
|
||||||
|
app.adapter.sendCommands = async (h, commands) => {
|
||||||
|
said.push(...commands.filter((c) => c.startsWith('say ')).map((c) => c.slice(4).replace(/[-]/g, '').trim()));
|
||||||
|
return send(h, commands);
|
||||||
|
};
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const cap = (t) => tokens[snap.teams[t].players.find((p) => p.isCaptain).nickname];
|
||||||
|
await playVeto(call, id, { A: cap('A'), B: cap('B') });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Map 1 eingerichtet' });
|
||||||
|
await waitFor(() => said.some((l) => l.startsWith('Map 1 von 3')), { label: 'Hinweis nach dem Laden' });
|
||||||
|
assert.ok(said.some((l) => / startet als CT, .* als T$/.test(l)), 'Seiten werden genannt');
|
||||||
|
assert.ok(said.includes('Serienstand: Alpha 0:0 Bravo'));
|
||||||
|
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
|
||||||
|
await waitFor(() => said.some((l) => l.startsWith('Match live!')), { label: 'Hinweis beim Start' });
|
||||||
|
|
||||||
|
const map = app.service.seriesMaps(id)[0].map;
|
||||||
|
const key = app.manager.row(id).config_key;
|
||||||
|
const body = ['Team playing "CT": Alpha', 'Team playing "TERRORIST": Bravo', 'World triggered "Match_Start" on "' + map + '"',
|
||||||
|
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', 'World triggered "Round_End"',
|
||||||
|
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`].join('\n');
|
||||||
|
await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
|
||||||
|
await waitFor(() => said.some((l) => /^Map 1 \(.+\) geht an Alpha mit 1:0$/.test(l)), { label: 'Hinweis am Map-Ende' });
|
||||||
|
assert.ok(said.some((l) => l.startsWith('Nächste Map: ')));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chatText, COLOR, mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from '../src/servers/chatAnnouncements.js';
|
||||||
|
|
||||||
|
const teams = { A: 'Nordlicht', B: 'Hafenkante' };
|
||||||
|
// Text ohne Farbcodes, zum Vergleichen des Inhalts
|
||||||
|
const plain = (lines) => lines.map((l) => l.replace(/[-]/g, ''));
|
||||||
|
|
||||||
|
test('Hinweise nach dem Laden: Map, Wahl, Seiten, Serienstand, Aufwärmphase', () => {
|
||||||
|
const lines = mapLoadedLines({
|
||||||
|
teams, total: 3, series: { A: 1, B: 0 },
|
||||||
|
map: { number: 2, mapName: 'Inferno', pickedBy: 'B', sideChosenBy: 'A', ctTeam: 'A' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(plain(lines), [
|
||||||
|
'Map 2 von 3: Inferno - gewählt von Hafenkante',
|
||||||
|
'Nordlicht hat die Seite gewählt: Nordlicht startet als CT, Hafenkante als T',
|
||||||
|
'Serienstand: Nordlicht 1:0 Hafenkante',
|
||||||
|
'Aufwärmphase - das Match startet, sobald der Organisator es freigibt',
|
||||||
|
]);
|
||||||
|
const bo1 = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
|
||||||
|
assert.equal(plain(bo1)[0], 'Map: Nuke - Entscheidungsmap');
|
||||||
|
assert.ok(!bo1.some((l) => l.startsWith('Serienstand')), 'kein Serienstand im BO1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hinweise bei Match-Start und Map-Ende', () => {
|
||||||
|
assert.deepEqual(plain(matchStartLines({ teams, ctTeam: 'B' })), ['Match live! Hafenkante (CT) gegen Nordlicht (T) - viel Glück, have fun']);
|
||||||
|
const next = mapEndLines({
|
||||||
|
teams, total: 3, decided: null, nextMapName: 'Inferno', autoAdvanceSeconds: 30, series: { A: 1, B: 0 },
|
||||||
|
map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(plain(next), [
|
||||||
|
'Map 1 (Mirage) geht an Nordlicht mit 13:9',
|
||||||
|
'Serienstand: Nordlicht 1:0 Hafenkante',
|
||||||
|
'Nächste Map: Inferno, wird in 30 s geladen - bleibt auf dem Server',
|
||||||
|
]);
|
||||||
|
const won = mapEndLines({
|
||||||
|
teams, total: 3, decided: 'B', nextMapName: null, autoAdvanceSeconds: 30, series: { A: 0, B: 2 },
|
||||||
|
map: { number: 2, mapName: 'Nuke', winner: 'B', score: { A: 5, B: 13 } },
|
||||||
|
});
|
||||||
|
assert.equal(plain(won)[1], 'Hafenkante gewinnt die Serie 2:0 - danke fürs Spielen!');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chattexte können keine weiteren Befehle anhängen', () => {
|
||||||
|
assert.equal(chatText('Team"; rcon_password x; say "'), 'Team rcon_password x say');
|
||||||
|
assert.deepEqual(sayCommands(['Hallo; quit', '', 'Stand 13:9']), ['say Hallo quit', 'say Stand 13:9']);
|
||||||
|
assert.ok(chatText('x'.repeat(300)).length <= 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Farben: CT blau, T gold, Sieger grün, Aufwärmphase gelb; Eingaben bringen keine eigenen Farben mit', () => {
|
||||||
|
const loaded = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
|
||||||
|
const sides = loaded.find((l) => l.includes('startet als'));
|
||||||
|
assert.ok(sides.includes(`${COLOR.blue}Hafenkante${COLOR.white}`), 'CT-Team blau');
|
||||||
|
assert.ok(sides.includes(`${COLOR.gold}Nordlicht${COLOR.white}`), 'T-Team gold');
|
||||||
|
assert.ok(loaded.at(-1).startsWith(COLOR.yellow), 'Aufwärmphase gelb');
|
||||||
|
assert.ok(matchStartLines({ teams, ctTeam: 'A' })[0].startsWith(`${COLOR.green}Match live!`));
|
||||||
|
const end = mapEndLines({ teams, total: 1, decided: null, nextMapName: null, autoAdvanceSeconds: 30, series: { A: 1, B: 0 }, map: { number: 1, mapName: 'Nuke', winner: 'A', score: { A: 13, B: 2 } } });
|
||||||
|
assert.ok(end[0].includes(`${COLOR.green}Nordlicht${COLOR.white}`), 'Sieger grün');
|
||||||
|
|
||||||
|
// Farbcodes im Teamnamen werden entfernt, die eigenen Farben bleiben
|
||||||
|
const evil = matchStartLines({ teams: { A: `BöseRot`, B: 'Gut' }, ctTeam: 'A' })[0];
|
||||||
|
assert.ok(evil.includes(`${COLOR.blue}BöseRot${COLOR.white}`));
|
||||||
|
const [cmd] = sayCommands([`${COLOR.yellow}gelb ${COLOR.blue}blau"; quit`]);
|
||||||
|
assert.equal(cmd, `say ${COLOR.yellow}gelb ${COLOR.blue}blau quit`, 'nur erlaubte Codes, kein Befehl anhängbar, Farben am Anfang bleiben');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mit CSTV-Verzögerung: Mapwechsel nach der Übertragung, Hinweis zum Abschalten am Serienende', () => {
|
||||||
|
const p = (lines) => lines.map((l) => l.replace(/[\x01-\x10]/g, ''));
|
||||||
|
const base = { teams, total: 3, autoAdvanceSeconds: 30, tvDelaySeconds: 90, map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } } };
|
||||||
|
const next = p(mapEndLines({ ...base, decided: null, nextMapName: 'Inferno', series: { A: 1, B: 0 } }));
|
||||||
|
assert.equal(next.at(-1), 'Nächste Map: Inferno, wird in etwa 90 s geladen (nach der CSTV-Übertragung) - bleibt auf dem Server');
|
||||||
|
const end = p(mapEndLines({ ...base, decided: 'A', nextMapName: null, series: { A: 2, B: 0 }, shutdownSeconds: 135 }));
|
||||||
|
assert.equal(end.at(-1), 'Der Server bleibt noch etwa 2 Minuten für die CSTV-Übertragung online und schaltet sich dann ab.');
|
||||||
|
// Ohne CSTV-Verzögerung kein Hinweis zum Abschalten
|
||||||
|
const noTv = p(mapEndLines({ ...base, tvDelaySeconds: 0, decided: 'A', nextMapName: null, series: { A: 2, B: 0 } }));
|
||||||
|
assert.ok(!noTv.some((l) => l.includes('CSTV')));
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
|||||||
assert.equal(env.SRCDS_TOKEN, undefined);
|
assert.equal(env.SRCDS_TOKEN, undefined);
|
||||||
assert.equal(env.CS2_GAMEMODE, '2');
|
assert.equal(env.CS2_GAMEMODE, '2');
|
||||||
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||||
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files +sv_deltaticks_enforce 0', 'Rundensicherung für Rollback, keine Trennung wegen Delta-Ticks');
|
||||||
assert.equal(env.CS2_PORT, '27100');
|
assert.equal(env.CS2_PORT, '27100');
|
||||||
assert.equal(env.TV_PORT, '27200');
|
assert.equal(env.TV_PORT, '27200');
|
||||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
@@ -97,3 +97,13 @@ test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohn
|
|||||||
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
reachable.add(r2.handle.container);
|
reachable.add(r2.handle.container);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('TV_DELAY geht an das Image, sonst setzt es CSTV nach jedem Mapwechsel auf 0 Sekunden', async () => {
|
||||||
|
const d = fakeDocker();
|
||||||
|
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: d.run });
|
||||||
|
a.isReachable = async () => false;
|
||||||
|
await a.launch({ matchId: 'tv1', mode: 'competitive', secrets, tvDelaySeconds: 90 });
|
||||||
|
const run = d.calls.find((c) => c.args?.[0] === 'run' || c[0] === 'run');
|
||||||
|
const env = envOf(run.args ?? run);
|
||||||
|
assert.equal(env.TV_DELAY, '90');
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { io as connect } from 'socket.io-client';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
import { DemoServerAdapter } from '../src/servers/demoAdapter.js';
|
||||||
|
import { applyLogLines, initialLiveState } from '../src/liveScore.js';
|
||||||
|
import { RconClient } from '../src/servers/rcon.js';
|
||||||
|
import { createRateLimiter, safeErrorMessage } from '../src/security.js';
|
||||||
|
|
||||||
|
// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war.
|
||||||
|
|
||||||
|
const quiet = { log() {}, warn() {}, error() {} };
|
||||||
|
|
||||||
|
async function start(overrides = {}, adapter) {
|
||||||
|
const config = loadConfig({
|
||||||
|
databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '',
|
||||||
|
demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides,
|
||||||
|
});
|
||||||
|
const app = createApp(config, { logger: quiet, adapter });
|
||||||
|
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = app.server.address().port;
|
||||||
|
const call = async (method, path, { token, body } = {}) => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
};
|
||||||
|
return { app, port, call, close: () => app.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } });
|
||||||
|
|
||||||
|
test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => {
|
||||||
|
const { port, call, close } = await start();
|
||||||
|
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
|
||||||
|
for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) {
|
||||||
|
socket.emit('stream:subscribe', payload);
|
||||||
|
socket.emit('match:subscribe', payload);
|
||||||
|
socket.emit('overview:subscribe', payload);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch');
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zu viele Socket-Abfragen werden gebremst', async () => {
|
||||||
|
const { port, close } = await start();
|
||||||
|
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => socket.on('connect', resolve));
|
||||||
|
let limited = 0;
|
||||||
|
socket.on('rate_limited', () => { limited += 1; });
|
||||||
|
for (let i = 0; i < 60; i++) socket.emit('overview:subscribe');
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen');
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => {
|
||||||
|
class Failing extends DemoServerAdapter {
|
||||||
|
async start({ secrets }) {
|
||||||
|
const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`);
|
||||||
|
err.stderr = '';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { app, call, close } = await start({}, new Failing({ startSeconds: 0 }));
|
||||||
|
try {
|
||||||
|
const { id } = (await newMatch(call)).body;
|
||||||
|
await app.manager.idle();
|
||||||
|
const row = app.manager.row(id);
|
||||||
|
assert.equal(row.status, 'start_failed');
|
||||||
|
const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body);
|
||||||
|
for (const secret of [row.rcon_password, row.server_password]) {
|
||||||
|
assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht');
|
||||||
|
assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung');
|
||||||
|
}
|
||||||
|
assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => {
|
||||||
|
const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' });
|
||||||
|
assert.equal(msg, 'Error: port is already allocated');
|
||||||
|
assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***');
|
||||||
|
assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chat-Nachrichten können keine Map beenden oder starten', () => {
|
||||||
|
const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
|
||||||
|
const ts = '11/01/2026 - 20:00:00.000 - ';
|
||||||
|
const lines = [
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`,
|
||||||
|
`${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]><T>" say "hi"`,
|
||||||
|
];
|
||||||
|
const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
|
||||||
|
assert.deepEqual(events, []);
|
||||||
|
assert.notEqual(after.status, 'finished');
|
||||||
|
assert.deepEqual(after.score, { A: 0, B: 0 });
|
||||||
|
// Die echte Systemzeile wirkt weiterhin
|
||||||
|
const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
|
||||||
|
assert.equal(real.events[0]?.type, 'map_end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => {
|
||||||
|
let release;
|
||||||
|
const stopped = [];
|
||||||
|
class Slow extends DemoServerAdapter {
|
||||||
|
async start(args) {
|
||||||
|
await new Promise((r) => { release = r; });
|
||||||
|
return super.start(args);
|
||||||
|
}
|
||||||
|
async stop(handle) {
|
||||||
|
stopped.push(handle.matchId);
|
||||||
|
return super.stop(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { app, call, close } = await start({}, new Slow({ startSeconds: 0 }));
|
||||||
|
try {
|
||||||
|
const { id } = (await newMatch(call)).body;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
await app.manager.stop(id, 'Test');
|
||||||
|
release();
|
||||||
|
await app.manager.idle();
|
||||||
|
assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt');
|
||||||
|
assert.equal(app.manager.row(id).status, 'stopped');
|
||||||
|
assert.equal(app.manager.row(id).handle, null);
|
||||||
|
|
||||||
|
// Löschen während des Starts
|
||||||
|
const second = (await newMatch(call)).body.id;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const deleting = app.manager.deleteMatch(second, 'Test');
|
||||||
|
await deleting;
|
||||||
|
release();
|
||||||
|
await app.manager.idle();
|
||||||
|
assert.ok(stopped.includes(second));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */
|
||||||
|
async function fakeRcon(onCommand) {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.on('data', (buf) => {
|
||||||
|
const id = buf.readInt32LE(4);
|
||||||
|
const type = buf.readInt32LE(8);
|
||||||
|
if (type === 3) {
|
||||||
|
const ok = Buffer.alloc(14);
|
||||||
|
ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8);
|
||||||
|
socket.write(ok);
|
||||||
|
} else onCommand(socket, id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
return { port: server.address().port, close: () => server.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => {
|
||||||
|
const bad = await fakeRcon((socket) => {
|
||||||
|
const junk = Buffer.alloc(8);
|
||||||
|
junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum
|
||||||
|
socket.write(junk);
|
||||||
|
});
|
||||||
|
const silent = await fakeRcon(() => {});
|
||||||
|
try {
|
||||||
|
const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect();
|
||||||
|
await assert.rejects(a.command('status'), /geschlossen|keine Antwort/);
|
||||||
|
a.close();
|
||||||
|
const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect();
|
||||||
|
await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/);
|
||||||
|
b.close();
|
||||||
|
} finally {
|
||||||
|
bad.close();
|
||||||
|
silent.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => {
|
||||||
|
const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' });
|
||||||
|
try {
|
||||||
|
const one = (await newMatch(call)).body;
|
||||||
|
const two = (await newMatch(call)).body;
|
||||||
|
const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body;
|
||||||
|
const link = view.organizer.links.overview;
|
||||||
|
assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link');
|
||||||
|
const key = new URL(link).searchParams.get('key');
|
||||||
|
const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body;
|
||||||
|
const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id);
|
||||||
|
assert.deepEqual(ids, [one.id], 'nur das eigene Match');
|
||||||
|
assert.ok(!ids.includes(two.id));
|
||||||
|
// Gefälschter Schlüssel für ein anderes Match geht nicht
|
||||||
|
assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403);
|
||||||
|
// Der globale Schlüssel zeigt weiter alles
|
||||||
|
const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body;
|
||||||
|
assert.equal(all.upcoming.length, 2);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rate-Limiter hält seinen Speicher begrenzt', () => {
|
||||||
|
const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 });
|
||||||
|
for (let i = 0; i < 1000; i++) check(`ip-${i}`);
|
||||||
|
assert.ok(check.size() <= 100);
|
||||||
|
for (let i = 0; i < 5; i++) assert.equal(check('x'), true);
|
||||||
|
assert.equal(check('x'), false);
|
||||||
|
});
|
||||||
@@ -79,6 +79,8 @@ test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht',
|
|||||||
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
||||||
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
||||||
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
|
||||||
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('sv_deltaticks_enforce 0'), 'keine Trennung wegen Delta-Ticks');
|
||||||
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.some((c) => /^tv_delay \d+$/.test(c)), 'tv_delay nach dem Laden der Map');
|
||||||
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
||||||
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
||||||
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
||||||
|
|||||||
Reference in new issue
Block a user