"use client"; import { useCallback, useEffect, useRef, useState, type FormEvent, } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Trophy, Users, CalendarClock, Plus, Copy } from "lucide-react"; import { api } from "@/lib/client-api"; import { useI18n } from "./I18nProvider"; import dynamic from "next/dynamic"; const MultiplayerGame = dynamic( () => import("./MultiplayerGame").then((module) => module.MultiplayerGame), { ssr: false }, ); import { roundClock } from "@/shared/round-clock"; import { serverLocalToIso, type TournamentRecord, type TournamentView, } from "@/shared/tournaments"; import type { DifficultyPreset } from "@/shared/protocol"; type Events = { tournaments: TournamentRecord[]; serverNow: number; serverTimezone: string; }; function time(value: string, zone: string) { return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: zone, }).format(new Date(value)); } export function TournamentIndex({ signedIn }: { signedIn: boolean }) { const { t } = useI18n(); const router = useRouter(); const [events, setEvents] = useState(null); const [maps, setMaps] = useState<{ id: string; name: string }[]>([]); const [presets, setPresets] = useState([]); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [creating, setCreating] = useState(false); const [clock, setClock] = useState(0); const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared"); const [roundSeconds, setRoundSeconds] = useState(450); const offset = useRef(0); const load = useCallback(async () => { try { const next = await api("tournaments"); offset.current = next.serverNow - Date.now(); setEvents(next); setClock(next.serverNow); setError(""); } catch (e) { setError((e as Error).message); } }, []); useEffect(() => { void load(); const timer = setInterval(() => { setClock(Date.now() + offset.current); }, 1000); const poll = setInterval(() => void load(), 30000); return () => { clearInterval(timer); clearInterval(poll); }; }, [load]); useEffect(() => { if (!creating) return; let active = true; Promise.all([ api<{ maps: { id: string; name: string }[] }>("catalog"), api<{ presets: DifficultyPreset[] }>("difficulty-presets"), ]) .then(([catalog, difficulties]) => { if (active) { setMaps(catalog.maps); setPresets(difficulties.presets); } }) .catch((e) => { if (active) setError(e.message); }); return () => { active = false; }; }, [creating]); async function create(event: FormEvent) { event.preventDefault(); if (busy || !events) return; const form = new FormData(event.currentTarget); setBusy(true); setError(""); try { const result = await api<{ id: string }>("tournaments", { name: form.get("name"), datasetId: form.get("dataset"), difficulty: form.get("difficulty"), playerLimit: Number(form.get("limit")), roundCount: Number(form.get("rounds")), roundTimeSeconds: Number(form.get("seconds")), timerMode: form.get("timerMode"), startsAt: serverLocalToIso( String(form.get("start")), events.serverTimezone, ), announceDiscord: form.has("discord"), }); router.push(`/tournaments/${result.id}`); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (

{t("COMMUNITY TOURNAMENTS")}

{t("One world. Up to 32 rivals.")}

{t( "Every tournament round contains five mini-rounds. Everyone plays the same locations.", )}

{signedIn ? ( ) : ( {t("Log in to create or join")} )}
{events && (

{t("Server time")}:{" "} {time( new Date(clock || events.serverNow).toISOString(), events.serverTimezone, )}{" "} · {events.serverTimezone}

)} {error && (
{t(error)}{" "}
)} {creating && signedIn && (

{t("Create tournament")}

{t( "A shared budget starts with the first mini-round and keeps running between mini-rounds. Initial loading is excluded.", )}

{t( "The organizer joins as a player. Registration closes at the scheduled start. At least two players are needed.", )}

)}
{events?.tournaments.map((event) => (
{t(event.status)}

{event.name}

{time(event.starts_at, event.server_timezone)} ·{" "} {event.server_timezone}

{t("Up to {count} players", { count: event.player_limit })} ·{" "} {t("{count} tournament rounds", { count: event.round_count })}

{t("Open tournament")} → ))}
{events && !events.tournaments.length && (

{t("The next champion could be you.")}

{t( "Create the first community tournament or check back for upcoming events.", )}

)}
); } export function TournamentLobby({ id, signedIn, }: { id: string; signedIn: boolean; }) { const { t, formatNumber } = useI18n(); const [event, setEvent] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); const [editing, setEditing] = useState(false); const acting = useRef(false), version = useRef(0); const latest = useRef(null); const clock = useRef | null>(null); const accept = useCallback((next: TournamentView, sent: number) => { clock.current = roundClock(next.serverNow, sent, performance.now()); latest.current = next; setEvent(next); setError(""); }, []); const load = useCallback(async () => { if (acting.current) return; const current = ++version.current, sent = performance.now(); try { const next = await api(`tournaments/${id}`); if (current === version.current) accept(next, sent); } catch (e) { if (current === version.current) setError((e as Error).message); } }, [id, accept]); useEffect(() => { let stopped = false; let timer: ReturnType; const poll = async () => { await load(); if ( !stopped && !["finished", "cancelled"].includes(latest.current?.status ?? "") ) timer = setTimeout(poll, document.hidden ? 5000 : 2000); }; void poll(); const resume = () => { if (!document.hidden) void load(); }; document.addEventListener("visibilitychange", resume); return () => { stopped = true; version.current++; clearTimeout(timer); document.removeEventListener("visibilitychange", resume); }; }, [load]); const action = useCallback( async (type: string, payload: object = {}) => { if (acting.current) return false; acting.current = true; setBusy(true); const current = ++version.current, sent = performance.now(); try { const next = await api( `tournaments/${id}/${type}`, payload, ); if (current === version.current) accept(next, sent); return true; } catch (e) { if (current === version.current) setError((e as Error).message); return false; } finally { acting.current = false; setBusy(false); } }, [id, accept], ); const remaining = useCallback( (deadline: string) => clock.current?.remaining(deadline, performance.now()) ?? 0, [], ); const room = event?.currentRoom; const owner = event?.you === event?.ownerId && !!event?.you; const joined = !!event?.players.some((p) => p.accountId === event.you); const gameAction = useCallback( (type: string, payload: object = {}) => action(type, { ...payload, tournamentRound: event?.currentRound ?? 0 }), [action, event?.currentRound], ); if (!event) return (
← {t("Tournaments")} {error ? (
{t(error)}{" "}
) : (

{t("Loading tournament…")}

)}
); async function edit(e: FormEvent) { e.preventDefault(); if (!event) return; const form = new FormData(e.currentTarget); try { if ( await action("edit", { startsAt: serverLocalToIso( String(form.get("start")), event.serverTimezone, ), playerLimit: Number(form.get("limit")), announceDiscord: form.has("discord"), }) ) setEditing(false); } catch (failure) { setError((failure as Error).message); } } return ( <> {event.status === "running" && room?.you && (
{event.name} ·{" "} {t("Tournament round {round} of {total}", { round: event.currentRound + 1, total: event.roundCount, })}
{ document .getElementById("tournament-standings") ?.scrollIntoView({ behavior: "instant" }); }} />
)}
← {t("Tournaments")}

