Compare commits
12
Commits
48241f0784
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7272d09f | ||
|
|
e8e070dfe6 | ||
|
|
123c2f11d7 | ||
|
|
4bce16d084 | ||
|
|
1fddf4d20c | ||
|
|
52c07c88b8 | ||
|
|
69fcf6d15a | ||
|
|
ece21db78a | ||
|
|
7e17a33c3f | ||
|
|
e384279491 | ||
|
|
da54150df6 | ||
|
|
ea37c23350 |
No files matched your search
@@ -6,12 +6,6 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<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="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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/barlow": "5.3.0",
|
||||
"@fontsource/barlow-condensed": "5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
|
||||
@@ -2,6 +2,13 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
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 running = (matches ?? []).filter((m) => m.group === 'running');
|
||||
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 (
|
||||
<div className="overview home">
|
||||
|
||||
Generated
+20
@@ -23,6 +23,8 @@
|
||||
"name": "@cs2-mapveto/client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/barlow": "5.3.0",
|
||||
"@fontsource/barlow-condensed": "5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
@@ -725,6 +727,24 @@
|
||||
"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": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
|
||||
@@ -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',
|
||||
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||
'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_limitteams 0',
|
||||
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
||||
|
||||
@@ -248,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 lan = (this.options.lanPorts ?? []).includes(port);
|
||||
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 env = {
|
||||
@@ -266,9 +266,13 @@ export class DockerServerAdapter {
|
||||
CS2_GAMETYPE: '0',
|
||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||
CS2_SERVER_HIBERNATE: '0',
|
||||
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
||||
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
||||
// -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
|
||||
// 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',
|
||||
// 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_PW: secrets.tvPassword,
|
||||
TV_RELAY_PW: secrets.tvPassword,
|
||||
@@ -300,7 +304,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, serverName }, new Set([...blocked, port, tvPort]));
|
||||
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,8 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
||||
'mp_match_can_clinch 1',
|
||||
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||
'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_restart_delay 10',
|
||||
'mp_halftime 0',
|
||||
@@ -207,6 +209,8 @@ export class KnifeServers {
|
||||
mode: row.mode,
|
||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||
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)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.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 { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||
@@ -21,6 +23,11 @@ const SLOW_POLL_MS = 30_000;
|
||||
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
|
||||
const KNIFE_RETRIES = 3;
|
||||
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
|
||||
@@ -35,6 +42,11 @@ export class ServerManager {
|
||||
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
|
||||
this.knifeAttempts = new Map();
|
||||
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.timers = [];
|
||||
}
|
||||
@@ -42,7 +54,7 @@ export class ServerManager {
|
||||
init() {
|
||||
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('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));
|
||||
}
|
||||
|
||||
@@ -52,6 +64,7 @@ export class ServerManager {
|
||||
*/
|
||||
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||
const seconds = this.config.autoAdvanceSeconds;
|
||||
this.announceMapEnd(matchId, number, decided, hasNext);
|
||||
if (seconds < 0) return;
|
||||
clearTimeout(this.autoAdvance.get(matchId));
|
||||
const finish = Boolean(decided) || !hasNext;
|
||||
@@ -155,6 +168,10 @@ export class ServerManager {
|
||||
this.autoAdvance.clear();
|
||||
this.timers.forEach(clearInterval);
|
||||
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) {
|
||||
@@ -350,6 +367,7 @@ export class ServerManager {
|
||||
mode: match.mode,
|
||||
secrets,
|
||||
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
|
||||
@@ -443,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. */
|
||||
wantsKnife(match, row) {
|
||||
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
||||
@@ -563,6 +657,7 @@ export class ServerManager {
|
||||
this.service.markCurrentMapLive(matchId);
|
||||
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.announceMapLoaded(matchId, seriesMap.number);
|
||||
} finally {
|
||||
this.busy.delete(matchId);
|
||||
}
|
||||
@@ -622,6 +717,9 @@ export class ServerManager {
|
||||
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.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
|
||||
const fields = [];
|
||||
if (this.config.match.tvEnabled && row.tv_port) {
|
||||
@@ -688,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) {
|
||||
this.simulator.stop(matchId);
|
||||
this.stopWarmupRepeat(matchId);
|
||||
clearTimeout(this.closeTimers.get(matchId));
|
||||
this.closeTimers.delete(matchId);
|
||||
const row = this.row(matchId);
|
||||
if (!row || row.status === 'stopped') return;
|
||||
if (row.handle) await this.safeStop(row);
|
||||
@@ -721,7 +843,7 @@ export class ServerManager {
|
||||
if (this.busy.has(row.match_id)) continue;
|
||||
const match = this.service.getMatch(row.match_id);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}, { 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.notEqual(app.service.serverRow(id).status, 'stopped', 'läuft für CSTV noch');
|
||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
||||
const list = (await call('GET', '/matches')).body.matches;
|
||||
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' });
|
||||
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);
|
||||
// 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' });
|
||||
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' });
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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.CS2_GAMEMODE, '2');
|
||||
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.TV_PORT, '27200');
|
||||
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);
|
||||
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');
|
||||
});
|
||||
@@ -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;
|
||||
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('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'));
|
||||
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
||||
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
||||
|
||||
Reference in new issue
Block a user