import { useCallback, useEffect, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { api } from '../lib/api.js'; import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js'; import { clearAccess, getPlayerToken, getToken, setAccess, setPlayerToken } from '../lib/session.js'; import { useMatch } from '../lib/useMatch.js'; import TeamCard from '../components/TeamCard.jsx'; import VetoBoard from '../components/VetoBoard.jsx'; import ServerPanel from '../components/ServerPanel.jsx'; import OrganizerPanel from '../components/OrganizerPanel.jsx'; import SeriesList from '../components/SeriesList.jsx'; import EventLog from '../components/EventLog.jsx'; import MapImage from '../components/MapImage.jsx'; import SelfJoin from '../components/SelfJoin.jsx'; import ChatBox from '../components/ChatBox.jsx'; export default function MatchPage() { const { id } = useParams(); const [token, setToken] = useState(() => { // Organisator-Link für ein weiteres Gerät: /m/#org= const hash = new URLSearchParams(window.location.hash.slice(1)); const org = hash.get('org'); if (org) { setAccess(id, { token: org, kind: 'organizer' }); window.history.replaceState(null, '', window.location.pathname); } return getToken(id); }); // Spieler-Token des Organisators, wenn er im selben Browser selbst mitspielt const [playerToken, setPlayerTokenState] = useState(() => getPlayerToken(id)); const { state, error, connected } = useMatch(id, token, playerToken); const [busy, setBusy] = useState(null); const [toast, setToast] = useState(null); // Gespeicherter Zugang ist ungültig geworden (z. B. aus dem Team entfernt) const lostAccess = state && token && state.viewer.role === 'public'; useEffect(() => { if (lostAccess) { clearAccess(id); setToken(null); setToast({ tone: 'error', text: 'Dein gespeicherter Zugang ist nicht mehr gültig. Tritt mit deinem Teamcode erneut bei.' }); } }, [lostAccess, id]); // Organisator war Spieler, ist es aber nicht mehr (Team verlassen oder entfernt) // justJoined: bis der erste Zustand mit Spieler ankommt, ist ein Zustand ohne Spieler noch veraltet const [justJoined, setJustJoined] = useState(false); useEffect(() => { if (state?.viewer.playerId) setJustJoined(false); }, [state]); const lostPlayer = state && playerToken && !justJoined && state.viewer.isOrganizer && !state.viewer.playerId; useEffect(() => { if (lostPlayer) { setPlayerToken(id, null); setPlayerTokenState(null); } }, [lostPlayer, id]); const joinAsOrganizer = useCallback((newPlayerToken) => { setJustJoined(true); setPlayerToken(id, newPlayerToken); setPlayerTokenState(newPlayerToken); }, [id]); useEffect(() => { if (!toast) return undefined; const t = setTimeout(() => setToast(null), 6000); return () => clearTimeout(t); }, [toast]); /** Führt eine Aktion aus; verhindert Doppelklicks, solange sie läuft. */ const act = useCallback(async (path, body, success) => { if (busy) return; setBusy(path); try { await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token, playerToken }); if (success) setToast({ tone: 'ok', text: success }); } catch (err) { setToast({ tone: 'error', text: err.message }); } finally { setBusy(null); } }, [busy, id, token, playerToken]); if (error && !state) { return (

{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}

{error.error}

Zur Übersicht
); } if (!state) return

Verbinde mit Lobby …

; const { viewer } = state; const isOrg = viewer.isOrganizer; const server = serverLabel(state.server); const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer }; return (
{connected === false &&
Verbindung unterbrochen – stelle wieder her …
} {toast &&
{toast.text}
}

{state.teams.A.name} {state.series.length > 1 ? ( {state.seriesScore.A}:{state.seriesScore.B} ) : ( vs. )} {state.teams.B.name}

Modus
{state.modeLabel}
Serie
{FORMAT_LABEL[state.format]}
Status
{state.statusLabel}
Server
{server.text}
Du bist
{describeViewer(viewer, state.teams)}
{viewer.role === 'public' && }
{viewer.role !== 'public' && (
{state.chat && } {isOrg && }
)}
); } /** z. B. "Organisator, Captain bei Nordlicht" oder "Spieler bei Hafenkante" */ function describeViewer(viewer, teams) { const team = viewer.team ? teams[viewer.team].name : null; const asPlayer = team ? `${viewer.isCaptain ? 'Captain' : 'Spieler'} bei ${team}` : null; if (viewer.isOrganizer) return asPlayer ? `Organisator, ${asPlayer}` : 'Organisator'; return asPlayer ?? ROLE_LABEL[viewer.role]; } function Stage(ctx) { const { state, isOrg, act, busy } = ctx; if (state.viewer.role === 'public' && state.status === 'lobby') { return

{state.statusLabel}

Das Veto und der Spielstand erscheinen hier, sobald das Match startet.

; } if (state.status === 'lobby') { const { A, B } = state.teams; const missing = [A, B].filter((t) => !t.readiness.ready); return (

{missing.length === 0 ? 'Beide Teams sind bereit' : 'Warten auf beide Teams'}

{missing.length > 0 && (
    {missing.map((t) =>
  • {t.name}: {readinessGap(t, state.requireFullTeams)}
  • )}
)} {state.vetoStart === 'knife' && } {isOrg && state.vetoStart === 'knife' ? (

Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.

{['A', 'B'].map((t) => ( ))}
{!state.canStartVeto &&

Die Knöpfe werden aktiv, sobald beide Teams bereit sind.

}
) : isOrg ? ( <> {!state.canStartVeto &&

Der Knopf wird aktiv, sobald beide Teams bereit sind.

} ) : state.vetoStart === 'knife' ? (

Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.

) : (

{missing.length === 0 ? 'Der Organisator kann das Veto jetzt starten.' : 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}

)} {isOrg && !state.viewer.playerId && }
); } if (state.status === 'veto') return ; return ( <> ); } /** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */ /** * Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst. * Frei = niemand drauf (oder Spielerzahl unbekannt). */ function KnifeRound({ state }) { const [servers, setServers] = useState(null); useEffect(() => { let alive = true; const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([])); load(); const t = setInterval(load, 10_000); return () => { alive = false; clearInterval(t); }; }, []); const free = (servers ?? []).filter((k) => !k.playerCount); const matching = free.filter((k) => k.mode === state.mode); const shown = matching.length ? matching : free; return (

Seitenwahl per Messerrunde

Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest, welches Team das Veto beginnt.

{servers === null ? (

Suche freie Messer-Server …

) : shown.length === 0 ? (

Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.

) : ( <> {!matching.length &&

Kein freier Server mit eurer Teamgröße, hier die anderen freien:

}
    {shown.map((k) => (
  • {k.name} {k.modeShort && {k.modeShort}} {k.mapName} {k.connect} Verbinden
  • ))}
)}
); } function readinessGap(team, requireFull) { const r = team.readiness; const gaps = []; if (!r.enough) { const need = requireFull ? team.teamSize - r.count : 1; gaps.push(requireFull ? `noch ${need} von ${team.teamSize} Spielern fehlen (Team muss vollständig sein)` : 'mindestens ein Spieler fehlt'); } if (r.count > 0 && !r.allReady) gaps.push('noch nicht alle haben „bereit“ geklickt'); if (r.count > 0 && !r.hasCaptain) gaps.push('kein Captain'); return gaps.join(', '); } function MapPoolPreview({ maps }) { return (
{maps.map((m) => ( {m.name} ))}
); } function PublicNotice() { return (
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich. Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
); }