From ece21db78a16802bdc1d791f0cd3cd62803f7f9d Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 1 Nov 2026 21:19:13 +0100 Subject: [PATCH] Hinweise im Ingame-Chat: Map, Seitenwahl, Serienstand, Match-Start, Map-Ende MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach dem Laden einer Map schreibt die App per RCON say, welche Map gespielt wird, wer sie gewählt hat, wer als CT und T startet und den Serienstand. Die Hinweise wiederholen sich während der Aufwärmphase jede Minute (höchstens zehnmal). Beim Match-Start und am Map-Ende folgen Start-, Ergebnis- und Serienhinweise. --- server/src/servers/chatAnnouncements.js | 81 +++++++++++++++++++++++ server/src/servers/serverManager.js | 85 +++++++++++++++++++++++++ server/test/api.test.js | 36 +++++++++++ server/test/chatAnnouncements.test.js | 45 +++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 server/src/servers/chatAnnouncements.js create mode 100644 server/test/chatAnnouncements.test.js diff --git a/server/src/servers/chatAnnouncements.js b/server/src/servers/chatAnnouncements.js new file mode 100644 index 0000000..3b04927 --- /dev/null +++ b/server/src/servers/chatAnnouncements.js @@ -0,0 +1,81 @@ +// 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; + +/** + * Text für "say", ohne Zeichen, mit denen sich weitere Konsolenbefehle anhängen ließen + * (Anführungszeichen, Semikolon, Zeilenumbrüche). Doppelpunkt und Komma bleiben für Spielstände erlaubt. + */ +export function chatText(text) { + return String(text ?? '') + .replace(/["';\\\r\n\t]/g, '') + .replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_LENGTH); +} + +/** Konsolenbefehle für eine Liste von Hinweisen. Leere Zeilen fallen weg. */ +export function sayCommands(lines) { + return lines.map(chatText).filter(Boolean).map((line) => `say ${line}`); +} + +const other = (team) => (team === 'A' ? 'B' : 'A'); + +/** + * 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 head = total > 1 ? `Map ${map.number} von ${total}: ${map.mapName}` : `Map: ${map.mapName}`; + lines.push(map.pickedBy ? `${head} - gewählt von ${teams[map.pickedBy]}` : `${head} - Entscheidungsmap`); + if (map.ctTeam) { + const t = other(map.ctTeam); + const chooser = map.sideChosenBy ? `${teams[map.sideChosenBy]} hat die Seite gewählt: ` : ''; + lines.push(`${chooser}${teams[map.ctTeam]} startet als CT, ${teams[t]} als T`); + } + if (total > 1) lines.push(`Serienstand: ${teams.A} ${series.A}:${series.B} ${teams.B}`); + lines.push('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 }) { + if (!ctTeam) return [`Match live! ${teams.A} gegen ${teams.B} - viel Glück, have fun`]; + return [`Match live! ${teams[ctTeam]} (CT) gegen ${teams[other(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 + */ +export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total }) { + const lines = []; + const score = `${map.score.A}:${map.score.B}`; + lines.push(map.winner + ? `Map ${map.number} (${map.mapName}) geht an ${teams[map.winner]} mit ${score}` + : `Map ${map.number} (${map.mapName}) endet ${score}`); + if (decided) { + const s = decided === 'A' ? `${series.A}:${series.B}` : `${series.B}:${series.A}`; + lines.push(total > 1 ? `${teams[decided]} gewinnt die Serie ${s} - danke fürs Spielen!` : 'Danke fürs Spielen!'); + } else if (nextMapName) { + lines.push(`Serienstand: ${teams.A} ${series.A}:${series.B} ${teams.B}`); + lines.push(autoAdvanceSeconds >= 0 + ? `Nächste Map: ${nextMapName}, wird in ${autoAdvanceSeconds} s geladen - bleibt auf dem Server` + : `Nächste Map: ${nextMapName} - bleibt auf dem Server`); + } + return lines; +} diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index d7e445c..0103cc5 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -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,9 @@ 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; /** * Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle @@ -35,6 +40,8 @@ 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(); this.lastPoll = new Map(); this.timers = []; } @@ -52,6 +59,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 +163,8 @@ export class ServerManager { this.autoAdvance.clear(); this.timers.forEach(clearInterval); this.timers = []; + for (const t of this.warmupTimers.values()) clearInterval(t); + this.warmupTimers.clear(); } row(matchId) { @@ -443,6 +453,76 @@ 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; + 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, + })); + } + /** 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 +643,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 +703,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) { @@ -690,6 +774,7 @@ export class ServerManager { async stop(matchId, reason) { this.simulator.stop(matchId); + this.stopWarmupRepeat(matchId); const row = this.row(matchId); if (!row || row.status === 'stopped') return; if (row.handle) await this.safeStop(row); diff --git a/server/test/api.test.js b/server/test/api.test.js index 8787052..f573031 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -831,3 +831,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))); + 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(); + } +}); diff --git a/server/test/chatAnnouncements.test.js b/server/test/chatAnnouncements.test.js new file mode 100644 index 0000000..76c6aeb --- /dev/null +++ b/server/test/chatAnnouncements.test.js @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { chatText, mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from '../src/servers/chatAnnouncements.js'; + +const teams = { A: 'Nordlicht', B: 'Hafenkante' }; + +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(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(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(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(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(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); +});