Stream-Übersicht aller laufenden Vetos und Matches

/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 19:32:53 +01:00
1 parent 54bfe198d8
commit 8996294d7e
11 files changed
+495 -3

No files matched your search

+2
View File
@@ -66,3 +66,5 @@ LOG_RECEIVER_URL=
LOG_DEBUG=false
# Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
SERIES_AUTO_ADVANCE_SECONDS=30
# Schlüssel für die Stream-Übersicht aller Matches (/stream?key=...); leer = offen
STREAM_KEY=
+14 -1
View File
@@ -293,7 +293,19 @@ Zwei Eigenheiten von CS2 berücksichtigt der Parser:
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
- Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.
### Stream-Übersicht
### Stream-Übersicht aller Matches
Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle laufenden Vetos und Matches gleichzeitig, live aktualisiert:
- **Karten:** eine Karte pro Match.
- Im Veto: wer am Zug ist, Fortschritt, Map-Board.
- Im Match: aktuelle Map mit Live-Stand, CT/T-Seiten und Serienstand.
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
### Stream-Übersicht eines Matches
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
@@ -326,6 +338,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
| `DEMO_ROUND_SECONDS` | `4` | Demo: Sekunden pro simulierter Runde |
| `LOG_RECEIVER_URL` | `PUBLIC_URL` | Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht |
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
Benötigte Zugangsdaten für den Echtbetrieb:
+3
View File
@@ -5,6 +5,7 @@ import Overview from './pages/Overview.jsx';
import CreateMatch from './pages/CreateMatch.jsx';
import MatchPage from './pages/MatchPage.jsx';
import StreamPage from './pages/StreamPage.jsx';
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
export default function App() {
@@ -12,6 +13,7 @@ export default function App() {
<Routes>
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
<Route path="/stream/:code" element={<StreamPage />} />
<Route path="/stream" element={<StreamOverviewPage />} />
<Route path="*" element={<MainLayout />} />
</Routes>
);
@@ -39,6 +41,7 @@ function MainLayout() {
)}
<nav>
<Link to="/" className="nav-link">Übersicht</Link>
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
</nav>
</header>
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useState } from 'react';
import { api } from '../lib/api.js';
import { FORMAT_LABEL } from '../lib/labels.js';
import { getSocket } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
import SideIcon from '../components/SideIcon.jsx';
import { ScaledStage, useClock } from './StreamPage.jsx';
import '../stream.css';
import '../stream-overview.css';
const PER_PAGE = 6;
const PAGE_SECONDS = 12;
/**
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
*/
export default function StreamOverviewPage() {
const params = new URLSearchParams(window.location.search);
const key = params.get('key') ?? '';
const transparent = params.get('bg') === 'transparent';
const { data, error } = useStreamOverview(key);
const now = useClock();
const [page, setPage] = useState(0);
useEffect(() => {
document.documentElement.classList.toggle('stream-transparent', transparent);
document.documentElement.classList.add('stream-mode');
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
}, [transparent]);
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
useEffect(() => {
if (pages <= 1) { setPage(0); return undefined; }
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
return () => clearInterval(t);
}, [pages]);
if (error) {
return (
<ScaledStage>
<div className="st-message">
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
<h1>Stream-Übersicht nicht verfügbar</h1>
<p>{error}</p>
</div>
</ScaledStage>
);
}
const active = data?.active ?? [];
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
const liveCount = active.filter((m) => m.status === 'live').length;
const vetoCount = active.filter((m) => m.status === 'veto').length;
const density = shown.length <= 1 ? 1 : shown.length <= 4 ? 2 : 3;
return (
<ScaledStage>
<div className="so-root">
<header className="st-top">
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
<div className="so-title">
<strong>Counter-Strike 2</strong>
{liveCount > 0 && <span className="so-count so-count-live">{liveCount} live</span>}
{vetoCount > 0 && <span className="so-count">{vetoCount} im Veto</span>}
{pages > 1 && <span className="so-count">Seite {page + 1} / {pages}</span>}
</div>
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
</header>
<main className={`so-grid density-${density} count-${shown.length}`}>
{!data && <div className="so-empty">Lade Matches …</div>}
{data && shown.length === 0 && (
<div className="so-empty">
<h2>Gerade läuft kein Match</h2>
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
</div>
)}
{shown.map((m) => <MatchCard key={m.id} m={m} />)}
</main>
<footer className="so-bottom">
<section className="so-strip">
<h3>Gleich</h3>
{data?.upcoming.length ? (
<ul>
{data.upcoming.map((m) => (
<li key={m.id}>
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
</li>
))}
</ul>
) : <p className="so-none">Keine offenen Lobbys</p>}
</section>
<section className="so-strip">
<h3>Ergebnisse</h3>
{data?.results.length ? (
<ul>
{data.results.map((m) => (
<li key={m.id}>
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
<b>{resultScore(m)}</b>
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
</li>
))}
</ul>
) : <p className="so-none">Noch keine Ergebnisse</p>}
</section>
</footer>
</div>
</ScaledStage>
);
}
function useStreamOverview(key) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('stream:subscribe', { key });
const onData = (d) => { setData(d); setError(null); };
const onError = (e) => setError(e.error);
socket.on('stream:overview', onData);
socket.on('stream:error', onError);
socket.on('connect', subscribe);
if (socket.connected) subscribe();
api(`/stream/overview?key=${encodeURIComponent(key)}`).then(onData).catch((e) => setError(e.message));
return () => {
socket.off('stream:overview', onData);
socket.off('stream:error', onError);
socket.off('connect', subscribe);
};
}, [key]);
return { data, error };
}
/** Bei BO1 zählt der Map-Stand, sonst der Serienstand. */
function resultScore(m) {
if (m.format === 'bo1' && m.series[0]) return `${m.series[0].score.A}:${m.series[0].score.B}`;
return `${m.seriesScore.A}:${m.seriesScore.B}`;
}
function winner(m) {
const [a, b] = resultScore(m).split(':').map(Number);
return a === b ? null : a > b ? 'A' : 'B';
}
function MatchCard({ m }) {
const current = m.series.find((s) => s.number === m.currentMapNumber);
const isBo1 = m.format === 'bo1';
const playing = m.status === 'live' && current?.live?.status === 'live' && current.status !== 'done';
const badge = m.status === 'veto' ? 'Veto' : m.status === 'server_setup' ? 'Server' : playing ? 'Live' : current?.status === 'done' ? 'Map beendet' : 'Aufwärmen';
return (
<article className={`so-card status-${m.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': current?.color ?? '#556' }}>
{m.status !== 'veto' && current && <MapImage map={current} className="so-card-art" />}
<div className="so-card-head">
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
<span className={`so-badge badge-${badge === 'Live' ? 'live' : 'other'}`}>{badge}</span>
</div>
<div className="so-teams">
<span className="so-team a">{m.teams.A}</span>
<span className="so-series">
{!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</> : <i className="so-vs">vs</i>}
</span>
<span className="so-team b">{m.teams.B}</span>
</div>
{m.status === 'veto' && <VetoBody m={m} />}
{m.status === 'server_setup' && current && (
<div className="so-setup">Server wird eingerichtet: Map {current.number}, {current.name}</div>
)}
{m.status === 'live' && current && <LiveBody m={m} current={current} />}
</article>
);
}
function VetoBody({ m }) {
const { veto, teams } = m;
const c = veto.current;
const action = c && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Seite' })[c.type];
return (
<div className="so-veto">
<div className="so-turn">
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
</div>
<div className="so-vmaps" style={{ '--count': veto.maps.length }}>
{veto.maps.map((x) => (
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
<MapImage map={x} />
<span className="so-vmap-name">{x.name}</span>
</div>
))}
</div>
</div>
);
}
function LiveBody({ m, current }) {
const sides = current.live?.sides;
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
const finished = current.status === 'done';
const rounds = current.live?.roundsPlayed ?? 0;
return (
<div className="so-live">
<div className="so-map">
<strong>{current.name}</strong>
<span>{finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
</div>
<div className="so-score">
{sideOf('A') && <SideIcon side={sideOf('A')} size={44} />}
<b className={`a ${current.winner === 'A' ? 'won' : ''}`}>{current.score.A}</b>
<i>:</i>
<b className={`b ${current.winner === 'B' ? 'won' : ''}`}>{current.score.B}</b>
{sideOf('B') && <SideIcon side={sideOf('B')} size={44} />}
</div>
{m.series.length > 1 && (
<ol className="so-maps">
{m.series.map((s) => (
<li key={s.number} className={`is-${s.status} ${s.number === current.number ? 'is-current' : ''}`}>
<span>{s.name}</span>
{(s.status === 'done' || s.number === current.number) && <b>{s.score.A}:{s.score.B}</b>}
</li>
))}
</ol>
)}
</div>
);
}
+2 -2
View File
@@ -46,7 +46,7 @@ export default function StreamPage() {
}
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
function ScaledStage({ children }) {
export function ScaledStage({ children }) {
const ref = useRef(null);
const [scale, setScale] = useState(1);
useLayoutEffect(() => {
@@ -74,7 +74,7 @@ function StreamMessage({ title, text }) {
);
}
function useClock() {
export function useClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
+116
View File
@@ -0,0 +1,116 @@
/* ---------- Stream-Übersicht aller Matches (1920×1080) ---------- */
.so-root {
width: 100%; height: 100%; overflow: hidden;
display: grid; grid-template-rows: 104px 1fr 190px; padding: 0 56px;
color: var(--text); font-family: var(--body);
background:
radial-gradient(900px 600px at 0% 20%, rgba(52, 74, 153, .25), transparent 70%),
radial-gradient(900px 600px at 100% 20%, rgba(167, 42, 28, .22), transparent 70%),
#000;
}
.stream-transparent .so-root { background: transparent; }
.so-title { display: flex; align-items: center; gap: 18px; font: 600 30px var(--display); }
.so-count { padding: 4px 14px; border-radius: 4px; background: var(--surface-2); color: var(--muted); font-size: 24px; }
.so-count-live { background: var(--brand-red); color: #fff; }
/* Raster je nach Anzahl */
.so-grid { display: grid; gap: 24px; padding: 24px 0; min-height: 0; }
.so-grid.count-1 { grid-template-columns: 1fr; }
.so-grid.count-2 { grid-template-columns: 1fr 1fr; }
.so-grid.count-3, .so-grid.count-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.so-grid.count-5, .so-grid.count-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr 1fr; }
.so-empty { grid-column: 1 / -1; display: grid; place-content: center; text-align: center; color: var(--muted); }
.so-empty h2 { font: 700 64px var(--display); color: var(--text); margin-bottom: 12px; }
.so-empty p { font-size: 28px; }
/* Karte */
.so-card {
position: relative; overflow: hidden; min-height: 0; border-radius: 10px;
display: grid; grid-template-rows: auto auto 1fr; gap: 10px; padding: 22px 28px;
background: rgba(17, 20, 30, .82); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
}
.so-card.is-playing { box-shadow: inset 0 0 0 3px var(--brand-red), 0 0 40px rgba(167, 42, 28, .35); }
.so-card-art { opacity: .3; }
.so-card-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .4), rgba(0, 0, 0, .9)); }
.so-card > :not(.so-card-art) { position: relative; z-index: 1; }
.so-card-head { display: flex; justify-content: space-between; align-items: center; font: 600 22px var(--display); color: var(--muted); }
.so-badge { padding: 3px 12px; border-radius: 4px; background: var(--surface-2); color: var(--text); font-size: 20px; }
.so-badge.badge-live { background: var(--brand-red); color: #fff; }
.so-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
.so-team { font: 700 52px/1 var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.so-team.a { color: var(--team-a); text-align: right; }
.so-team.b { color: var(--team-b); }
.so-series { font: 700 56px var(--display); font-variant-numeric: tabular-nums; }
.so-series i { font-style: normal; color: var(--muted); margin: 0 .15em; }
.so-series .so-vs { font-size: 32px; color: var(--muted); margin: 0; }
/* Veto */
.so-veto { display: grid; grid-template-rows: auto 1fr; gap: 14px; min-height: 0; }
.so-turn { display: flex; align-items: baseline; justify-content: center; gap: 14px; font: 600 32px var(--display); }
.so-turn .a { color: var(--team-a); }
.so-turn .b { color: var(--team-b); }
.so-turn small { font: 500 20px var(--body); color: var(--muted); }
.so-vmaps { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 10px; min-height: 0; }
.so-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; align-items: flex-end; padding: 10px; min-height: 0; }
.so-vmap.by-A { --by: var(--team-a); }
.so-vmap.by-B { --by: var(--team-b); }
.so-vmap-name { position: relative; z-index: 2; font: 700 24px/1 var(--display); text-shadow: 0 2px 8px #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.so-vmap .map-art::after { background: linear-gradient(180deg, transparent 40%, rgba(0, 0, 0, .85)); }
.so-vmap.status-banned { filter: grayscale(.95) brightness(.45); }
.so-vmap.status-banned::after {
content: ''; position: absolute; inset: 0; z-index: 3;
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
}
.so-vmap.status-picked::before, .so-vmap.status-decider::before { content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 4px var(--by); }
.so-vmap.status-decider { --by: #fff; }
/* Live */
.so-live { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; align-items: center; min-height: 0; }
.so-map { display: flex; align-items: baseline; gap: 16px; }
.so-map strong { font: 700 40px var(--display); }
.so-map span { font: 600 24px var(--display); color: var(--pending); }
.so-score { display: flex; align-items: center; gap: 22px; }
.so-score b { font: 700 140px/0.9 var(--display); font-variant-numeric: tabular-nums; }
.so-score b.a { color: var(--team-a); }
.so-score b.b { color: var(--team-b); }
.so-score b.won { text-shadow: 0 0 30px currentColor; }
.so-score i { font: 700 70px var(--display); font-style: normal; color: var(--muted); }
.so-maps { list-style: none; display: flex; gap: 10px; padding: 0; margin: 0; }
.so-maps li { display: flex; gap: 10px; align-items: baseline; padding: 6px 14px; border-radius: 4px; background: rgba(0, 0, 0, .55); font: 600 22px var(--display); color: var(--muted); }
.so-maps li.is-current { color: var(--text); box-shadow: inset 0 -3px 0 var(--brand-red); }
.so-maps li b { color: var(--text); font-variant-numeric: tabular-nums; }
.so-setup { align-self: center; justify-self: center; font: 600 32px var(--display); color: var(--muted); }
/* Dichte: weniger Matches = größere Schrift */
.density-1 .so-team { font-size: 96px; }
.density-1 .so-series { font-size: 110px; }
.density-1 .so-score b { font-size: 260px; }
.density-1 .so-map strong { font-size: 60px; }
.density-1 .so-vmap-name { font-size: 40px; }
.density-1 .so-turn { font-size: 44px; }
.density-3 .so-team { font-size: 38px; }
.density-3 .so-series { font-size: 42px; }
.density-3 .so-score b { font-size: 96px; }
.density-3 .so-score .side-icon { width: 32px; height: 32px; }
.density-3 .so-map strong { font-size: 30px; }
.density-3 .so-vmap-name { font-size: 18px; }
.density-3 .so-turn { font-size: 26px; }
.density-3 .so-maps li { font-size: 18px; padding: 4px 10px; }
/* Unten: Lobbys und Ergebnisse */
.so-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding-bottom: 28px; min-height: 0; }
.so-strip { background: rgba(17, 20, 30, .82); border-radius: 10px; padding: 16px 24px; overflow: hidden; border-top: 4px solid var(--line-strong); }
.so-strip:last-child { border-top-color: var(--brand-red); }
.so-strip h3 { font: 700 26px var(--display); color: var(--muted); margin: 0 0 10px; }
.so-strip ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 28px; }
.so-strip li { display: flex; align-items: baseline; gap: 10px; font: 600 26px var(--display); white-space: nowrap; overflow: hidden; }
.so-strip li .a { color: var(--team-a); }
.so-strip li .b { color: var(--team-b); }
.so-strip li .vs { color: var(--muted); font-size: 20px; }
.so-strip li small { font: 500 18px var(--body); color: var(--muted); }
.so-strip li b { font-variant-numeric: tabular-nums; }
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
+2
View File
@@ -39,6 +39,8 @@ export function loadConfig(overrides = {}) {
databaseFile: path.resolve(REPO_ROOT, str('DATABASE_FILE', 'data/cs2-mapveto.db')),
mapPoolFile: path.resolve(REPO_ROOT, str('MAP_POOL_FILE', 'server/config/map-pools.json')),
createMatchPassword: str('CREATE_MATCH_PASSWORD'),
// Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
streamKey: str('STREAM_KEY'),
requireFullTeams: bool('REQUIRE_FULL_TEAMS', false),
trustProxy: bool('TRUST_PROXY', false),
+8
View File
@@ -69,6 +69,14 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
router.get('/stream/overview', wrap((req) => {
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
}
return service.streamOverview();
}));
router.post('/matches', limit('create'), wrap((req) => {
const { teamA, teamB, mode, format, vetoStart, password } = req.body ?? {};
if (config.createMatchPassword && !safeEqual(password ?? '', config.createMatchPassword)) {
+70
View File
@@ -561,6 +561,76 @@ export class MatchService extends EventEmitter {
this.emit('changed', matchId);
}
/**
* Übersicht aller aktiven Matches für die Stream-Seite: laufende Vetos und Matches mit Details,
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
* keine Codes, keine Verbindungsdaten.
*/
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const now = this.now();
const active = [];
const upcoming = [];
const results = [];
for (const m of rows) {
const pool = this.pools[m.mode];
const maps = JSON.parse(m.maps);
const mapInfo = (id) => {
const map = maps.find((x) => x.id === id);
return { id, name: map?.name ?? id, image: map?.image ?? null, color: map?.color ?? null };
};
const base = {
id: m.id,
teams: { A: m.team_a_name, B: m.team_b_name },
mode: m.mode,
modeLabel: pool?.label ?? m.mode,
format: m.format,
status: m.status,
statusLabel: MATCH_STATUS[m.status],
};
if (m.status === 'lobby') {
const readiness = this.teamReadiness(m);
upcoming.push({ ...base, ready: { A: readiness.A.ready, B: readiness.B.ready } });
continue;
}
const seriesRows = this.seriesMaps(m.id);
const series = seriesRows.map((s) => {
const live = s.live ? JSON.parse(s.live) : null;
return {
number: s.number,
...mapInfo(s.map),
pickedBy: s.picked_by,
status: s.status,
score: { A: s.score_a, B: s.score_b },
winner: s.winner,
live: live && { status: live.status, sides: live.sides, roundsPlayed: live.rounds.length },
};
});
const item = { ...base, seriesScore: seriesScore(seriesRows), currentMapNumber: m.current_map_number, series };
if (CLOSED_STATUSES.includes(m.status)) {
if (m.status === 'finished' && now - (m.finished_at ?? 0) < resultsWithinMs) results.push(item);
continue;
}
if (m.status === 'veto') {
const veto = this.vetoState(m);
item.veto = {
current: veto.current,
maps: veto.maps.map((x) => ({ ...mapInfo(x.id), status: x.status, by: x.by })),
stepsDone: veto.steps.filter((x) => x.done).length,
stepsTotal: veto.steps.length,
};
}
active.push(item);
}
// Laufende Matches zuerst, dann Vetos, dann Serverstart – jeweils älteste zuerst
const order = { live: 0, veto: 1, server_setup: 2 };
active.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
return { active, upcoming: upcoming.slice(0, 8), results: results.slice(0, 8), updatedAt: now };
}
listMatches() {
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
const counts = this.db.prepare('SELECT team, COUNT(*) AS n FROM players WHERE match_id = ? AND active = 1 GROUP BY team');
+17
View File
@@ -1,4 +1,5 @@
import { Server } from 'socket.io';
import { safeEqual } from './security.js';
/**
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
@@ -50,9 +51,16 @@ export function attachRealtime(httpServer, { service, config }) {
}, 50));
};
let streamTimer = null;
const broadcastStream = () => {
clearTimeout(streamTimer);
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
};
service.on('changed', (matchId) => {
broadcastMatch(matchId);
broadcastOverview();
broadcastStream();
});
io.on('connection', (socket) => {
@@ -61,6 +69,15 @@ export function attachRealtime(httpServer, { service, config }) {
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('stream:subscribe', ({ key } = {}) => {
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
return;
}
socket.join('stream-overview');
socket.emit('stream:overview', service.streamOverview());
});
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
+29
View File
@@ -348,6 +348,35 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
}
});
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
const { app, call, close } = await setup({ streamKey: 'geheim123' });
try {
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
await call('POST', `/matches/${id}/veto/start`, { token: org });
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
assert.equal((await call('GET', '/stream/overview')).status, 403);
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
const res = await call('GET', '/stream/overview?key=geheim123');
assert.equal(res.status, 200);
const { active, upcoming } = res.body;
assert.equal(active.length, 1);
assert.equal(active[0].status, 'veto');
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
assert.equal(active[0].veto.current.team, 'B');
assert.ok(upcoming.some((m) => m.id === lobby.id));
const json = JSON.stringify(res.body);
const row = app.service.getMatch(id);
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
}
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {