Ingame-Hinweise farbig: CT blau, T gold, Sieger grün, Aufwärmphase gelb

Farbcodes kommen nur aus den Textbausteinen; Teamnamen und andere Eingaben bleiben ohne Steuerzeichen. Am Server geprüft, welche Codes CS2 im Chat als Farbe anzeigt.
This commit is contained in:
mregosar committed 2026-11-01 21:44:56 +01:00
1 parent 69fcf6d15a
commit 1fddf4d20c
3 files changed
+86 -28

No files matched your search

+59 -20
View File
@@ -4,24 +4,57 @@
const MAX_LENGTH = 120; const MAX_LENGTH = 120;
/** /**
* Text für "say", ohne Zeichen, mit denen sich weitere Konsolenbefehle anhängen ließen * Farbcodes für den CS2-Chat (am Server geprüft). Ein Code färbt alles bis zum nächsten Code.
* (Anführungszeichen, Semikolon, Zeilenumbrüche). Doppelpunkt und Komma bleiben für Spielstände erlaubt. * 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 ?? '') return String(text ?? '')
.replace(/["';\\\r\n\t]/g, '') .replace(/["';\\\r\n\t]/g, '')
.replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '') .replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .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) { 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 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, * 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 }) { export function mapLoadedLines({ teams, map, total, series }) {
const lines = []; const lines = [];
const head = total > 1 ? `Map ${map.number} von ${total}: ${map.mapName}` : `Map: ${map.mapName}`; const mapName = chatText(map.mapName, 32);
lines.push(map.pickedBy ? `${head} - gewählt von ${teams[map.pickedBy]}` : `${head} - Entscheidungsmap`); 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) { if (map.ctTeam) {
const t = other(map.ctTeam); const t = other(map.ctTeam);
const chooser = map.sideChosenBy ? `${teams[map.sideChosenBy]} hat die Seite gewählt: ` : ''; const chooser = map.sideChosenBy ? `${name(teams, map.sideChosenBy)} hat die Seite gewählt: ` : '';
lines.push(`${chooser}${teams[map.ctTeam]} startet als CT, ${teams[t]} als T`); 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}`); if (total > 1) lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
lines.push('Aufwärmphase - das Match startet, sobald der Organisator es freigibt'); lines.push(paint(COLOR.yellow, 'Aufwärmphase - das Match startet, sobald der Organisator es freigibt'));
return lines; return lines;
} }
/** Hinweis beim Start des Matches (Aufwärmphase beendet). */ /** Hinweis beim Start des Matches (Aufwärmphase beendet). */
export function matchStartLines({ teams, ctTeam }) { export function matchStartLines({ teams, ctTeam }) {
if (!ctTeam) return [`Match live! ${teams.A} gegen ${teams.B} - viel Glück, have fun`]; const live = paint(COLOR.green, 'Match live!');
return [`Match live! ${teams[ctTeam]} (CT) gegen ${teams[other(ctTeam)]} (T) - viel Glück, have fun`]; 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 }) { export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total }) {
const lines = []; const lines = [];
const score = `${map.score.A}:${map.score.B}`; const score = `${map.score.A}:${map.score.B}`;
const mapName = chatText(map.mapName, 32);
lines.push(map.winner lines.push(map.winner
? `Map ${map.number} (${map.mapName}) geht an ${teams[map.winner]} mit ${score}` ? `Map ${map.number} (${mapName}) geht an ${paint(COLOR.green, name(teams, map.winner))} mit ${score}`
: `Map ${map.number} (${map.mapName}) endet ${score}`); : `Map ${map.number} (${mapName}) endet ${score}`);
if (decided) { if (decided) {
const s = decided === 'A' ? `${series.A}:${series.B}` : `${series.B}:${series.A}`; 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) { } 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 lines.push(autoAdvanceSeconds >= 0
? `Nächste Map: ${nextMapName}, wird in ${autoAdvanceSeconds} s geladen - bleibt auf dem Server` ? `Nächste Map: ${next}, wird in ${autoAdvanceSeconds} s geladen - bleibt auf dem Server`
: `Nächste Map: ${nextMapName} - bleibt auf dem Server`); : `Nächste Map: ${next} - bleibt auf dem Server`);
} }
return lines; return lines;
} }
+1 -1
View File
@@ -838,7 +838,7 @@ test('Ingame-Chat: Hinweise nach dem Laden, beim Start und am Map-Ende', async (
const said = []; const said = [];
const send = app.adapter.sendCommands.bind(app.adapter); const send = app.adapter.sendCommands.bind(app.adapter);
app.adapter.sendCommands = async (h, commands) => { 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); return send(h, commands);
}; };
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3'); const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
+26 -7
View File
@@ -1,32 +1,34 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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' }; 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', () => { test('Hinweise nach dem Laden: Map, Wahl, Seiten, Serienstand, Aufwärmphase', () => {
const lines = mapLoadedLines({ const lines = mapLoadedLines({
teams, total: 3, series: { A: 1, B: 0 }, teams, total: 3, series: { A: 1, B: 0 },
map: { number: 2, mapName: 'Inferno', pickedBy: 'B', sideChosenBy: 'A', ctTeam: 'A' }, 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', 'Map 2 von 3: Inferno - gewählt von Hafenkante',
'Nordlicht hat die Seite gewählt: Nordlicht startet als CT, Hafenkante als T', 'Nordlicht hat die Seite gewählt: Nordlicht startet als CT, Hafenkante als T',
'Serienstand: Nordlicht 1:0 Hafenkante', 'Serienstand: Nordlicht 1:0 Hafenkante',
'Aufwärmphase - das Match startet, sobald der Organisator es freigibt', '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' } }); 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'); assert.ok(!bo1.some((l) => l.startsWith('Serienstand')), 'kein Serienstand im BO1');
}); });
test('Hinweise bei Match-Start und Map-Ende', () => { 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({ const next = mapEndLines({
teams, total: 3, decided: null, nextMapName: 'Inferno', autoAdvanceSeconds: 30, series: { A: 1, B: 0 }, 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 } }, 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', 'Map 1 (Mirage) geht an Nordlicht mit 13:9',
'Serienstand: Nordlicht 1:0 Hafenkante', 'Serienstand: Nordlicht 1:0 Hafenkante',
'Nächste Map: Inferno, wird in 30 s geladen - bleibt auf dem Server', '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 }, 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 } }, 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', () => { test('Chattexte können keine weiteren Befehle anhängen', () => {
assert.equal(chatText('Team"; rcon_password x; say "'), 'Team rcon_password x say'); 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); 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');
});