Compare commits
6
Commits
7b6ce01d05
...
fb96fb5d08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb96fb5d08 | ||
|
|
c820ba9f12 | ||
|
|
e26c736f2e | ||
|
|
a3471a2f3c | ||
|
|
cdf41df01c | ||
|
|
c62fe4ba1e |
No files matched your search
@@ -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`).
|
||||
@@ -279,6 +288,7 @@ Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Mes
|
||||
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
||||
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
||||
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
|
||||
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, zeigt die Lobby beiden Teams die freien Messer-Server (zuerst die mit passender Teamgröße) mit „Verbinden“. Nach der Runde klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Das gewählte Team beginnt das Veto. Im Admin-Bereich gibt es dieselben zwei Knöpfe in der Matchkarte.
|
||||
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
||||
|
||||
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||
|
||||
@@ -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 (
|
||||
<section className="panel chat-box" aria-label="Chat">
|
||||
<div className="chat-tabs" role="tablist">
|
||||
{chat.channels.map((c) => {
|
||||
const unread = c.key !== channel && lastId(c.key) > (seen[c.key] ?? 0);
|
||||
return (
|
||||
<button key={c.key} role="tab" aria-selected={c.key === channel}
|
||||
className={`chat-tab chat-tab-${c.key.toLowerCase()} ${c.key === channel ? 'is-active' : ''}`}
|
||||
onClick={() => setActive(c.key)}>
|
||||
{c.label}
|
||||
{unread && <span className="chat-unread" aria-label="neue Nachrichten" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ol className="chat-list" ref={listRef} aria-live="polite">
|
||||
{messages.length === 0 && (
|
||||
<li className="chat-empty muted small">
|
||||
{channel === 'match' ? 'Noch keine Nachricht. Hier schreiben beide Teams und der Organisator.' : 'Noch keine Nachricht. Diesen Chat sieht nur dein Team.'}
|
||||
</li>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<li key={m.id} className={`chat-msg ${m.mine ? 'is-mine' : ''}`}>
|
||||
<span className="chat-meta">
|
||||
<strong className={m.team ? `team-${m.team.toLowerCase()}` : 'chat-org'}>{m.author}</strong>
|
||||
{m.organizer && m.team && <span className="chat-role">Organisator</span>}
|
||||
<time className="muted">{time(m.at)}</time>
|
||||
</span>
|
||||
<span className="chat-text">{m.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{chat.canWrite ? (
|
||||
<form className="chat-form" onSubmit={send}>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={chat.maxLength}
|
||||
placeholder={channel === 'match' ? 'Nachricht an alle im Match' : 'Nachricht an dein Team'}
|
||||
aria-label="Nachricht"
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={!text.trim() || sending}>Senden</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="small muted chat-closed">Das Match ist beendet, der Chat ist nur noch lesbar.</p>
|
||||
)}
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -384,7 +384,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
|
||||
<div className="admin-actions">
|
||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||
{m.status === 'lobby' && m.vetoStart !== 'knife' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||
{m.status === 'lobby' && m.vetoStart === 'knife' && ['A', 'B'].map((t) => (
|
||||
<span key={t}>{btn(`${m.teams[t].name} beginnt das Veto`, { path: '/veto/start', body: { firstTeam: t }, confirm: `Messerrunde vorbei: ${m.teams[t].name} beginnt das Veto?`, success: 'Veto gestartet' }, 'btn-primary')}</span>
|
||||
))}
|
||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
|
||||
<option value="coin">Münzwurf beim Veto-Start</option>
|
||||
<option value="A">{form.teamA || 'Team A'}</option>
|
||||
<option value="B">{form.teamB || 'Team B'}</option>
|
||||
<option value="knife">Messerrunde: Organisator legt danach fest, wer beginnt</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -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' && (
|
||||
<div className="match-lower">
|
||||
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
|
||||
<ServerPanel {...ctx} />
|
||||
{isOrg && <OrganizerPanel {...ctx} token={token} />}
|
||||
<EventLog events={state.events} />
|
||||
@@ -170,7 +172,21 @@ function Stage(ctx) {
|
||||
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{isOrg ? (
|
||||
{state.vetoStart === 'knife' && <KnifeRound state={state} />}
|
||||
{isOrg && state.vetoStart === 'knife' ? (
|
||||
<div className="knife-decide">
|
||||
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
|
||||
<div className="row">
|
||||
{['A', 'B'].map((t) => (
|
||||
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
|
||||
{state.teams[t].name} beginnt
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
|
||||
</div>
|
||||
) : isOrg ? (
|
||||
<>
|
||||
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
||||
@@ -178,6 +194,8 @@ function Stage(ctx) {
|
||||
</button>
|
||||
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
|
||||
</>
|
||||
) : state.vetoStart === 'knife' ? (
|
||||
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</p>
|
||||
) : (
|
||||
<p className="small muted">
|
||||
{missing.length === 0
|
||||
@@ -200,6 +218,56 @@ function Stage(ctx) {
|
||||
}
|
||||
|
||||
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
|
||||
/**
|
||||
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
|
||||
* Frei = niemand drauf (oder Spielerzahl unbekannt).
|
||||
*/
|
||||
function KnifeRound({ state }) {
|
||||
const [servers, setServers] = useState(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
|
||||
load();
|
||||
const t = setInterval(load, 10_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
const free = (servers ?? []).filter((k) => !k.playerCount);
|
||||
const matching = free.filter((k) => k.mode === state.mode);
|
||||
const shown = matching.length ? matching : free;
|
||||
return (
|
||||
<div className="knife-round">
|
||||
<h3>Seitenwahl per Messerrunde</h3>
|
||||
<p className="small">
|
||||
Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
|
||||
welches Team das Veto beginnt.
|
||||
</p>
|
||||
{servers === null ? (
|
||||
<p className="small muted">Suche freie Messer-Server …</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
|
||||
) : (
|
||||
<>
|
||||
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
|
||||
<ul className="knife-list">
|
||||
{shown.map((k) => (
|
||||
<li key={k.id}>
|
||||
<strong>{k.name}</strong>
|
||||
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
||||
<span className="muted small">{k.mapName}</span>
|
||||
<code>{k.connect}</code>
|
||||
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readinessGap(team, requireFull) {
|
||||
const r = team.readiness;
|
||||
const gaps = [];
|
||||
|
||||
+155
-40
@@ -6,53 +6,145 @@ import { getAccess } from '../lib/session.js';
|
||||
import { useOverview } from '../lib/useMatch.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
|
||||
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
|
||||
];
|
||||
|
||||
export default function Overview() {
|
||||
const { matches } = useOverview();
|
||||
const mine = (matches ?? []).filter((m) => m.group !== 'closed' && getAccess(m.id));
|
||||
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');
|
||||
|
||||
return (
|
||||
<div className="overview">
|
||||
<section className="overview-head">
|
||||
<div>
|
||||
<h1>Match-Lobbys</h1>
|
||||
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
||||
<div className="overview home">
|
||||
<section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
|
||||
<div className="home-intro">
|
||||
<h1>Mit deinem Team ins Match</h1>
|
||||
<p className="lead">
|
||||
Den Teamlink postet der Organisator im Match-Chat auf maxlan.de oder in Discord. Öffne den Link deines Teams.
|
||||
Hast du nur den Code, gib ihn hier ein.
|
||||
</p>
|
||||
<CodeTicket />
|
||||
</div>
|
||||
<CodeEntry />
|
||||
{mine.length > 0 && <MyMatches matches={mine} />}
|
||||
</section>
|
||||
|
||||
<Steps current={step} />
|
||||
|
||||
<KnifeServers />
|
||||
|
||||
{matches === null ? (
|
||||
<p className="muted">Lade Matches …</p>
|
||||
) : (
|
||||
<div className="groups">
|
||||
{GROUPS.map((g) => {
|
||||
const list = matches.filter((m) => m.group === g.key);
|
||||
return (
|
||||
<section key={g.key} className={`group group-${g.key}`}>
|
||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
||||
<div className="run-grid">
|
||||
{list.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="match-list">
|
||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<section className="group home-live">
|
||||
<h2>Laufend <span className="count">{running.length}</span></h2>
|
||||
{running.length === 0 ? (
|
||||
<p className="muted small">Gerade läuft kein Match.</p>
|
||||
) : (
|
||||
<div className="run-grid">
|
||||
{running.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="home-lists">
|
||||
<section className="group">
|
||||
<h2>Offene Lobbys <span className="count">{open.length}</span></h2>
|
||||
{open.length === 0 ? (
|
||||
<p className="muted small">Keine offene Lobby. Sobald der Organisator ein Match erstellt, steht es hier.</p>
|
||||
) : (
|
||||
<ul className="match-list">{open.map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="group">
|
||||
<h2>Ergebnisse <span className="count">{closed.length}</span></h2>
|
||||
{closed.length === 0 ? (
|
||||
<p className="muted small">Noch kein Match abgeschlossen.</p>
|
||||
) : (
|
||||
<ul className="match-list">{closed.slice(0, 12).map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ title: 'Teamlink öffnen', text: 'Link aus dem Match-Chat, Namen eingeben' },
|
||||
{ title: 'Bereit klicken', text: 'In der Lobby auf „Ich bin bereit“' },
|
||||
{ title: 'Map-Veto', text: 'Die Captains bannen und wählen' },
|
||||
{ title: 'Auf den Server', text: 'Befehl kopieren oder im LAN-Reiter' },
|
||||
];
|
||||
|
||||
/** Was als Nächstes zu tun ist, je nach Stand des Matches und Rolle. step = Schritt in STEPS (1–4). */
|
||||
function nextStep(m, access) {
|
||||
const organizer = access?.kind === 'organizer';
|
||||
switch (m.status) {
|
||||
case 'lobby':
|
||||
return organizer
|
||||
? { step: 2, text: 'Lobby offen: Veto starten, sobald beide Teams bereit sind', action: 'Zur Lobby' }
|
||||
: { step: 2, text: 'Lobby offen: Klick auf „Ich bin bereit“', action: 'Zur Lobby' };
|
||||
case 'veto':
|
||||
return { step: 3, text: 'Das Map-Veto läuft', action: 'Zum Veto' };
|
||||
case 'server_setup':
|
||||
return { step: 4, text: 'Der Server wird eingerichtet, gleich geht es los', action: 'Zum Match' };
|
||||
default:
|
||||
return organizer
|
||||
? { step: 4, text: 'Match läuft: Aufwärmphase beenden, wenn alle da sind', action: 'Zum Match' }
|
||||
: { step: 4, text: 'Match läuft: Verbindung steht auf der Match-Seite', action: 'Zum Server' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches, für die dieser Browser einen Zugang hat, mit dem nächsten Schritt. */
|
||||
function MyMatches({ matches }) {
|
||||
return (
|
||||
<aside className="home-mine" aria-label="Deine Matches">
|
||||
<h2>{matches.length === 1 ? 'Dein Match' : 'Deine Matches'}</h2>
|
||||
<ul>
|
||||
{matches.map((m) => {
|
||||
const access = getAccess(m.id);
|
||||
const next = nextStep(m, access);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Link to={`/m/${m.id}`} className="mine-card">
|
||||
<span className="mine-teams">
|
||||
<span className="team-a">{m.teamA}</span> <span className="vs">vs.</span> <span className="team-b">{m.teamB}</span>
|
||||
</span>
|
||||
<span className="mine-meta">
|
||||
{m.modeLabel}, {FORMAT_LABEL[m.format]}
|
||||
{access.kind === 'organizer' ? ', du bist Organisator' : access.kind === 'spectator' ? ', als Zuschauer' : ''}
|
||||
</span>
|
||||
<span className="mine-next">{next.text}</span>
|
||||
<span className="btn btn-primary btn-sm mine-go">{next.action}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die vier Schritte vom Teamlink bis auf den Server. current = Schritt des eigenen Matches (0 = keins). */
|
||||
function Steps({ current }) {
|
||||
return (
|
||||
<ol className="home-steps">
|
||||
{STEPS.map((st, i) => {
|
||||
const n = i + 1;
|
||||
const state = current === 0 ? '' : n < current ? 'is-done' : n === current ? 'is-current' : '';
|
||||
return (
|
||||
<li key={st.title} className={state} aria-current={n === current ? 'step' : undefined}>
|
||||
<span className="step-n">{n}</span>
|
||||
<span className="step-title">{st.title}</span>
|
||||
<span className="step-text">{st.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match: m }) {
|
||||
const access = getAccess(m.id);
|
||||
const server = serverLabel(m.server);
|
||||
@@ -114,31 +206,54 @@ function RunningCard({ match: m }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
/** Code in die Form ABCD-EFGH bringen (Großbuchstaben, Bindestrich nach vier Zeichen). */
|
||||
function formatCode(raw) {
|
||||
const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||||
return clean.length > 4 ? `${clean.slice(0, 4)}-${clean.slice(4)}` : clean;
|
||||
}
|
||||
|
||||
/** Code-Eingabe im Stil einer Eintrittskarte. */
|
||||
function CodeTicket() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const complete = code.replace(/-/g, '').length === 8;
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api('/access', { method: 'POST', body: { code } });
|
||||
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
||||
navigate(res.access === 'spectator' ? `/watch/${code}` : `/join/${code}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="code-entry" onSubmit={submit}>
|
||||
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
||||
<div className="row">
|
||||
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
||||
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<form className="ticket" onSubmit={submit}>
|
||||
<label className="ticket-stub" htmlFor="code">Code</label>
|
||||
<input
|
||||
id="code"
|
||||
className="ticket-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(formatCode(e.target.value))}
|
||||
placeholder="ABCD-EFGH"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck="false"
|
||||
inputMode="text"
|
||||
aria-describedby={error ? 'code-error' : 'code-hint'}
|
||||
/>
|
||||
<button className="btn btn-primary ticket-go" disabled={!complete || busy}>{busy ? 'Prüfe …' : 'Beitreten'}</button>
|
||||
{error
|
||||
? <p id="code-error" className="field-error ticket-msg">{error}</p>
|
||||
: <p id="code-hint" className="muted small ticket-msg">Teamcode oder Zuschauercode, acht Zeichen.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
||||
function KnifeServers() {
|
||||
const [servers, setServers] = useState([]);
|
||||
|
||||
@@ -645,3 +645,110 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.settings-nav-item small { display: none; }
|
||||
.settings-nav-item.is-active { border-color: var(--primary); }
|
||||
}
|
||||
|
||||
/* ---------- Startseite: Spieler zuerst ---------- */
|
||||
.home-hero { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; margin: 8px 0 28px; }
|
||||
.home-hero.has-mine { grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr); }
|
||||
.home-intro h1 { font: 700 clamp(2.6rem, 6vw, 4.4rem)/.95 var(--display); letter-spacing: -.01em; margin: 0 0 14px; max-width: 14ch; }
|
||||
.home-intro .lead { max-width: 58ch; margin: 0 0 22px; }
|
||||
|
||||
/* Code-Eingabe als Eintrittskarte: Abriss links, Code groß in der Mitte */
|
||||
.ticket {
|
||||
--notch: 11px;
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 0;
|
||||
max-width: 620px; background: var(--surface-2); border: 1px solid var(--line-strong);
|
||||
clip-path: polygon(var(--chamfer) 0, 100% 0, 100% calc(100% - var(--chamfer)), calc(100% - var(--chamfer)) 100%, 0 100%, 0 var(--chamfer));
|
||||
}
|
||||
.ticket-stub {
|
||||
align-self: stretch; display: flex; align-items: center; padding: 0 18px 0 22px;
|
||||
font: 700 1.05rem var(--display); color: var(--muted); letter-spacing: .02em;
|
||||
border-right: 2px dashed var(--line-strong); background: color-mix(in srgb, var(--brand-red) 14%, transparent);
|
||||
}
|
||||
.ticket-code {
|
||||
min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); outline: none;
|
||||
font: 700 clamp(1.9rem, 4.6vw, 2.75rem)/1 var(--display); letter-spacing: .14em; padding: 16px 18px;
|
||||
}
|
||||
.ticket-code::placeholder { color: color-mix(in srgb, var(--muted) 45%, transparent); }
|
||||
.ticket:focus-within { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||
.ticket-go { margin: 0 14px 0 0; }
|
||||
.ticket-msg { grid-column: 1 / -1; margin: 0; padding: 8px 22px 12px; border-top: 1px solid var(--line); }
|
||||
|
||||
/* Eigene Matches */
|
||||
.home-mine h2 { font-size: 1.15rem; margin: 6px 0 10px; }
|
||||
.home-mine ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.mine-card {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: center;
|
||||
padding: 14px 16px; text-decoration: none; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--primary); border-radius: var(--radius);
|
||||
}
|
||||
.mine-card:hover { border-color: var(--line-strong); border-left-color: var(--primary-hover); }
|
||||
.mine-card:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
|
||||
.mine-teams { font: 700 1.35rem/1.1 var(--display); grid-column: 1; }
|
||||
.mine-meta { grid-column: 1; font-size: .85rem; color: var(--muted); }
|
||||
.mine-next { grid-column: 1; font-weight: 600; margin-top: 4px; }
|
||||
.mine-go { grid-column: 2; grid-row: 1 / span 3; }
|
||||
|
||||
/* Vier Schritte */
|
||||
.home-steps { list-style: none; margin: 0 0 36px; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); }
|
||||
.home-steps li { position: relative; display: grid; grid-template-columns: auto 1fr; align-content: start; column-gap: 12px; row-gap: 2px; padding: 14px 16px 4px 0; }
|
||||
.step-n {
|
||||
grid-row: 1; align-self: center; width: 34px; height: 34px; display: grid; place-items: center;
|
||||
font: 700 1.2rem var(--display); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 50%;
|
||||
}
|
||||
.step-title { font-weight: 700; align-self: center; }
|
||||
.step-text { grid-column: 2; font-size: .85rem; color: var(--muted); }
|
||||
.home-steps li.is-done .step-n { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 60%, transparent); }
|
||||
.home-steps li.is-current::before { content: ''; position: absolute; top: -2px; left: 0; right: 16px; height: 3px; background: var(--primary); }
|
||||
.home-steps li.is-current .step-n { color: #fff; background: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.home-live { margin-bottom: 36px; }
|
||||
.home-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
|
||||
.home-lists .match-row-meta { flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.home-hero.has-mine { grid-template-columns: minmax(0, 1fr); }
|
||||
.home-hero.has-mine .home-mine { order: -1; }
|
||||
.home-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.home-lists { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.ticket { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.ticket-stub { grid-column: 1 / -1; border-right: 0; border-bottom: 2px dashed var(--line-strong); padding: 8px 18px; }
|
||||
.ticket-code { letter-spacing: .08em; padding: 12px 14px; }
|
||||
.home-steps { grid-template-columns: minmax(0, 1fr); }
|
||||
.home-steps li { padding: 10px 0 10px 12px; }
|
||||
.home-steps li.is-current::before { top: 8px; bottom: 8px; left: 0; right: auto; width: 3px; height: auto; }
|
||||
.mine-card { grid-template-columns: minmax(0, 1fr); }
|
||||
.mine-go { grid-column: 1; grid-row: auto; justify-self: start; margin-top: 6px; }
|
||||
}
|
||||
/* Seitenwahl per Messerrunde in der Lobby */
|
||||
.knife-round { margin: 14px 0; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--brand-red); border-radius: var(--radius); background: var(--surface-2); text-align: left; }
|
||||
.knife-round h3 { margin: 0 0 6px; font-size: 1.05rem; }
|
||||
.knife-round .knife-list { margin-top: 10px; }
|
||||
.knife-decide { margin: 6px 0 4px; }
|
||||
.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; }
|
||||
@@ -195,7 +195,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
||||
action('server/pause', (v) => manager.setPaused(v, true));
|
||||
action('server/resume', (v) => manager.setPaused(v, false));
|
||||
action('series/next', (v) => manager.loadNextMap(v));
|
||||
action('veto/start', (v) => service.startVeto(v));
|
||||
action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
|
||||
action('veto/action', (v, b) => service.vetoAction(
|
||||
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||||
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -113,11 +113,12 @@ 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));
|
||||
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||||
action('veto/start', (v) => service.startVeto(v));
|
||||
action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
|
||||
action('veto/action', (v, b) => service.vetoAction(v, b));
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
|
||||
@@ -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;
|
||||
@@ -150,7 +154,8 @@ export class MatchService extends EventEmitter {
|
||||
if (!formatInfo?.supported) {
|
||||
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
|
||||
}
|
||||
if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
|
||||
// knife = Seitenwahl per Messerrunde: der Organisator legt nach der Runde fest, wer das Veto beginnt
|
||||
if (!['coin', 'A', 'B', 'knife'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
|
||||
const nameA = cleanName(teamA, 'Name von Team A');
|
||||
const nameB = cleanName(teamB, 'Name von Team B');
|
||||
if (nameA.toLowerCase() === nameB.toLowerCase()) {
|
||||
@@ -326,7 +331,11 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
// ---------- Veto ----------
|
||||
|
||||
startVeto(viewer) {
|
||||
/**
|
||||
* Veto starten. Bei Seitenwahl per Messerrunde (veto_start = knife) gibt der Organisator mit
|
||||
* `firstTeam` an, welches Team das Veto beginnt.
|
||||
*/
|
||||
startVeto(viewer, { firstTeam: chosen } = {}) {
|
||||
this.require(viewer, 'organizer');
|
||||
this.db.transaction(() => {
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
@@ -335,11 +344,16 @@ export class MatchService extends EventEmitter {
|
||||
if (!readiness.A.ready || !readiness.B.ready) {
|
||||
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready');
|
||||
}
|
||||
const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
|
||||
const knife = match.veto_start === 'knife';
|
||||
if (knife && !['A', 'B'].includes(chosen)) {
|
||||
throw new HttpError(400, 'Nach der Messerrunde festlegen, welches Team das Veto beginnt', 'first_team_required');
|
||||
}
|
||||
const firstTeam = knife ? chosen : match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
|
||||
this.db
|
||||
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
|
||||
.run(firstTeam, this.now(), this.now(), match.id);
|
||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||
const how = knife ? 'nach der Messerrunde vom Organisator festgelegt'
|
||||
: match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
||||
this.notify('veto_start', match.id, {
|
||||
title: '🎮 Veto gestartet',
|
||||
@@ -625,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);
|
||||
@@ -947,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
|
||||
|
||||
@@ -607,3 +607,86 @@ test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, la
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Seitenwahl per Messerrunde: Organisator legt fest, wer das Veto beginnt', async () => {
|
||||
const { app, call, close } = await setup();
|
||||
try {
|
||||
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'knife' } });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
const { id, organizerToken: org } = created.body;
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.equal(snap.vetoStart, 'knife');
|
||||
const codes = snap.organizer.codes;
|
||||
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
||||
const r = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
|
||||
await call('POST', `/matches/${id}/ready`, { token: r.body.playerToken, body: { ready: true } });
|
||||
}
|
||||
// Ohne Angabe, wer beginnt, startet das Veto nicht
|
||||
const missing = await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.code, 'first_team_required');
|
||||
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'X' } })).status, 400);
|
||||
|
||||
const ok = await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.equal(after.status, 'veto');
|
||||
assert.equal(after.veto.current.team, 'B', 'das gewählte Team beginnt');
|
||||
assert.ok(app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND message LIKE '%Messerrunde%'").get(id));
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
||||
await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body;
|
||||
assert.equal(snap.veto.current.team, 'A', 'feste Startregel A gilt');
|
||||
} finally {
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user