diff --git a/README.md b/README.md
index 368b544..357b393 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,15 @@ Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich au
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
+### Chat im Match
+
+Auf der Match-Seite gibt es einen Chat mit zwei Reitern über der Chatbox:
+
+- **Match-Chat:** beide Teams und der Organisator.
+- **Team-Chat:** nur die Spieler des eigenen Teams, z. B. für Absprachen im Veto. Der Organisator sieht ihn nur, wenn er selbst im Team mitspielt.
+
+Zuschauer und Gäste sehen keinen Chat. Er gilt das ganze Match über, von der Lobby bis zum Ende. Danach ist er nur noch lesbar. Nachrichten haben höchstens 300 Zeichen, pro Person gehen höchstens 5 Nachrichten in 10 Sekunden. Ein roter Punkt am Reiter zeigt ungelesene Nachrichten.
+
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
diff --git a/client/src/components/ChatBox.jsx b/client/src/components/ChatBox.jsx
new file mode 100644
index 0000000..1e6dd2c
--- /dev/null
+++ b/client/src/components/ChatBox.jsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from 'react';
+import { api } from '../lib/api.js';
+
+const time = (ms) => new Date(ms).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
+const SEEN_KEY = (matchId) => `cs2mv:chat-seen:${matchId}`;
+
+function readSeen(matchId) {
+ try {
+ return JSON.parse(localStorage.getItem(SEEN_KEY(matchId))) ?? {};
+ } catch {
+ return {};
+ }
+}
+
+/** Chat im Match: Tabs für Match-Chat und Team-Chat über der Nachrichtenliste. */
+export default function ChatBox({ chat, matchId, token, playerToken }) {
+ const [active, setActive] = useState(chat.channels[0].key);
+ const [text, setText] = useState('');
+ const [error, setError] = useState(null);
+ const [sending, setSending] = useState(false);
+ const [seen, setSeen] = useState(() => readSeen(matchId));
+ const listRef = useRef(null);
+ const channel = chat.channels.some((c) => c.key === active) ? active : chat.channels[0].key;
+ const messages = chat.messages[channel] ?? [];
+ const lastId = (key) => chat.messages[key]?.at(-1)?.id ?? 0;
+
+ // Aktiven Kanal als gelesen merken und ans Ende scrollen, wenn neue Nachrichten kommen
+ useEffect(() => {
+ const id = lastId(channel);
+ if (id && seen[channel] !== id) {
+ const next = { ...seen, [channel]: id };
+ setSeen(next);
+ try {
+ localStorage.setItem(SEEN_KEY(matchId), JSON.stringify(next));
+ } catch {
+ /* ignorieren */
+ }
+ }
+ const el = listRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ }, [channel, messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const send = async (e) => {
+ e.preventDefault();
+ if (!text.trim() || sending) return;
+ setSending(true);
+ setError(null);
+ try {
+ await api(`/matches/${matchId}/chat`, { method: 'POST', body: { channel, text }, token, playerToken });
+ setText('');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSending(false);
+ }
+ };
+
+ return (
+
+
+ {chat.channels.map((c) => {
+ const unread = c.key !== channel && lastId(c.key) > (seen[c.key] ?? 0);
+ return (
+ setActive(c.key)}>
+ {c.label}
+ {unread && }
+
+ );
+ })}
+
+
+ {messages.length === 0 && (
+
+ {channel === 'match' ? 'Noch keine Nachricht. Hier schreiben beide Teams und der Organisator.' : 'Noch keine Nachricht. Diesen Chat sieht nur dein Team.'}
+
+ )}
+ {messages.map((m) => (
+
+
+ {m.author}
+ {m.organizer && m.team && Organisator }
+ {time(m.at)}
+
+ {m.text}
+
+ ))}
+
+ {chat.canWrite ? (
+
+ ) : (
+ Das Match ist beendet, der Chat ist nur noch lesbar.
+ )}
+ {error && {error}
}
+
+ );
+}
diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx
index 01c9702..7abde21 100644
--- a/client/src/pages/MatchPage.jsx
+++ b/client/src/pages/MatchPage.jsx
@@ -12,6 +12,7 @@ import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx';
+import ChatBox from '../components/ChatBox.jsx';
export default function MatchPage() {
const { id } = useParams();
@@ -137,6 +138,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && (
+ {state.chat && }
{isOrg && }
diff --git a/client/src/styles.css b/client/src/styles.css
index 25b4f85..b5679b3 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -729,3 +729,26 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.knife-team-a { border-color: var(--team-a); color: var(--team-a); }
.knife-team-b { border-color: var(--team-b); color: var(--team-b); }
+/* Chat im Match */
+.chat-box { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
+.chat-tabs { display: flex; border-bottom: 1px solid var(--line); }
+.chat-tab { position: relative; flex: 1; font: 700 1rem var(--display); color: var(--muted); background: transparent; border: 0; border-bottom: 3px solid transparent; padding: 12px 14px 10px; cursor: pointer; }
+.chat-tab:hover { color: var(--text); }
+.chat-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
+.chat-tab-a.is-active { border-bottom-color: var(--team-a); }
+.chat-tab-b.is-active { border-bottom-color: var(--team-b); }
+.chat-unread { display: inline-block; width: 8px; height: 8px; margin-left: 6px; border-radius: 50%; background: var(--brand-red); vertical-align: middle; }
+.chat-list { list-style: none; margin: 0; padding: 10px 16px; height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
+.chat-empty { margin: auto 0; text-align: center; }
+.chat-msg { display: flex; flex-direction: column; gap: 1px; max-width: 92%; }
+.chat-msg.is-mine { align-self: flex-end; text-align: right; }
+.chat-meta { display: flex; gap: 6px; align-items: baseline; font-size: .8rem; }
+.chat-msg.is-mine .chat-meta { justify-content: flex-end; }
+.chat-org { color: var(--text); }
+.chat-role { font-size: .7rem; padding: 0 6px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--muted); }
+.chat-text { display: inline-block; padding: 6px 10px; border-radius: 10px; background: var(--surface-2); overflow-wrap: anywhere; }
+.chat-msg.is-mine .chat-text { background: color-mix(in srgb, var(--primary) 30%, var(--surface-2)); }
+.chat-form { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
+.chat-form input { flex: 1; min-width: 0; }
+.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
+.chat-box .field-error { margin: 0 16px 10px; }
diff --git a/server/src/db.js b/server/src/db.js
index 50c369d..7fdcf27 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS knife_servers (
updated_at INTEGER NOT NULL
);
+-- Chat im Match: channel = 'match' (beide Teams + Organisator) oder 'A' / 'B' (nur das eigene Team)
+CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
+ channel TEXT NOT NULL,
+ player_id TEXT,
+ author TEXT NOT NULL,
+ team TEXT,
+ organizer INTEGER NOT NULL DEFAULT 0,
+ text TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS chat_by_match ON chat_messages (match_id, channel, id);
+
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
diff --git a/server/src/http.js b/server/src/http.js
index a06dc0d..4c4de52 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -113,6 +113,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
+ action('chat', (v, b) => service.postChat(v, { channel: b.channel, text: b.text }));
action('leave', (v) => service.leave(v));
action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId));
diff --git a/server/src/matchService.js b/server/src/matchService.js
index c379768..413ed75 100644
--- a/server/src/matchService.js
+++ b/server/src/matchService.js
@@ -41,9 +41,13 @@ function cleanSteamId(value) {
return raw;
}
+const CHAT_MAX_LENGTH = 300;
+const CHAT_HISTORY = 200;
+
export class MatchService extends EventEmitter {
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
super();
+ this.chatRate = new Map();
this.db = db;
this.pools = pools;
this.config = config;
@@ -635,6 +639,70 @@ export class MatchService extends EventEmitter {
.run(matchId, match.current_map_number);
}
+ // ---------- Chat ----------
+
+ /**
+ * Chat-Kanäle, die dieser Zugang sieht: der Match-Chat für Spieler beider Teams und den Organisator,
+ * dazu der Team-Chat des eigenen Teams. Zuschauer und Gäste sehen keinen Chat.
+ */
+ chatChannels(match, viewer) {
+ const channels = [];
+ if (viewer.isOrganizer || viewer.playerId) channels.push({ key: 'match', label: 'Match-Chat' });
+ if (viewer.playerId && (viewer.team === 'A' || viewer.team === 'B')) {
+ channels.push({ key: viewer.team, label: `Team ${this.teamName(match, viewer.team)}` });
+ }
+ return channels;
+ }
+
+ chatView(match, viewer) {
+ const channels = this.chatChannels(match, viewer);
+ if (!channels.length) return null;
+ const stmt = this.db.prepare(`
+ SELECT * FROM (SELECT * FROM chat_messages WHERE match_id = ? AND channel = ? ORDER BY id DESC LIMIT ${CHAT_HISTORY})
+ ORDER BY id
+ `);
+ const messages = {};
+ for (const c of channels) {
+ messages[c.key] = stmt.all(match.id, c.key).map((m) => ({
+ id: m.id,
+ author: m.author,
+ team: m.team,
+ organizer: !!m.organizer,
+ text: m.text,
+ at: m.created_at,
+ mine: viewer.playerId ? m.player_id === viewer.playerId : !!m.organizer && !m.player_id,
+ }));
+ }
+ return { channels, messages, canWrite: !CLOSED_STATUSES.includes(match.status), maxLength: CHAT_MAX_LENGTH };
+ }
+
+ /** Nachricht in einen Kanal schreiben. Nach Matchende ist der Chat nur noch lesbar. */
+ postChat(viewer, { channel, text } = {}) {
+ const match = this.getMatch(viewer.matchId);
+ if (!this.chatChannels(match, viewer).some((c) => c.key === channel)) {
+ throw new HttpError(403, 'In diesem Chat darfst du nicht schreiben', 'forbidden');
+ }
+ if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist beendet, der Chat ist nur noch lesbar', 'match_closed');
+ const clean = String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
+ if (!clean) throw new HttpError(400, 'Die Nachricht ist leer', 'invalid_input');
+ if (clean.length > CHAT_MAX_LENGTH) throw new HttpError(400, `Höchstens ${CHAT_MAX_LENGTH} Zeichen`, 'invalid_input');
+
+ // Gegen Spam: höchstens 5 Nachrichten in 10 Sekunden pro Person
+ const who = `${match.id}:${viewer.playerId ?? 'org'}`;
+ const now = this.now();
+ 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');
+ this.chatRate.set(who, [...recent, now]);
+
+ const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
+ this.db.prepare(`
+ INSERT INTO chat_messages (match_id, channel, player_id, author, team, organizer, text, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(match.id, channel, viewer.playerId ?? null, player?.nickname ?? 'Organisator', player?.team ?? null,
+ viewer.isOrganizer ? 1 : 0, clean, now);
+ this.changed(match.id);
+ }
+
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
setArchived(matchId, archived) {
const match = this.getMatch(matchId);
@@ -957,6 +1025,7 @@ export class MatchService extends EventEmitter {
seriesScore: seriesScore(seriesRows),
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
server: this.serverView(match, viewer),
+ chat: this.chatView(match, viewer),
events: publicView
? []
: this.db
diff --git a/server/test/api.test.js b/server/test/api.test.js
index b677871..38039b2 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -648,3 +648,45 @@ test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
await close();
}
});
+test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
+ const { call, close } = await setup();
+ try {
+ const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
+ const spectator = codes.spectator;
+ const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
+
+ assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
+ assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
+ assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
+ // Fremder Team-Chat und Zuschauer: verboten
+ assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
+ assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
+ assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
+ assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
+ assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
+
+ const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
+ assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
+ assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
+ assert.equal(a.messages.match[1].organizer, true);
+ assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
+
+ const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
+ assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
+ assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
+ assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
+ assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
+ assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
+
+ // Spam-Schutz und nach Matchende nur lesbar
+ for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
+ assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
+ await call('POST', `/matches/${id}/finish`, { token: org });
+ assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
+ const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
+ assert.equal(closed.canWrite, false);
+ assert.equal(closed.messages.match.length, 7);
+ } finally {
+ await close();
+ }
+});