diff --git a/server/src/servers/chatAnnouncements.js b/server/src/servers/chatAnnouncements.js index 3b04927..f91e128 100644 --- a/server/src/servers/chatAnnouncements.js +++ b/server/src/servers/chatAnnouncements.js @@ -4,24 +4,57 @@ 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. + * 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 function chatText(text) { +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, MAX_LENGTH); + .slice(0, maxLength); } -/** Konsolenbefehle für eine Liste von Hinweisen. Leere Zeilen fallen weg. */ +/** 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.map(chatText).filter(Boolean).map((line) => `say ${line}`); + 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, @@ -34,22 +67,24 @@ const other = (team) => (team === 'A' ? 'B' : 'A'); */ 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`); + 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 ? `${teams[map.sideChosenBy]} hat die Seite gewählt: ` : ''; - lines.push(`${chooser}${teams[map.ctTeam]} startet als CT, ${teams[t]} als T`); + 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: ${teams.A} ${series.A}:${series.B} ${teams.B}`); - lines.push('Aufwärmphase - das Match startet, sobald der Organisator es freigibt'); + 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 }) { - 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`]; + 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`]; } /** @@ -65,17 +100,21 @@ export function matchStartLines({ teams, ctTeam }) { export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total }) { const lines = []; const score = `${map.score.A}:${map.score.B}`; + const mapName = chatText(map.mapName, 32); lines.push(map.winner - ? `Map ${map.number} (${map.mapName}) geht an ${teams[map.winner]} mit ${score}` - : `Map ${map.number} (${map.mapName}) endet ${score}`); + ? `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 ? `${teams[decided]} gewinnt die Serie ${s} - danke fürs Spielen!` : 'Danke fürs Spielen!'); + lines.push(total > 1 + ? `${paint(COLOR.green, `${name(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}`); + const next = chatText(nextMapName, 32); + lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(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`); + ? `Nächste Map: ${next}, wird in ${autoAdvanceSeconds} s geladen - bleibt auf dem Server` + : `Nächste Map: ${next} - bleibt auf dem Server`); } return lines; } diff --git a/server/test/api.test.js b/server/test/api.test.js index f573031..d720c89 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -838,7 +838,7 @@ test('Ingame-Chat: Hinweise nach dem Laden, beim Start und am Map-Ende', async ( 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))); + 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'); diff --git a/server/test/chatAnnouncements.test.js b/server/test/chatAnnouncements.test.js index 76c6aeb..149a5a9 100644 --- a/server/test/chatAnnouncements.test.js +++ b/server/test/chatAnnouncements.test.js @@ -1,32 +1,34 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { chatText, mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from '../src/servers/chatAnnouncements.js'; +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(lines, [ + 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(bo1[0], 'Map: Nuke - Entscheidungsmap'); + 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(matchStartLines({ teams, ctTeam: 'B' }), ['Match live! Hafenkante (CT) gegen Nordlicht (T) - viel Glück, have fun']); + 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(next, [ + 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', @@ -35,11 +37,28 @@ test('Hinweise bei Match-Start und Map-Ende', () => { 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!'); + 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.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'); +});