From 85041b2bff056b7181ad69d6e4b8e680cbdb6d79 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 25 Oct 2026 19:32:53 +0100 Subject: [PATCH] =?UTF-8?q?Stream-=C3=9Cbersicht=20aller=20laufenden=20Vet?= =?UTF-8?q?os=20und=20Matches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- .env.example | 2 + README.md | 15 +- client/src/App.jsx | 3 + client/src/pages/StreamOverviewPage.jsx | 232 ++++++++++++++++++++++++ client/src/pages/StreamPage.jsx | 4 +- client/src/stream-overview.css | 116 ++++++++++++ server/src/config.js | 2 + server/src/http.js | 8 + server/src/matchService.js | 70 +++++++ server/src/realtime.js | 17 ++ server/test/api.test.js | 29 +++ 11 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 client/src/pages/StreamOverviewPage.jsx create mode 100644 client/src/stream-overview.css diff --git a/.env.example b/.env.example index 94a139e..6116ff5 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index f2b27fe..f311a6c 100644 --- a/README.md +++ b/README.md @@ -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=` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. + +### Stream-Übersicht eines Matches Unter `/stream/` 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: diff --git a/client/src/App.jsx b/client/src/App.jsx index a7b85dd..16fdd7d 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { {/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */} } /> + } /> } /> ); @@ -39,6 +41,7 @@ function MainLayout() { )} diff --git a/client/src/pages/StreamOverviewPage.jsx b/client/src/pages/StreamOverviewPage.jsx new file mode 100644 index 0000000..3353b79 --- /dev/null +++ b/client/src/pages/StreamOverviewPage.jsx @@ -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=. 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 ( + +
+ MaxLAN e.V. +

Stream-Übersicht nicht verfügbar

+

{error}

+
+
+ ); + } + + 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 ( + +
+
+ MaxLAN e.V. +
+ Counter-Strike 2 + {liveCount > 0 && {liveCount} live} + {vetoCount > 0 && {vetoCount} im Veto} + {pages > 1 && Seite {page + 1} / {pages}} +
+
+
+ +
+ {!data &&
Lade Matches …
} + {data && shown.length === 0 && ( +
+

Gerade läuft kein Match

+

{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}

+
+ )} + {shown.map((m) => )} +
+ +
+
+

Gleich

+ {data?.upcoming.length ? ( +
    + {data.upcoming.map((m) => ( +
  • + {m.teams.A} vs {m.teams.B} + {FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''} +
  • + ))} +
+ ) :

Keine offenen Lobbys

} +
+
+

Ergebnisse

+ {data?.results.length ? ( +
    + {data.results.map((m) => ( +
  • + {m.teams.A} + {resultScore(m)} + {m.teams.B} +
  • + ))} +
+ ) :

Noch keine Ergebnisse

} +
+
+
+
+ ); +} + +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 ( +
+ {m.status !== 'veto' && current && } +
+ {m.modeLabel}, {FORMAT_LABEL[m.format]} + {badge} +
+ +
+ {m.teams.A} + + {!isBo1 && m.status !== 'veto' ? <>{m.seriesScore.A}:{m.seriesScore.B} : vs} + + {m.teams.B} +
+ + {m.status === 'veto' && } + {m.status === 'server_setup' && current && ( +
Server wird eingerichtet: Map {current.number}, {current.name}
+ )} + {m.status === 'live' && current && } +
+ ); +} + +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 ( +
+
+ {c ? <>{teams[c.team]} {action} : 'Veto abgeschlossen'} + Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal} +
+
+ {veto.maps.map((x) => ( +
+ + {x.name} +
+ ))} +
+
+ ); +} + +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 ( +
+
+ {current.name} + {finished ? 'Endstand' : current.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'} +
+
+ {sideOf('A') && } + {current.score.A} + : + {current.score.B} + {sideOf('B') && } +
+ {m.series.length > 1 && ( +
    + {m.series.map((s) => ( +
  1. + {s.name} + {(s.status === 'done' || s.number === current.number) && {s.score.A}:{s.score.B}} +
  2. + ))} +
+ )} +
+ ); +} diff --git a/client/src/pages/StreamPage.jsx b/client/src/pages/StreamPage.jsx index 12ec570..e5d9e12 100644 --- a/client/src/pages/StreamPage.jsx +++ b/client/src/pages/StreamPage.jsx @@ -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); diff --git a/client/src/stream-overview.css b/client/src/stream-overview.css new file mode 100644 index 0000000..ceeb4d2 --- /dev/null +++ b/client/src/stream-overview.css @@ -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; } diff --git a/server/src/config.js b/server/src/config.js index d48ec9b..950e631 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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), diff --git a/server/src/http.js b/server/src/http.js index e9ed45e..c8fc8a6 100644 --- a/server/src/http.js +++ b/server/src/http.js @@ -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)) { diff --git a/server/src/matchService.js b/server/src/matchService.js index 76ddc4a..2cd41f6 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -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'); diff --git a/server/src/realtime.js b/server/src/realtime.js index 4ef46f0..739c016 100644 --- a/server/src/realtime.js +++ b/server/src/realtime.js @@ -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)); diff --git a/server/test/api.test.js b/server/test/api.test.js index 4077670..30b16dd 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -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 {