Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand) - Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht - Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe, Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit - Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie - LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
This commit is contained in:
1 parent
306b3aa7e5
commit
263c7e7b7c
20 files changed
+899
-42
No files matched your search
@@ -58,3 +58,11 @@ DOCKER_BIN=docker
|
||||
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
||||
# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
|
||||
CS2_STATIC_SERVERS=
|
||||
|
||||
# --- Live-Ergebnisse ---
|
||||
# Adresse, unter der der CS2-Server dieses Backend erreicht (Standard: PUBLIC_URL), z. B. http://192.168.178.31:3000
|
||||
LOG_RECEIVER_URL=
|
||||
# true: empfangene Logzeilen im Terminal ausgeben (Fehlersuche)
|
||||
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
|
||||
@@ -269,7 +269,38 @@ Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Win
|
||||
### Bekannte Grenzen von Vanilla-CS2
|
||||
|
||||
- Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
|
||||
- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell.
|
||||
- Das Team eines Spielers ergibt sich aus der Seite, die er im Spiel wählt. Wechselt ein Spieler ins falsche Team, zählen seine Runden für das falsche Team.
|
||||
|
||||
### Live-Ergebnisse
|
||||
|
||||
Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add_http "<LOG_RECEIVER_URL>/api/logs/<match>/<schlüssel>"`. Der CS2-Server schickt damit seine Logzeilen per HTTP an das Backend. Der Schlüssel ist pro Match und Serverstart zufällig, Zeilen mit falschem Schlüssel werden abgelehnt.
|
||||
|
||||
- **Was ausgewertet wird:**
|
||||
- Matchstart nach der Aufwärmphase
|
||||
- jedes Rundenende mit Sieger und Grund (Bombe, Entschärfung, Zeit, Ausschalten)
|
||||
- die Seitenzuordnung der Teams
|
||||
- das Spielende
|
||||
- **Was angezeigt wird:**
|
||||
- Live-Spielstand und Rundenverlauf pro Map
|
||||
- Serienstand
|
||||
- Ergebnisse in der Übersicht
|
||||
- Stream-Seite
|
||||
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
||||
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
||||
- **Geprüft:** mit einem AMP-Server (Wingman), siehe `server/test/fixtures/amp-wingman-inferno.log`.
|
||||
|
||||
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
|
||||
|
||||
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||
|
||||
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
||||
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
||||
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
||||
- **Datenschutz:** Verbindungsdaten und Passwörter erscheinen dort nie.
|
||||
|
||||
---
|
||||
|
||||
@@ -292,6 +323,10 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
||||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||||
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
|
||||
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
|
||||
| `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 |
|
||||
| `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:
|
||||
|
||||
|
||||
@@ -4,9 +4,20 @@ import { api } from './lib/api.js';
|
||||
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 { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||
<Route path="/stream/:code" element={<StreamPage />} />
|
||||
<Route path="*" element={<MainLayout />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function MainLayout() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configError, setConfigError] = useState(null);
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
||||
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
|
||||
</div>
|
||||
<CopyField value={links[key]} />
|
||||
{key === 'spectator' && (
|
||||
<div className="stream-link">
|
||||
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<details className="org-link">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Rundenverlauf einer Map: ein Feld pro Runde in der Farbe des Siegerteams,
|
||||
// mit Symbol für den Grund. Nach der Halbzeit (und jeder Verlängerungshälfte) ein Trenner.
|
||||
|
||||
export const REASON_LABEL = {
|
||||
bomb: 'Bombe explodiert',
|
||||
defuse: 'Bombe entschärft',
|
||||
time: 'Zeit abgelaufen',
|
||||
elimination: 'Alle ausgeschaltet',
|
||||
surrender: 'Aufgegeben',
|
||||
other: 'Runde gewonnen',
|
||||
};
|
||||
|
||||
export function ReasonIcon({ reason, size = 12 }) {
|
||||
const common = { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, className: 'reason-icon' };
|
||||
switch (reason) {
|
||||
case 'bomb':
|
||||
return (
|
||||
<svg {...common}><circle cx="7" cy="9.5" r="5" fill="currentColor" /><path d="M10.5 5.5 13 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="13.6" cy="2.4" r="1.4" fill="currentColor" /></svg>
|
||||
);
|
||||
case 'defuse':
|
||||
return (
|
||||
<svg {...common}><path d="M3 13 9.5 6.5M6.5 3.5 12.5 9.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /><circle cx="11.5" cy="4.5" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
|
||||
);
|
||||
case 'time':
|
||||
return (
|
||||
<svg {...common}><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.8" /><path d="M8 4.5V8l2.5 1.8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||
);
|
||||
case 'elimination':
|
||||
return (
|
||||
<svg {...common}><circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M8 1v4M8 11v4M1 8h4M11 8h4" stroke="currentColor" strokeWidth="1.6" /></svg>
|
||||
);
|
||||
default:
|
||||
return <svg {...common}><circle cx="8" cy="8" r="3" fill="currentColor" /></svg>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RoundStrip({ rounds = [], maxRounds = 24, teams, size = 'md' }) {
|
||||
const half = maxRounds / 2;
|
||||
const total = Math.max(rounds.length, maxRounds);
|
||||
const cells = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const r = rounds.find((x) => x.number === i);
|
||||
const breakBefore = i > 1 && (i - 1 === half || (i - 1 > maxRounds && (i - 1 - maxRounds) % 3 === 0));
|
||||
cells.push(
|
||||
<li key={i}
|
||||
className={`round ${r ? `won-${r.winner}` : 'open'} ${breakBefore ? 'break' : ''}`}
|
||||
title={r ? `Runde ${i}: ${teams[r.winner].name} (${REASON_LABEL[r.reason] ?? ''})` : `Runde ${i}`}>
|
||||
{r ? <ReasonIcon reason={r.reason} /> : null}
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
return <ol className={`round-strip round-strip-${size}`} aria-label="Rundenverlauf">{cells}</ol>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import MapImage from './MapImage.jsx';
|
||||
import SideIcon from './SideIcon.jsx';
|
||||
import RoundStrip from './RoundStrip.jsx';
|
||||
|
||||
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
|
||||
|
||||
@@ -13,11 +14,26 @@ export default function SeriesList({ state }) {
|
||||
const map = state.maps.find((m) => m.id === s.map);
|
||||
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
|
||||
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
|
||||
const isLive = s.live?.status === 'live' && s.status !== 'done';
|
||||
const hasScore = s.live && (s.live.rounds.length > 0 || s.status === 'done');
|
||||
return (
|
||||
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||
<li key={s.number} className={`series-item is-${s.status} ${isLive ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||
<MapImage map={map} className="series-art" />
|
||||
<span className="series-no">Map {s.number}</span>
|
||||
<strong className="series-map">{s.mapName}</strong>
|
||||
<span className="series-status">
|
||||
{isLive ? <span className="live-badge">Live</span> : STATUS[s.status]}
|
||||
</span>
|
||||
{hasScore && (
|
||||
<span className="series-score" aria-label={`${teams.A.name} ${s.score.A} zu ${s.score.B} ${teams.B.name}`}>
|
||||
<span className={`team-a ${s.winner === 'A' ? 'is-winner' : ''}`}>{s.score.A}</span>
|
||||
<span className="colon">:</span>
|
||||
<span className={`team-b ${s.winner === 'B' ? 'is-winner' : ''}`}>{s.score.B}</span>
|
||||
{s.status === 'done' && (
|
||||
<small>{s.winner ? `${teams[s.winner].name} gewinnt` : 'Unentschieden'}</small>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="series-origin">
|
||||
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
|
||||
</span>
|
||||
@@ -29,7 +45,11 @@ export default function SeriesList({ state }) {
|
||||
</>
|
||||
) : 'Seiten im Spiel festlegen'}
|
||||
</span>
|
||||
<span className="series-status">{STATUS[s.status]}</span>
|
||||
{hasScore && (
|
||||
<div className="series-rounds">
|
||||
<RoundStrip rounds={s.live.rounds} maxRounds={state.maxRounds} teams={teams} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,32 @@ const RETRY = {
|
||||
};
|
||||
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
||||
|
||||
/** Zeigt dem Organisator, ob Live-Daten vom Server ankommen und ob die Map vorbei ist. */
|
||||
function LiveDataStatus({ state, hasNext }) {
|
||||
const current = state.series.find((s) => s.number === state.currentMapNumber);
|
||||
if (!current) return null;
|
||||
const updatedAt = current.live?.updatedAt;
|
||||
if (current.status === 'done') {
|
||||
const auto = state.autoAdvanceSeconds >= 0;
|
||||
return (
|
||||
<div className="alert alert-ok">
|
||||
Map {current.number} ist beendet.{' '}
|
||||
{auto
|
||||
? (hasNext ? `Die nächste Map wird nach ${state.autoAdvanceSeconds} s automatisch geladen, sofern die Serie nicht entschieden ist.` : 'Das Match wird automatisch abgeschlossen.')
|
||||
: (hasNext ? 'Lade jetzt die nächste Map.' : 'Das war die letzte Map – schließ das Match ab.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="small live-data">
|
||||
<span className={`status-dot ${updatedAt ? 'tone-ok' : 'tone-muted'}`} />
|
||||
{updatedAt
|
||||
? `Live-Daten vom Server empfangen, zuletzt um ${new Date(updatedAt).toLocaleTimeString('de-DE')}.`
|
||||
: 'Noch keine Live-Daten. Sie kommen, sobald die Aufwärmphase endet und der Server diese Webseite erreicht (LOG_RECEIVER_URL).'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerPanel({ state, isOrg, act, busy }) {
|
||||
const { server } = state;
|
||||
const label = serverLabel(server);
|
||||
@@ -63,6 +89,8 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
|
||||
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
|
||||
)}
|
||||
|
||||
{isOrg && server?.status === 'configured' && <LiveDataStatus state={state} hasNext={hasNext} />}
|
||||
|
||||
{isOrg && server && !closed && (
|
||||
<div className="org-controls">
|
||||
{canRetry && (
|
||||
|
||||
@@ -106,7 +106,13 @@ export default function MatchPage() {
|
||||
<div className="versus">
|
||||
<h1>
|
||||
<span className="team-a">{state.teams.A.name}</span>
|
||||
{state.series.length > 1 ? (
|
||||
<span className="series-total" title="Serienstand (gewonnene Maps)">
|
||||
{state.seriesScore.A}<span className="colon">:</span>{state.seriesScore.B}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vs">vs.</span>
|
||||
)}
|
||||
<span className="team-b">{state.teams.B.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,19 @@ function MatchRow({ match: m }) {
|
||||
<Link to={`/m/${m.id}`} className="match-row">
|
||||
<div className="match-row-teams">
|
||||
<span className="team-a">{m.teamA}</span>
|
||||
{m.seriesScore && m.format !== 'bo1' ? (
|
||||
<span className="row-score" title="Serienstand">{m.seriesScore.A}:{m.seriesScore.B}</span>
|
||||
) : (
|
||||
<span className="vs">vs.</span>
|
||||
)}
|
||||
<span className="team-b">{m.teamB}</span>
|
||||
</div>
|
||||
<div className="match-row-meta">
|
||||
{m.currentMap && (
|
||||
<span className={m.currentMap.finished ? '' : 'row-live'}>
|
||||
{m.currentMap.finished ? '' : <span className="live-badge">Live</span>} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B}
|
||||
</span>
|
||||
)}
|
||||
<span>{m.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[m.format]}</span>
|
||||
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import { useMatch } from '../lib/useMatch.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SideIcon from '../components/SideIcon.jsx';
|
||||
import RoundStrip from '../components/RoundStrip.jsx';
|
||||
import '../stream.css';
|
||||
|
||||
const STAGE_W = 1920;
|
||||
const STAGE_H = 1080;
|
||||
|
||||
/**
|
||||
* Vollbild-Übersicht für den Stream: /stream/<Zuschauercode>
|
||||
* Optional ?bg=transparent für eine transparente OBS-Browserquelle.
|
||||
*/
|
||||
export default function StreamPage() {
|
||||
const { code } = useParams();
|
||||
const [matchId, setMatchId] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent';
|
||||
|
||||
useEffect(() => {
|
||||
api('/access', { method: 'POST', body: { code } })
|
||||
.then((info) => {
|
||||
if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.');
|
||||
setMatchId(info.matchId);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('stream-transparent', transparent);
|
||||
document.documentElement.classList.add('stream-mode');
|
||||
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||
}, [transparent]);
|
||||
|
||||
return (
|
||||
<ScaledStage>
|
||||
{error ? <StreamMessage title="Stream-Ansicht nicht verfügbar" text={error} />
|
||||
: matchId ? <StreamContent matchId={matchId} code={code} />
|
||||
: <StreamMessage title="Verbinde …" />}
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
|
||||
function ScaledStage({ children }) {
|
||||
const ref = useRef(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
useLayoutEffect(() => {
|
||||
const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H));
|
||||
fit();
|
||||
window.addEventListener('resize', fit);
|
||||
return () => window.removeEventListener('resize', fit);
|
||||
}, []);
|
||||
return (
|
||||
<div className="stream-viewport">
|
||||
<div ref={ref} className="stream-stage" style={{ width: STAGE_W, height: STAGE_H, transform: `translate(-50%, -50%) scale(${scale})` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamMessage({ title, text }) {
|
||||
return (
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<h1>{title}</h1>
|
||||
{text && <p>{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
function StreamContent({ matchId, code }) {
|
||||
const { state, connected } = useMatch(matchId, code);
|
||||
const now = useClock();
|
||||
if (!state) return <StreamMessage title="Lade Match …" />;
|
||||
|
||||
const { teams } = state;
|
||||
const current = state.series.find((s) => s.number === state.currentMapNumber);
|
||||
const playing = current?.live?.status === 'live' && current.status !== 'done';
|
||||
const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match';
|
||||
const showSeries = state.series.length > 1;
|
||||
// Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen
|
||||
const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type));
|
||||
|
||||
return (
|
||||
<div className={`st-root phase-${phase} ${phase !== 'lobby' && state.series.length > 0 ? 'has-maps' : 'no-maps'}`}>
|
||||
<header className="st-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<div className="st-meta">
|
||||
<span>Counter-Strike 2</span>
|
||||
<span>{state.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[state.format]}</span>
|
||||
</div>
|
||||
<div className="st-clock">
|
||||
{playing && <span className="st-live">Live</span>}
|
||||
{!connected && connected !== null && <span className="st-offline">Verbindung …</span>}
|
||||
<time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="st-hero">
|
||||
<div className="st-team st-team-a">
|
||||
<h1>{teams.A.name}</h1>
|
||||
</div>
|
||||
<div className="st-center">
|
||||
<span className="st-slash" aria-hidden="true" />
|
||||
{showSeries && phase === 'match' ? (
|
||||
<div className="st-series" aria-label="Serienstand">
|
||||
<span className="a">{state.seriesScore.A}</span>
|
||||
<span className="sep">–</span>
|
||||
<span className="b">{state.seriesScore.B}</span>
|
||||
<small>Maps</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-vs">vs</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="st-team st-team-b">
|
||||
<h1>{teams.B.name}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="st-main">
|
||||
{phase === 'lobby' && <LobbyScene state={state} />}
|
||||
{phase === 'veto' && <VetoScene state={state} />}
|
||||
{phase === 'match' && <MatchScene state={state} current={current} />}
|
||||
</section>
|
||||
|
||||
{phase !== 'lobby' && state.series.length > 0 && (
|
||||
<section className="st-maps">
|
||||
{state.series.map((s) => <SeriesCard key={s.number} s={s} state={state} />)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="st-ticker">
|
||||
<span className="st-ticker-label">{phaseLabel(state)}</span>
|
||||
<span className="st-ticker-text" key={tickerEvent?.at}>{tickerEvent?.message ?? 'Warten auf die Teams'}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseLabel(state) {
|
||||
if (state.status === 'lobby') return 'Lobby';
|
||||
if (state.status === 'veto') return 'Map-Veto';
|
||||
if (state.status === 'finished') return 'Endstand';
|
||||
if (state.status === 'cancelled') return 'Abgebrochen';
|
||||
return 'Match';
|
||||
}
|
||||
|
||||
function LobbyScene({ state }) {
|
||||
return (
|
||||
<div className="st-lobby">
|
||||
{['A', 'B'].map((key) => {
|
||||
const team = state.teams[key];
|
||||
return (
|
||||
<div key={key} className={`st-roster st-roster-${key}`}>
|
||||
<h2>{team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}</h2>
|
||||
<ul>
|
||||
{team.players.map((p) => (
|
||||
<li key={p.id} className={p.ready ? 'is-ready' : ''}>
|
||||
<span>{p.nickname}</span>
|
||||
{p.isCaptain && <em>Captain</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="st-lobby-note">Das Map-Veto beginnt gleich</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VetoScene({ state }) {
|
||||
const { veto, teams } = state;
|
||||
const current = veto?.current;
|
||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||
const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type];
|
||||
return (
|
||||
<div className="st-veto">
|
||||
{current && (
|
||||
<div className={`st-turn st-turn-${current.team}`}>
|
||||
<strong>{teams[current.team].name}</strong> {action}
|
||||
{current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-veto-grid" style={{ '--count': veto.maps.length }}>
|
||||
{veto.maps.map((m) => (
|
||||
<div key={m.id} className={`st-vmap status-${m.status} ${m.by ? `by-${m.by}` : ''} ${current?.map === m.id ? 'is-side' : ''}`}>
|
||||
<MapImage map={mapsById[m.id]} />
|
||||
<span className="st-vmap-name">{mapsById[m.id]?.name}</span>
|
||||
<span className="st-vmap-status">
|
||||
{m.status === 'banned' && `Bann ${teams[m.by].name}`}
|
||||
{m.status === 'picked' && `Pick ${teams[m.by].name}`}
|
||||
{m.status === 'decider' && 'Decider'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchScene({ state, current }) {
|
||||
const { teams } = state;
|
||||
if (!current) return <div className="st-waiting">Server wird vorbereitet</div>;
|
||||
const map = state.maps.find((m) => m.id === current.map);
|
||||
const live = current.live;
|
||||
const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null);
|
||||
const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null);
|
||||
const finished = current.status === 'done';
|
||||
const waiting = !live || live.status === 'warmup';
|
||||
|
||||
return (
|
||||
<div className="st-board" style={{ '--map': map?.color ?? '#556' }}>
|
||||
<MapImage map={map} className="st-board-art" />
|
||||
<div className="st-board-head">
|
||||
<span>Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''}</span>
|
||||
<strong>{current.mapName}</strong>
|
||||
<span className="st-board-state">{finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`}</span>
|
||||
</div>
|
||||
<div className="st-score">
|
||||
<div className={`st-score-team a ${current.winner === 'A' ? 'won' : ''}`}>
|
||||
{sideOf('A') && <SideIcon side={sideOf('A')} size={64} />}
|
||||
<span className="num">{current.score.A}</span>
|
||||
</div>
|
||||
<span className="st-score-colon">:</span>
|
||||
<div className={`st-score-team b ${current.winner === 'B' ? 'won' : ''}`}>
|
||||
<span className="num">{current.score.B}</span>
|
||||
{sideOf('B') && <SideIcon side={sideOf('B')} size={64} />}
|
||||
</div>
|
||||
</div>
|
||||
{finished && current.winner && <div className="st-winner">{teams[current.winner].name} gewinnt {current.mapName}</div>}
|
||||
<div className="st-rounds">
|
||||
<RoundStrip rounds={live?.rounds ?? []} maxRounds={state.maxRounds} teams={teams} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesCard({ s, state }) {
|
||||
const map = state.maps.find((m) => m.id === s.map);
|
||||
const isCurrent = s.number === state.currentMapNumber && state.status === 'live';
|
||||
const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done';
|
||||
return (
|
||||
<div className={`st-mapcard is-${s.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||
<MapImage map={map} />
|
||||
<span className="st-mapcard-no">Map {s.number}</span>
|
||||
<strong>{s.mapName}</strong>
|
||||
<span className="st-mapcard-foot">
|
||||
{s.status === 'done' || playing ? (
|
||||
<span className="st-mapcard-score">
|
||||
<b className={s.winner === 'A' ? 'won a' : 'a'}>{s.score.A}</b>
|
||||
<i>:</i>
|
||||
<b className={s.winner === 'B' ? 'won b' : 'b'}>{s.score.B}</b>
|
||||
</span>
|
||||
) : (
|
||||
<span className="st-mapcard-origin">{s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* ---------- Stream-Übersicht (1920×1080, skaliert) ---------- */
|
||||
html.stream-mode, html.stream-mode body { background: #000; overflow: hidden; height: 100%; }
|
||||
html.stream-mode.stream-transparent, html.stream-mode.stream-transparent body { background: transparent; }
|
||||
|
||||
.stream-viewport { position: fixed; inset: 0; overflow: hidden; }
|
||||
.stream-stage { position: absolute; left: 50%; top: 50%; transform-origin: center center; }
|
||||
|
||||
.st-root {
|
||||
position: relative; width: 100%; height: 100%; overflow: hidden;
|
||||
display: grid; grid-template-rows: 104px 260px 1fr 190px 60px;
|
||||
padding: 0 72px; color: var(--text); font-family: var(--body);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 30%, rgba(52, 74, 153, .28), transparent 70%),
|
||||
radial-gradient(900px 600px at 100% 30%, rgba(167, 42, 28, .26), transparent 70%),
|
||||
#000;
|
||||
}
|
||||
.stream-transparent .st-root { background: transparent; }
|
||||
/* Ohne Map-Leiste (Lobby, Veto) bekommt die Hauptfläche den Platz */
|
||||
.st-root.no-maps { grid-template-rows: 104px 260px 1fr 60px; }
|
||||
.phase-veto .st-veto-grid { align-items: stretch; }
|
||||
.phase-veto .st-vmap { min-height: 420px; }
|
||||
|
||||
/* Kopfzeile */
|
||||
.st-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 3px solid var(--brand-red); }
|
||||
.st-logo { height: 58px; width: auto; }
|
||||
.st-meta { display: flex; gap: 28px; font: 600 26px var(--display); color: var(--muted); }
|
||||
.st-meta span + span { padding-left: 28px; border-left: 2px solid var(--line-strong); }
|
||||
.st-clock { justify-self: end; display: flex; align-items: center; gap: 22px; font: 600 30px var(--display); font-variant-numeric: tabular-nums; }
|
||||
.st-live {
|
||||
display: inline-flex; align-items: center; gap: 10px; padding: 4px 16px 4px 12px; border-radius: 4px;
|
||||
background: var(--brand-red); color: #fff; font: 700 24px var(--display);
|
||||
}
|
||||
.st-live::before { content: ''; width: 12px; height: 12px; border-radius: 50%; background: #fff; animation: st-blink 1.4s ease-in-out infinite; }
|
||||
.st-offline { color: var(--pending); font-size: 22px; }
|
||||
@keyframes st-blink { 50% { opacity: .25; } }
|
||||
|
||||
/* Teams und Serienstand */
|
||||
.st-hero { display: grid; grid-template-columns: 1fr 380px 1fr; align-items: center; }
|
||||
.st-team h1 {
|
||||
margin: 0; font: 700 108px/0.95 var(--display); letter-spacing: -.01em;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;
|
||||
}
|
||||
.st-team-a { text-align: right; }
|
||||
.st-team-a h1 { color: var(--team-a); }
|
||||
.st-team-b h1 { color: var(--team-b); }
|
||||
.st-center { position: relative; display: grid; place-items: center; height: 100%; }
|
||||
/* Das rote X aus dem Logo als diagonaler Schnitt */
|
||||
.st-slash {
|
||||
position: absolute; width: 34px; height: 230px; background: var(--brand-red); transform: rotate(18deg);
|
||||
border-radius: 6px; box-shadow: 0 0 60px rgba(167, 42, 28, .55); opacity: .9;
|
||||
}
|
||||
.st-series { position: relative; display: grid; grid-template-columns: 1fr 60px 1fr; align-items: center; gap: 0 34px; text-shadow: 0 4px 24px #000; }
|
||||
.st-series span { font: 700 150px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||
.st-series .a { text-align: right; color: var(--team-a); }
|
||||
.st-series .b { color: var(--team-b); }
|
||||
/* Der rote Schrägstrich übernimmt die Trennung zwischen den Ziffern */
|
||||
.st-series .sep { visibility: hidden; }
|
||||
.st-series small { grid-column: 1 / -1; text-align: center; font: 600 24px var(--display); color: var(--muted); margin-top: -6px; }
|
||||
.st-vs { position: relative; font: 700 84px var(--display); color: #fff; text-shadow: 0 4px 30px #000; }
|
||||
|
||||
.st-main { min-height: 0; padding: 18px 0 24px; }
|
||||
|
||||
/* Lobby */
|
||||
.st-lobby { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr auto; gap: 24px 80px; height: 100%; }
|
||||
.st-roster { padding: 30px 40px; background: rgba(17, 20, 30, .82); border-top: 6px solid var(--team-a); border-radius: 8px; }
|
||||
.st-roster-B { border-top-color: var(--team-b); }
|
||||
.st-roster h2 { font: 600 36px var(--display); color: var(--muted); margin-bottom: 18px; }
|
||||
.st-roster ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||
.st-roster li { display: flex; align-items: baseline; gap: 16px; font: 600 44px var(--display); color: var(--muted); }
|
||||
.st-roster li.is-ready { color: var(--text); }
|
||||
.st-roster li.is-ready::after { content: 'bereit'; font: 500 24px var(--body); color: var(--ok); }
|
||||
.st-roster em { font: 500 22px var(--body); font-style: normal; color: var(--pending); }
|
||||
.st-lobby-note { grid-column: 1 / -1; text-align: center; font: 600 34px var(--display); color: var(--muted); }
|
||||
|
||||
/* Veto */
|
||||
.st-veto { display: grid; grid-template-rows: auto 1fr; gap: 26px; height: 100%; }
|
||||
.st-turn {
|
||||
justify-self: center; padding: 14px 40px; font: 600 40px var(--display); border-radius: 6px;
|
||||
background: rgba(17, 20, 30, .85); border-left: 8px solid var(--team-a);
|
||||
}
|
||||
.st-turn-B { border-left-color: var(--team-b); }
|
||||
.st-turn strong { color: var(--team-a); }
|
||||
.st-turn-B strong { color: var(--team-b); }
|
||||
.st-veto-grid { display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); gap: 18px; align-items: stretch; }
|
||||
.st-vmap { --by: var(--line-strong); position: relative; overflow: hidden; border-radius: 6px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; min-height: 0; }
|
||||
.st-vmap.by-A { --by: var(--team-a); }
|
||||
.st-vmap.by-B { --by: var(--team-b); }
|
||||
.st-vmap .map-art::after { background: linear-gradient(180deg, transparent 35%, rgba(0, 0, 0, .9)); }
|
||||
.st-vmap-name, .st-vmap-status { position: relative; z-index: 2; }
|
||||
.st-vmap-name { font: 700 46px/1 var(--display); text-shadow: 0 2px 12px #000; }
|
||||
.st-vmap-status { font: 600 24px var(--display); color: var(--by); margin-top: 6px; min-height: 30px; }
|
||||
.st-vmap.status-banned { filter: grayscale(.95) brightness(.5); }
|
||||
.st-vmap.status-banned::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 3;
|
||||
background: linear-gradient(to top right, transparent calc(50% - 5px), var(--by) calc(50% - 5px), var(--by) calc(50% + 5px), transparent calc(50% + 5px));
|
||||
}
|
||||
.st-vmap.status-picked::before, .st-vmap.status-decider::before, .st-vmap.is-side::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: 3; border-radius: 6px; box-shadow: inset 0 0 0 6px var(--by);
|
||||
}
|
||||
.st-vmap.status-decider { --by: #fff; }
|
||||
.st-vmap.is-side::before { animation: st-side 1.6s ease-in-out infinite; }
|
||||
@keyframes st-side { 50% { box-shadow: inset 0 0 0 6px var(--pending); } }
|
||||
|
||||
/* Spielstand der aktuellen Map */
|
||||
.st-board {
|
||||
position: relative; overflow: hidden; height: 100%; border-radius: 10px;
|
||||
display: grid; grid-template-rows: auto 1fr auto auto; justify-items: center; padding: 26px 48px 30px;
|
||||
background: rgba(17, 20, 30, .7); box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .06);
|
||||
}
|
||||
.st-board-art { opacity: .38; }
|
||||
.st-board-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .35), rgba(0, 0, 0, .85)); }
|
||||
.st-board > :not(.st-board-art) { position: relative; z-index: 1; }
|
||||
.st-board-head { display: flex; align-items: baseline; gap: 26px; font: 600 30px var(--display); color: var(--muted); }
|
||||
.st-board-head strong { font: 700 56px var(--display); color: var(--text); }
|
||||
.st-board-state { color: var(--pending); }
|
||||
.st-score { display: flex; align-items: center; gap: 40px; }
|
||||
.st-score-team { display: flex; align-items: center; gap: 28px; }
|
||||
.st-score .num { font: 700 230px/0.9 var(--display); font-variant-numeric: tabular-nums; min-width: 2ch; }
|
||||
.st-score-team.a .num { color: var(--team-a); text-align: right; }
|
||||
.st-score-team.b .num { color: var(--team-b); }
|
||||
.st-score-team.won .num { text-shadow: 0 0 40px currentColor; }
|
||||
.st-score-colon { font: 700 120px var(--display); color: var(--muted); }
|
||||
.st-score .side-icon { filter: drop-shadow(0 4px 16px #000); }
|
||||
.st-winner { font: 700 40px var(--display); color: var(--ok); margin-bottom: 8px; }
|
||||
.st-rounds { width: 100%; display: flex; justify-content: center; }
|
||||
.st-waiting { display: grid; place-items: center; height: 100%; font: 600 48px var(--display); color: var(--muted); }
|
||||
|
||||
/* Map-Leiste */
|
||||
.st-maps { display: flex; gap: 18px; padding-bottom: 16px; }
|
||||
.st-mapcard {
|
||||
position: relative; flex: 1 1 0; min-width: 0; overflow: hidden; border-radius: 8px;
|
||||
display: grid; align-content: end; padding: 14px 20px; border-bottom: 6px solid var(--map);
|
||||
}
|
||||
.st-mapcard .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .1), rgba(0, 0, 0, .9)); }
|
||||
.st-mapcard > :not(.map-art) { position: relative; z-index: 1; }
|
||||
.st-mapcard-no { font: 600 22px var(--display); color: var(--muted); }
|
||||
.st-mapcard strong { font: 700 40px/1.05 var(--display); }
|
||||
.st-mapcard-foot { font: 600 26px var(--display); color: var(--muted); min-height: 36px; }
|
||||
.st-mapcard-score { display: inline-flex; gap: 10px; align-items: baseline; font-size: 38px; }
|
||||
.st-mapcard-score b { font-weight: 700; color: var(--muted); }
|
||||
.st-mapcard-score b.won.a { color: var(--team-a); }
|
||||
.st-mapcard-score b.won.b { color: var(--team-b); }
|
||||
.st-mapcard.is-playing .st-mapcard-score b.a { color: var(--team-a); }
|
||||
.st-mapcard.is-playing .st-mapcard-score b.b { color: var(--team-b); }
|
||||
.st-mapcard.is-pending, .st-mapcard.is-next { opacity: .65; }
|
||||
.st-mapcard.is-playing { box-shadow: 0 0 0 4px var(--brand-red), 0 0 40px rgba(167, 42, 28, .5); }
|
||||
|
||||
/* Laufband */
|
||||
.st-ticker { display: flex; align-items: stretch; margin: 0 -72px; background: #05060a; border-top: 2px solid var(--line); }
|
||||
.st-ticker-label { display: flex; align-items: center; padding: 0 30px 0 72px; background: var(--brand-red); color: #fff; font: 700 26px var(--display); }
|
||||
.st-ticker-text { display: flex; align-items: center; padding: 0 30px; font: 500 26px var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: st-in .5s ease-out; }
|
||||
@keyframes st-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
.st-message { display: grid; place-items: center; align-content: center; gap: 24px; height: 100%; text-align: center; background: #000; }
|
||||
.st-message img { height: 90px; }
|
||||
.st-message h1 { font: 700 64px var(--display); margin: 0; }
|
||||
.st-message p { font-size: 28px; color: var(--muted); }
|
||||
|
||||
/* Große Rundenleiste */
|
||||
.round-strip-lg { gap: 6px; }
|
||||
.round-strip-lg .round { width: 50px; height: 50px; border-radius: 5px; }
|
||||
.round-strip-lg .round.break { margin-left: 22px; }
|
||||
.round-strip-lg .reason-icon { width: 24px; height: 24px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.st-live::before, .st-vmap.is-side::before, .st-ticker-text { animation: none; }
|
||||
}
|
||||
@@ -443,3 +443,43 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- Live-Ergebnisse ---------- */
|
||||
.live-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px 1px 6px; border-radius: 3px;
|
||||
background: var(--brand-red); color: #fff; font: 700 .8125rem var(--display); vertical-align: middle;
|
||||
}
|
||||
.live-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: pulse 1.4s ease-in-out infinite; }
|
||||
.series-total { font-variant-numeric: tabular-nums; color: var(--text); }
|
||||
.series-total .colon, .series-score .colon { color: var(--muted); margin: 0 .12em; }
|
||||
.row-score { font: 700 1.375rem var(--display); color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.row-live { color: var(--text); }
|
||||
|
||||
.series-score {
|
||||
grid-column: 3; grid-row: 2 / 4; align-self: center; justify-self: end; text-align: right;
|
||||
font: 700 2.25rem/1 var(--display); font-variant-numeric: tabular-nums; text-shadow: 0 2px 10px rgba(0, 0, 0, .8);
|
||||
}
|
||||
.series-score small { display: block; font: 500 .8125rem var(--body); color: var(--muted); margin-top: 4px; }
|
||||
.series-score .is-winner { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; }
|
||||
.series-rounds { grid-column: 1 / -1; margin-top: 10px; overflow-x: auto; }
|
||||
.series-item.is-playing { box-shadow: inset 0 0 0 2px var(--brand-red); }
|
||||
|
||||
.round-strip { list-style: none; display: flex; gap: 3px; padding: 0; margin: 0; }
|
||||
.round {
|
||||
display: grid; place-items: center; flex: none; width: 22px; height: 22px; border-radius: 3px;
|
||||
background: var(--surface-2); color: #fff; box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.round.won-A { background: var(--team-a); box-shadow: none; }
|
||||
.round.won-B { background: var(--team-b); box-shadow: none; }
|
||||
.round.break { margin-left: 10px; }
|
||||
.round-strip-sm .round { width: 18px; height: 18px; }
|
||||
.round-strip-sm .reason-icon { width: 10px; height: 10px; }
|
||||
.round-strip-sm .round.break { margin-left: 8px; }
|
||||
|
||||
.alert-ok { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 9%, var(--surface)); }
|
||||
.live-data { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
||||
.stream-link { margin-top: 10px; display: grid; gap: 8px; justify-items: start; }
|
||||
.stream-link .copy-field { width: 100%; }
|
||||
@media (max-width: 640px) {
|
||||
.series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; }
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function loadConfig(overrides = {}) {
|
||||
publicUrl: str('PUBLIC_URL', 'http://localhost:5173').replace(/\/$/, ''),
|
||||
// Adresse, unter der der CS2-Server dieses Backend für Live-Logs erreicht (Standard: PUBLIC_URL)
|
||||
logReceiverUrl: str('LOG_RECEIVER_URL', str('PUBLIC_URL', 'http://localhost:3000')).replace(/\/$/, ''),
|
||||
logDebug: bool('LOG_DEBUG', false),
|
||||
// Sekunden nach Map-Ende bis zur nächsten Map bzw. zum Abschluss der Serie; -1 = nur manuell
|
||||
autoAdvanceSeconds: int('SERIES_AUTO_ADVANCE_SECONDS', 30),
|
||||
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'),
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||
// Der Schlüssel in der URL ist pro Server und Match zufällig und nur im Backend bekannt.
|
||||
router.post('/logs/:id/:key', express.text({ type: () => true, limit: '2mb' }), (req, res) => {
|
||||
try {
|
||||
service.ingestLogs(req.params.id, req.params.key, typeof req.body === 'string' ? req.body : '');
|
||||
const body = typeof req.body === 'string' ? req.body : '';
|
||||
// LOG_DEBUG=true: empfangene Zeilen ausgeben, z. B. um das Logformat eines Servers zu prüfen
|
||||
if (config.logDebug) console.log(`[logs ${req.params.id}] ${body.trim().replace(/\n/g, `\n[logs ${req.params.id}] `)}`);
|
||||
service.ingestLogs(req.params.id, req.params.key, body);
|
||||
res.status(200).end();
|
||||
} catch (err) {
|
||||
res.status(err.status ?? 500).end();
|
||||
|
||||
+57
-17
@@ -5,13 +5,18 @@
|
||||
// World triggered "Match_Start" on "de_mirage"
|
||||
// MatchStatus: Team playing "CT": MaxLAN Blau
|
||||
// Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "3") (T "5")
|
||||
// World triggered "Warmup_Start"
|
||||
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
||||
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
||||
|
||||
const RE = {
|
||||
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
||||
warmupStart: /World triggered "Warmup_Start"/,
|
||||
worldRoundEnd: /World triggered "Round_End"/,
|
||||
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
||||
gameOver: /Game Over: \S+ \S+ (\S+) score (\d+):(\d+)/,
|
||||
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
||||
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
|
||||
};
|
||||
|
||||
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||
@@ -28,18 +33,23 @@ const REASONS = {
|
||||
const other = (team) => (team === 'A' ? 'B' : 'A');
|
||||
const normalizeName = (name) => String(name ?? '').trim().toLowerCase();
|
||||
|
||||
/** Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet. */
|
||||
export function initialLiveState({ map, ctTeam }) {
|
||||
/**
|
||||
* Neuer Live-Zustand einer Map. ctTeam = Team, das laut Veto auf CT startet.
|
||||
* warmupEnded: erst danach zählt ein Match_Start (CS2 sendet ihn auch beim Laden der Map, vor der Aufwärmphase).
|
||||
*/
|
||||
export function initialLiveState({ map, ctTeam, warmupEnded = false }) {
|
||||
const startCt = ctTeam ?? 'A';
|
||||
return {
|
||||
map,
|
||||
status: 'warmup',
|
||||
warmupEnded,
|
||||
startCt,
|
||||
sides: { CT: startCt, T: other(startCt) },
|
||||
sidesFromLog: false,
|
||||
score: { A: 0, B: 0 },
|
||||
rounds: [],
|
||||
winner: null,
|
||||
pendingRound: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,16 +65,42 @@ export function applyLogLines(state, lines, ctx) {
|
||||
const events = [];
|
||||
const names = { A: normalizeName(ctx.teamNames.A), B: normalizeName(ctx.teamNames.B) };
|
||||
|
||||
// Ein Rundenende wird erst abgeschlossen, wenn die Seitenzuordnung der gerade gespielten Runde feststeht:
|
||||
// CS2 schickt sie direkt nach dem Rundenende ("MatchStatus: Team playing ..."). Die Zeilen zu Beginn der
|
||||
// nächsten Runde sind nach der Halbzeit noch veraltet und werden deshalb ignoriert.
|
||||
const finalize = () => {
|
||||
const p = s.pendingRound;
|
||||
if (!p) return;
|
||||
s.pendingRound = null;
|
||||
if (p.number <= s.rounds.length) return; // doppelt gesendete Zeile
|
||||
const winner = s.sides[p.side];
|
||||
s.rounds.push({ number: p.number, winner, side: p.side, reason: p.reason });
|
||||
// Stand aus den Seitenwerten übernehmen – korrigiert sich bei verlorenen Zeilen selbst.
|
||||
s.score = { [s.sides.CT]: p.sideScore.CT, [s.sides.T]: p.sideScore.T };
|
||||
events.push({ type: 'round', number: p.number, winner, score: { ...s.score } });
|
||||
switchSidesByRules(s, p.number, ctx);
|
||||
};
|
||||
|
||||
const reset = (status) => {
|
||||
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let m;
|
||||
if ((m = RE.matchStart.exec(line))) {
|
||||
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||
if (m[1] !== s.map) continue;
|
||||
Object.assign(s, { status: 'live', score: { A: 0, B: 0 }, rounds: [], winner: null });
|
||||
// Nach Spielende startet CS2 von selbst neu – das Ergebnis bleibt dann stehen.
|
||||
if (m[1] !== s.map || s.status === 'finished') continue;
|
||||
reset(s.warmupEnded ? 'live' : 'warmup');
|
||||
// Seitenzuordnung aus dem Log hat Vorrang vor der Annahme aus dem Veto.
|
||||
if (!s.sidesFromLog) s.sides = { CT: s.startCt, T: other(s.startCt) };
|
||||
events.push({ type: 'match_start', map: s.map });
|
||||
if (s.warmupEnded) events.push({ type: 'match_start', map: s.map });
|
||||
} else if (RE.warmupStart.test(line)) {
|
||||
// Neue Aufwärmphase (Mapstart oder Neustart): bisherige Zählung verwerfen, Ergebnis einer beendeten Map bleibt.
|
||||
if (s.status !== 'finished') reset('warmup');
|
||||
} else if ((m = RE.teamPlaying.exec(line))) {
|
||||
// Nur vor der ersten Runde und direkt nach einem Rundenende verlässlich.
|
||||
if (!s.pendingRound && s.rounds.length > 0) continue;
|
||||
const side = m[1] === 'CT' ? 'CT' : 'T';
|
||||
const name = normalizeName(m[2]);
|
||||
const team = name === names.A ? 'A' : name === names.B ? 'B' : null;
|
||||
@@ -75,32 +111,36 @@ export function applyLogLines(state, lines, ctx) {
|
||||
}
|
||||
} else if ((m = RE.roundEnd.exec(line))) {
|
||||
if (s.status === 'finished') continue;
|
||||
finalize();
|
||||
if (s.status === 'warmup') s.status = 'live';
|
||||
const winnerSide = m[1] === 'CT' ? 'CT' : 'T';
|
||||
const sideScore = { CT: Number(m[3]), T: Number(m[4]) };
|
||||
const winner = s.sides[winnerSide];
|
||||
const number = sideScore.CT + sideScore.T;
|
||||
if (number <= s.rounds.length) continue; // doppelt gesendete Zeile
|
||||
s.rounds.push({ number, winner, side: winnerSide, reason: REASONS[m[2]] ?? 'other' });
|
||||
// Stand aus den Seitenwerten der Zeile übernehmen – korrigiert sich damit bei verlorenen Zeilen selbst.
|
||||
s.score = { [s.sides.CT]: sideScore.CT, [s.sides.T]: sideScore.T };
|
||||
events.push({ type: 'round', number, winner, score: { ...s.score } });
|
||||
if (!s.sidesFromLog) maybeSwitchSides(s, number, ctx);
|
||||
s.pendingRound = {
|
||||
number: sideScore.CT + sideScore.T,
|
||||
side: m[1] === 'CT' ? 'CT' : 'T',
|
||||
reason: REASONS[m[2]] ?? 'other',
|
||||
sideScore,
|
||||
};
|
||||
} else if (RE.worldRoundEnd.test(line)) {
|
||||
finalize();
|
||||
} else if ((m = RE.gameOver.exec(line))) {
|
||||
if (m[1] !== s.map || s.status === 'finished') continue;
|
||||
finalize();
|
||||
s.status = 'finished';
|
||||
s.winner = s.score.A === s.score.B ? null : s.score.A > s.score.B ? 'A' : 'B';
|
||||
events.push({ type: 'map_end', winner: s.winner, score: { ...s.score } });
|
||||
}
|
||||
}
|
||||
// Ende des Pakets: offenes Rundenende mit der bekannten Zuordnung abschließen, damit die Anzeige nicht hängt.
|
||||
finalize();
|
||||
return { state: s, events };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersatz, wenn der Server keine "Team playing"-Zeilen schickt: Seitenwechsel nach Regelwerk.
|
||||
* Seitenwechsel nach Regelwerk, damit die Anzeige auch während einer Runde stimmt:
|
||||
* Halbzeit nach maxRounds/2, in der Verlängerung nach jeder halben Verlängerung.
|
||||
* Die Zeilen nach dem nächsten Rundenende korrigieren die Zuordnung, falls ein Server anders wechselt.
|
||||
*/
|
||||
function maybeSwitchSides(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||
function switchSidesByRules(s, roundsPlayed, { maxRounds, overtimeRounds = 6 }) {
|
||||
const half = maxRounds / 2;
|
||||
const otHalf = overtimeRounds / 2;
|
||||
const regulationSwitch = roundsPlayed === half;
|
||||
|
||||
@@ -416,9 +416,14 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
advanceSeries(viewer) {
|
||||
this.require(viewer, 'organizer');
|
||||
return this.advanceSeriesFor(viewer.matchId);
|
||||
}
|
||||
|
||||
/** Nächste Map der Serie vormerken (Organisator oder automatisch nach Map-Ende). */
|
||||
advanceSeriesFor(matchId) {
|
||||
let nextNumber;
|
||||
this.db.transaction(() => {
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
const match = this.getMatch(matchId);
|
||||
if (match.status !== 'live') throw new HttpError(409, 'Die nächste Map kann erst geladen werden, wenn das Match läuft', 'wrong_phase');
|
||||
const maps = this.seriesMaps(match.id);
|
||||
nextNumber = match.current_map_number + 1;
|
||||
@@ -430,7 +435,7 @@ export class MatchService extends EventEmitter {
|
||||
.run(nextNumber, this.now(), match.id);
|
||||
this.log(match.id, 'series', `Map ${nextNumber} der Serie wird geladen`);
|
||||
})();
|
||||
this.changed(viewer.matchId);
|
||||
this.changed(matchId);
|
||||
return nextNumber;
|
||||
}
|
||||
|
||||
@@ -443,6 +448,18 @@ export class MatchService extends EventEmitter {
|
||||
.run(matchId, number);
|
||||
}
|
||||
|
||||
/** Organisator hat die Aufwärmphase beendet: ab dem nächsten Match_Start wird gezählt. */
|
||||
markWarmupEnded(matchId) {
|
||||
const match = this.getMatch(matchId);
|
||||
const row = this.seriesMaps(matchId).find((m) => m.number === match.current_map_number);
|
||||
if (!row) return;
|
||||
const state = row.live ? JSON.parse(row.live) : initialLiveState({ map: row.map, ctTeam: row.ct_team });
|
||||
state.warmupEnded = true;
|
||||
this.db
|
||||
.prepare('UPDATE series_maps SET live = ? WHERE match_id = ? AND number = ?')
|
||||
.run(JSON.stringify(state), matchId, row.number);
|
||||
}
|
||||
|
||||
/** Logzeilen vom CS2-Server (logaddress_add_http). Der Schlüssel in der URL identifiziert den Server. */
|
||||
ingestLogs(matchId, key, body) {
|
||||
const server = this.serverRow(matchId);
|
||||
@@ -488,6 +505,10 @@ export class MatchService extends EventEmitter {
|
||||
const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1;
|
||||
const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null;
|
||||
if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`);
|
||||
const hasNext = maps.some((m) => m.number === ended.row.number + 1);
|
||||
this.changed(matchId);
|
||||
this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext });
|
||||
return;
|
||||
}
|
||||
this.changed(matchId);
|
||||
}
|
||||
@@ -505,12 +526,16 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
close(viewer, status) {
|
||||
this.require(viewer, 'organizer');
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
this.closeMatch(viewer.matchId, status);
|
||||
}
|
||||
|
||||
closeMatch(matchId, status, message) {
|
||||
const match = this.getMatch(matchId);
|
||||
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist bereits beendet', 'match_closed');
|
||||
if (status === 'finished') {
|
||||
this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id);
|
||||
}
|
||||
this.log(match.id, 'status', status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen');
|
||||
this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen'));
|
||||
this.setStatus(match.id, status);
|
||||
this.emit('closed', match.id);
|
||||
}
|
||||
@@ -639,6 +664,7 @@ export class MatchService extends EventEmitter {
|
||||
},
|
||||
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
|
||||
requireFullTeams: this.config.requireFullTeams,
|
||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||
maps: pool.maps,
|
||||
veto: publicView
|
||||
? null
|
||||
|
||||
@@ -39,8 +39,11 @@ export class DemoMatchSimulator {
|
||||
run.score[winner] += 1;
|
||||
run.played += 1;
|
||||
const notice = REASONS[side][Math.floor(this.random() * REASONS[side].length)];
|
||||
// Reihenfolge wie bei einem echten CS2-Server: Rundenende, Seitenzuordnung der gespielten Runde, Round_End
|
||||
const lines = [
|
||||
`${stamp()}Team "${side === 'CT' ? 'CT' : 'TERRORIST'}" triggered "SFUI_Notice_${notice}" (CT "${run.score[run.sides.CT]}") (T "${run.score[run.sides.T]}")`,
|
||||
...teamLines(),
|
||||
`${stamp()}World triggered "Round_End"`,
|
||||
];
|
||||
if (run.score[winner] >= toWin) {
|
||||
lines.push(`${stamp()}Game Over: competitive mg_active ${map} score ${run.score[run.sides.CT]}:${run.score[run.sides.T]} after 1 min`);
|
||||
@@ -48,10 +51,8 @@ export class DemoMatchSimulator {
|
||||
this.stop(matchId);
|
||||
return;
|
||||
}
|
||||
if (run.played === maxRounds / 2) {
|
||||
run.sides = { CT: run.sides.T, T: run.sides.CT };
|
||||
lines.push(...teamLines());
|
||||
}
|
||||
// Halbzeit: Seiten wechseln, die nächsten Zeilen nennen die neue Zuordnung
|
||||
if (run.played === maxRounds / 2) run.sides = { CT: run.sides.T, T: run.sides.CT };
|
||||
onLines(lines);
|
||||
}, roundMs);
|
||||
this.runs.set(matchId, run);
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ServerManager {
|
||||
Object.assign(this, { db, service, adapter, config, logger });
|
||||
this.busy = new Set();
|
||||
this.simulator = new DemoMatchSimulator();
|
||||
this.autoAdvance = new Map();
|
||||
this.lastPoll = new Map();
|
||||
this.timers = [];
|
||||
}
|
||||
@@ -34,6 +35,37 @@ export class ServerManager {
|
||||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
||||
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Map-Ende: ist die Serie entschieden, wird das Match abgeschlossen, sonst die nächste Map geladen.
|
||||
* Die Wartezeit lässt Spielern und Stream Zeit für den Endstand. Lädt der Organisator vorher selbst, entfällt es.
|
||||
*/
|
||||
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||
const seconds = this.config.autoAdvanceSeconds;
|
||||
if (seconds < 0) return;
|
||||
clearTimeout(this.autoAdvance.get(matchId));
|
||||
const finish = Boolean(decided) || !hasNext;
|
||||
this.service.log(matchId, 'series', finish
|
||||
? `Serie entschieden – das Match wird in ${seconds} s automatisch abgeschlossen`
|
||||
: `Map ${number + 1} wird in ${seconds} s automatisch geladen`);
|
||||
this.service.changed(matchId);
|
||||
this.autoAdvance.set(matchId, setTimeout(() => {
|
||||
this.autoAdvance.delete(matchId);
|
||||
const match = this.service.getMatch(matchId);
|
||||
if (CLOSED_STATUSES.includes(match.status) || match.current_map_number !== number) return;
|
||||
try {
|
||||
if (finish) {
|
||||
this.service.closeMatch(matchId, 'finished', 'Match automatisch abgeschlossen – Serie entschieden');
|
||||
} else if (this.row(matchId)?.status === 'configured') {
|
||||
this.service.advanceSeriesFor(matchId);
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`[serie] Automatischer Wechsel für ${matchId} fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}, seconds * 1000));
|
||||
}
|
||||
|
||||
/** Nach einem Neustart des Backends: unterbrochene Vorgänge aufräumen und fortsetzen. */
|
||||
@@ -56,6 +88,8 @@ export class ServerManager {
|
||||
|
||||
stopLoops() {
|
||||
this.simulator.stopAll();
|
||||
for (const t of this.autoAdvance.values()) clearTimeout(t);
|
||||
this.autoAdvance.clear();
|
||||
this.timers.forEach(clearInterval);
|
||||
this.timers = [];
|
||||
}
|
||||
@@ -277,6 +311,8 @@ export class ServerManager {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const row = this.row(viewer.matchId);
|
||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit für die nächste Map', 'wrong_state');
|
||||
clearTimeout(this.autoAdvance.get(viewer.matchId));
|
||||
this.autoAdvance.delete(viewer.matchId);
|
||||
this.service.advanceSeries(viewer);
|
||||
this.configure(viewer.matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
@@ -292,6 +328,7 @@ export class ServerManager {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||||
this.service.markWarmupEnded(viewer.matchId);
|
||||
if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId);
|
||||
this.service.changed(viewer.matchId);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,45 @@ test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => {
|
||||
const { app, call, close } = await setup({ autoAdvanceSeconds: 0 });
|
||||
try {
|
||||
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
||||
await app.manager.poll();
|
||||
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
||||
|
||||
const key = app.service.serverRow(id).config_key;
|
||||
const winMapForAlpha = async (map) => {
|
||||
const body = [
|
||||
'Team playing "CT": Alpha',
|
||||
'Team playing "TERRORIST": Bravo',
|
||||
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
|
||||
'World triggered "Round_End"',
|
||||
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`,
|
||||
].join('\n');
|
||||
const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
|
||||
assert.equal(res.status, 200);
|
||||
};
|
||||
|
||||
await winMapForAlpha(app.service.seriesMaps(id)[0].map);
|
||||
// Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen
|
||||
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
||||
assert.equal(app.service.getMatch(id).status, 'live');
|
||||
|
||||
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
||||
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
||||
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
||||
assert.equal(snap.series[2].winner, null);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from '../src/liveScore.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 24, overtimeRounds: 6 };
|
||||
const ts = '10/25/2026 - 18:00:00.000 - ';
|
||||
|
||||
@@ -12,7 +17,7 @@ function round(winnerSide, ct, t, notice) {
|
||||
}
|
||||
|
||||
test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
|
||||
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A' });
|
||||
let state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
|
||||
const lines = [
|
||||
`${ts}World triggered "Match_Start" on "de_mirage"`,
|
||||
round('T', 0, 1, 'Target_Bombed'),
|
||||
@@ -28,31 +33,62 @@ test('Logzeilen ergeben Spielstand, Rundenverlauf und Gründe', () => {
|
||||
});
|
||||
|
||||
test('Doppelt gesendete Zeilen werden nur einmal gezählt', () => {
|
||||
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
|
||||
const state = initialLiveState({ map: 'de_nuke', ctTeam: 'A', warmupEnded: true });
|
||||
const line = round('CT', 1, 0);
|
||||
const { state: s } = applyLogLines(state, [line, line, line], ctx);
|
||||
assert.equal(s.rounds.length, 1);
|
||||
assert.deepEqual(s.score, { A: 1, B: 0 });
|
||||
});
|
||||
|
||||
test('Seitenwechsel über "Team playing"-Zeilen', () => {
|
||||
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'A' });
|
||||
test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Rundenbeginn zählen nicht', () => {
|
||||
let s = initialLiveState({ map: 'de_nuke', ctTeam: 'B', warmupEnded: true });
|
||||
const playing = (ct, t) => [`${ts}MatchStatus: Team playing "CT": ${ct}`, `${ts}MatchStatus: Team playing "TERRORIST": ${t}`];
|
||||
({ state: s } = applyLogLines(s, [
|
||||
`${ts}MatchStatus: Team playing "CT": MaxLAN Blau`,
|
||||
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Rot`,
|
||||
// Veto sagt Rot auf CT, tatsächlich steht Blau auf CT – das Log gewinnt
|
||||
`${ts}World triggered "Match_Start" on "de_nuke"`,
|
||||
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||
round('CT', 1, 0),
|
||||
// Halbzeit: Rot ist jetzt CT. In den Seitenwerten steht der Stand der Teams auf der jeweiligen Seite.
|
||||
`${ts}MatchStatus: Team playing "CT": MaxLAN Rot`,
|
||||
`${ts}MatchStatus: Team playing "TERRORIST": MaxLAN Blau`,
|
||||
round('CT', 1, 1),
|
||||
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||
`${ts}World triggered "Round_End"`,
|
||||
// veraltete Zeile zu Beginn der nächsten Runde (darf nichts ändern)
|
||||
...playing('MaxLAN Rot', 'MaxLAN Blau'),
|
||||
round('T', 1, 1),
|
||||
...playing('MaxLAN Blau', 'MaxLAN Rot'),
|
||||
`${ts}World triggered "Round_End"`,
|
||||
], ctx));
|
||||
assert.deepEqual(s.score, { A: 1, B: 1 });
|
||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||
});
|
||||
|
||||
test('Echte Logzeilen eines AMP-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'amp-wingman-inferno.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||
const amp = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||
|
||||
const check = (s) => {
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.winner, 'A');
|
||||
assert.deepEqual(s.score, { A: 3, B: 1 });
|
||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['B', 'A', 'A', 'A']);
|
||||
assert.ok(s.rounds.every((r) => r.reason === 'elimination'));
|
||||
};
|
||||
|
||||
// a) wie vom Server in Paketen geschickt
|
||||
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
|
||||
({ state: s } = applyLogLines(s, lines.slice(0, warmupEnd), amp));
|
||||
assert.equal(s.status, 'warmup');
|
||||
s.warmupEnded = true; // Organisator hat "Aufwärmphase beenden" geklickt
|
||||
({ state: s } = applyLogLines(s, lines.slice(warmupEnd), amp));
|
||||
check(s);
|
||||
|
||||
// b) jede Zeile einzeln (ungünstigste Paketgrenzen)
|
||||
let t = initialLiveState({ map: 'de_inferno', ctTeam: 'A', warmupEnded: true });
|
||||
for (const line of lines.slice(warmupEnd)) ({ state: t } = applyLogLines(t, [line], amp));
|
||||
check(t);
|
||||
});
|
||||
|
||||
test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
|
||||
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B' });
|
||||
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'B', warmupEnded: true });
|
||||
const small = { ...ctx, maxRounds: 4 };
|
||||
({ state: s } = applyLogLines(s, [
|
||||
`${ts}World triggered "Match_Start" on "de_inferno"`,
|
||||
@@ -65,7 +101,7 @@ test('Ohne "Team playing": Halbzeitwechsel nach Regelwerk', () => {
|
||||
});
|
||||
|
||||
test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht', () => {
|
||||
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
|
||||
let s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
|
||||
({ state: s } = applyLogLines(s, [
|
||||
round('T', 0, 1), // Aufwärm-/Messerrunde
|
||||
`${ts}World triggered "Match_Start" on "de_dust2"`,
|
||||
@@ -79,9 +115,20 @@ test('Game Over beendet die Map, Aufwärmrunden vor Match_Start zählen nicht',
|
||||
});
|
||||
|
||||
test('Zeilen einer anderen Map werden ignoriert, Serienstand wird gezählt', () => {
|
||||
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A' });
|
||||
const s = initialLiveState({ map: 'de_dust2', ctTeam: 'A', warmupEnded: true });
|
||||
const { state } = applyLogLines(s, [`${ts}Game Over: competitive mg_active de_nuke score 13:2 after 30 min`], ctx);
|
||||
assert.equal(state.status, 'warmup');
|
||||
assert.deepEqual(seriesScore([{ winner: 'A' }, { winner: 'B' }, { winner: 'A' }, { winner: null }]), { A: 2, B: 1 });
|
||||
assert.deepEqual(splitLogBody('a\r\n\r\nb\n'), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('Match_Start vor dem Ende der Aufwärmphase startet keine Zählung', () => {
|
||||
let s = initialLiveState({ map: 'de_inferno', ctTeam: 'A' });
|
||||
const start = `${ts}World triggered "Match_Start" on "de_inferno"`;
|
||||
({ state: s } = applyLogLines(s, [start], ctx));
|
||||
assert.equal(s.status, 'warmup');
|
||||
s.warmupEnded = true;
|
||||
const { state, events } = applyLogLines(s, [start], ctx);
|
||||
assert.equal(state.status, 'live');
|
||||
assert.equal(events[0].type, 'match_start');
|
||||
});
|
||||
Reference in new issue
Block a user