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 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fade769e7
commit
43dc5b837a
20 files changed
+901
-44
No files matched your search
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user