{t(event.status)}

{event.name}

{time(event.startsAt, event.serverTimezone)} ·{" "} {event.serverTimezone}

{error && (
{t(error)}
)}
{event.players.length} / {event.playerLimit} {t("{count} tournament rounds", { count: event.roundCount })} × 5{" "} {t("mini-rounds")} {event.roundTimeSeconds}{" "} {t( event.timerMode === "shared" ? "seconds per tournament round" : "seconds per mini-round", )}
{owner && event.announceDiscord && (

{t("Discord announcement")}:{" "} {t(event.announcementState ?? "pending")}

)} {event.status === "scheduled" && (
{!signedIn ? ( {t("Log in to create or join")} ) : joined ? ( <> {t( "You’re registered. Stay on this page when the tournament starts.", )} {!owner && ( )} ) : ( )} {owner && ( <> )}
)} {editing && owner && event.status === "scheduled" && (

{t("Already-sent announcements are not posted again.")}

)} {event.status === "finished" && (

{t("TOURNAMENT CHAMPION")}

{event.players.find((p) => p.accountId === event.winnerId) ?.name ?? t("Explorer")}

{t( "The latest tournament champion holds first place on the global leaderboard for seven days.", )}

{t("Global leaderboard")} →
)} {event.status === "cancelled" && (

{t( "This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.", )}

)}

{t( event.status === "finished" ? "Final standings" : "Tournament standings", )}

{t( "Points count after each mini-round closes. Ties use total distance, then registration order.", )}

{event.players.map((player) => ( ))}
{t("Rank")} {t("Explorer")} {t("Total points")}
{player.rank} {player.name} {player.accountId === event.winnerId ? " 🏆" : ""} {formatNumber(player.total)}
); }