Files
MineGeo/components/TournamentLobby.tsx
pavel444-byte 0d62f08e77
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 6m29s
Fix login return routes and recovery feedback
2026-10-31 13:56:01 +05:00

787 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { RoomChat } from "./RoomChat";
import { TournamentModeration } from "./TournamentModeration";
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,
initialMapId = null,
}: {
signedIn: boolean;
initialMapId?: string | null;
}) {
const { t } = useI18n();
const router = useRouter();
const [events, setEvents] = useState<Events | null>(null);
const [maps, setMaps] = useState<{ id: string; name: string }[]>([]);
const [presets, setPresets] = useState<DifficultyPreset[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [creating, setCreating] = useState(!!(signedIn && initialMapId));
const [selectedMap, setSelectedMap] = useState(initialMapId ?? "");
const [clock, setClock] = useState(0);
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
const [roundSeconds, setRoundSeconds] = useState(450);
const [difficulty, setDifficulty] = useState("normal");
const offset = useRef(0);
const load = useCallback(async () => {
try {
const next = await api<Events>("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);
setSelectedMap((current) =>
catalog.maps.some((map) => map.id === current) ? current : "",
);
}
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [creating]);
async function create(event: FormEvent<HTMLFormElement>) {
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: difficulty === "void" ? 10 : roundSeconds,
timerMode: difficulty === "void" ? "perMini" : 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 (
<div className="section tournament-page">
<div className="section-heading">
<div>
<p className="eyebrow">
<Trophy size={16} />
{t("COMMUNITY TOURNAMENTS")}
</p>
<h1>{t("One world. Up to 32 rivals.")}</h1>
<p className="muted">
{t(
"Every tournament round contains five mini-rounds. Everyone plays the same locations.",
)}
</p>
</div>
{signedIn ? (
<button
className="button primary"
onClick={() => setCreating(!creating)}
>
<Plus size={16} />
{t(creating ? "Close" : "Create tournament")}
</button>
) : (
<Link
href={`/log-in?next=${encodeURIComponent(
initialMapId
? `/tournaments?map=${initialMapId}`
: "/tournaments",
)}`}
className="button primary"
>
{t("Log in to create or join")}
</Link>
)}
</div>
{events && (
<p className="tournament-server-clock">
<CalendarClock size={16} />
{t("Server time")}:{" "}
{time(
new Date(clock || events.serverNow).toISOString(),
events.serverTimezone,
)}{" "}
· {events.serverTimezone}
</p>
)}
{error && (
<div className="notice error" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Try again")}
</button>
</div>
)}
{creating && signedIn && (
<form className="tournament-form panel" onSubmit={create}>
<h2>{t("Create tournament")}</h2>
<label>
{t("Tournament name")}
<input
name="name"
required
minLength={3}
maxLength={80}
placeholder={t("Saturday expedition")}
/>
</label>
<label>
{t("Map")}
<select
name="dataset"
required
value={selectedMap}
onChange={(event) => setSelectedMap(event.target.value)}
>
<option value="" disabled>
{t("Choose a map")}
</option>
{maps.map((map) => (
<option key={map.id} value={map.id}>
{map.name}
</option>
))}
</select>
</label>
<label>
{t("Difficulty")}
<select
name="difficulty"
value={difficulty}
onChange={(event) => setDifficulty(event.target.value)}
>
<option value="easy">{t("Easy")}</option>
<option value="normal">{t("Normal")}</option>
<option value="hard">{t("Hard")}</option>
<option value="void">{t("Void")}</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
</label>
<label>
{t("Player limit")}
<input
name="limit"
type="number"
min={2}
max={32}
defaultValue={32}
required
/>
</label>
<label>
{t("Tournament rounds")}
<input
name="rounds"
type="number"
min={1}
max={10}
defaultValue={3}
required
/>
</label>
<label>
{t("Timer mode")}
<select
name="timerMode"
value={difficulty === "void" ? "perMini" : timerMode}
disabled={difficulty === "void"}
onChange={(e) => {
const mode = e.target.value as "shared" | "perMini";
setTimerMode(mode);
setRoundSeconds(mode === "shared" ? 450 : 90);
}}
>
<option value="shared">
{t("One budget for five mini-rounds")}
</option>
<option value="perMini">
{t("Separate timer for each mini-round")}
</option>
</select>
</label>
<label>
{t(
difficulty !== "void" && timerMode === "shared"
? "Seconds per tournament round"
: "Seconds per mini-round",
)}
<input
name="seconds"
type="number"
min={difficulty === "void" ? 10 : 15}
max={timerMode === "shared" ? 3000 : 600}
step={difficulty === "void" ? 5 : 15}
value={difficulty === "void" ? 10 : roundSeconds}
disabled={difficulty === "void"}
onChange={(e) => setRoundSeconds(Number(e.target.value))}
required
/>
</label>
<label>
{t("Start time")} · {events?.serverTimezone ?? t("Server time")}
<input
name="start"
type="datetime-local"
required
disabled={!events}
/>
</label>
<label className="tournament-checkbox">
<input name="discord" type="checkbox" />
{t("Announce in Discord 15 minutes before starting")}
</label>
<p className="muted">
{t(
"A shared budget starts with the first mini-round and keeps running between mini-rounds. Initial loading is excluded.",
)}
</p>
<p className="muted">
{t(
"The organizer joins as a player. Registration closes at the scheduled start. At least two players are needed.",
)}
</p>
<button
disabled={busy || !events || !maps.length}
className="button primary"
>
{t(busy ? "Creating…" : "Create tournament")}
</button>
</form>
)}
<div className="tournament-grid">
{events?.tournaments.map((event) => (
<Link
className="tournament-card panel"
href={`/tournaments/${event.id}`}
key={event.id}
>
<div className="tournament-card-top">
<Trophy size={24} />
<span className={`tournament-status ${event.status}`}>
{t(event.status)}
</span>
</div>
<h2>{event.name}</h2>
<p>
<CalendarClock size={15} />
{time(event.starts_at, event.server_timezone)} ·{" "}
{event.server_timezone}
</p>
<p>
<Users size={15} />
{t("Up to {count} players", { count: event.player_limit })} ·{" "}
{t("{count} tournament rounds", { count: event.round_count })}
</p>
<span className="text-link">{t("Open tournament")} →</span>
</Link>
))}
</div>
{events && !events.tournaments.length && (
<div className="empty-state">
<Trophy size={36} />
<h2>{t("The next champion could be you.")}</h2>
<p>
{t(
"Create the first community tournament or check back for upcoming events.",
)}
</p>
</div>
)}
</div>
);
}
export function TournamentLobby({
id,
signedIn,
}: {
id: string;
signedIn: boolean;
}) {
const { t, formatNumber } = useI18n();
const [event, setEvent] = useState<TournamentView | null>(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<TournamentView | null>(null);
const clock = useRef<ReturnType<typeof roundClock> | 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<TournamentView>(`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<typeof setTimeout>;
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<TournamentView>(
`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 (
<div className="section tournament-page">
<Link href="/tournaments">← {t("Tournaments")}</Link>
{error ? (
<div role="alert" className="notice error">
{t(error)}{" "}
<button onClick={() => void load()} className="button">
{t("Try again")}
</button>
</div>
) : (
<p role="status">{t("Loading tournament…")}</p>
)}
</div>
);
async function edit(e: FormEvent<HTMLFormElement>) {
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 && (
<div className="tournament-play">
<div className="tournament-game-heading">
<Trophy size={18} />
{event.name} ·{" "}
{t("Tournament round {round} of {total}", {
round: event.currentRound + 1,
total: event.roundCount,
})}
</div>
<MultiplayerGame
key={`${room.id}:${room.round}`}
id={room.id}
mediaPath={`tournaments/${id}/media?tournamentRound=${event.currentRound}`}
room={room}
busy={busy}
action={gameAction}
embedded
leaveLabel="View tournament standings"
remaining={remaining}
onLeave={() => {
document
.getElementById("tournament-standings")
?.scrollIntoView({ behavior: "instant" });
}}
/>
</div>
)}
<div className="section tournament-page" id="tournament-standings">
<Link href="/tournaments" className="text-link">
← {t("Tournaments")}
</Link>
<div className="section-heading">
<div>
<p className="eyebrow">
<Trophy size={16} />
{t(event.status)}
</p>
<h1>{event.name}</h1>
<p>
{time(event.startsAt, event.serverTimezone)} ·{" "}
{event.serverTimezone}
</p>
</div>
<button
className="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
} catch {
setError("Copy the tournament link from your address bar");
}
}}
>
<Copy size={16} />
{t(copied ? "Copied!" : "Copy invite")}
</button>
</div>
{error && (
<div className="notice error" role="alert">
{t(error)}
</div>
)}
<div className="tournament-facts">
<span>
<Users size={16} />
{event.players.length} / {event.playerLimit}
</span>
<span>
{t("{count} tournament rounds", { count: event.roundCount })} × 5{" "}
{t("mini-rounds")}
</span>
<span>
{event.roundTimeSeconds}{" "}
{t(
event.timerMode === "shared"
? "seconds per tournament round"
: "seconds per mini-round",
)}
</span>
</div>
{joined && (
<TournamentModeration
id={id}
players={event.players}
ownerId={event.ownerId}
you={event.you}
/>
)}
{owner && event.announceDiscord && (
<p role="status" className="muted">
{t("Discord announcement")}:{" "}
{t(event.announcementState ?? "pending")}
</p>
)}
{event.status === "scheduled" && (
<div className="tournament-actions">
{!signedIn ? (
<Link
className="button primary"
href={`/log-in?next=${encodeURIComponent(`/tournaments/${id}`)}`}
>
{t("Log in to create or join")}
</Link>
) : joined ? (
<>
<span className="notice">
{t(
"You’re registered. Stay on this page when the tournament starts.",
)}
</span>
{!owner && (
<button
className="button"
disabled={busy}
onClick={() => void action("leave")}
>
{t("Leave tournament")}
</button>
)}
</>
) : (
<button
className="button primary"
disabled={busy || event.players.length >= event.playerLimit}
onClick={() => void action("join")}
>
{t(
event.players.length >= event.playerLimit
? "Tournament is full"
: "Join tournament",
)}
</button>
)}
{owner && (
<>
<button
className="button"
disabled={busy}
onClick={() => setEditing(!editing)}
>
{t("Tournament settings")}
</button>
<button
className="button danger"
disabled={busy}
onClick={() => void action("cancel")}
>
{t("Cancel tournament")}
</button>
</>
)}
</div>
)}
{editing && owner && event.status === "scheduled" && (
<form className="tournament-form panel" onSubmit={edit}>
<label>
{t("Start time")} · {event.serverTimezone}
<input
name="start"
type="datetime-local"
required
defaultValue={new Intl.DateTimeFormat("sv-SE", {
timeZone: event.serverTimezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.format(new Date(event.startsAt))
.replace(" ", "T")}
/>
</label>
<label>
{t("Player limit")}
<input
name="limit"
type="number"
min={Math.max(2, event.players.length)}
max={32}
required
defaultValue={event.playerLimit}
/>
</label>
<label className="tournament-checkbox">
<input
name="discord"
type="checkbox"
defaultChecked={event.announceDiscord}
/>
{t("Announce in Discord 15 minutes before starting")}
</label>
<p className="muted">
{t("Already-sent announcements are not posted again.")}
</p>
<button className="button primary" disabled={busy}>
{t("Save settings")}
</button>
</form>
)}
{event.status === "finished" && (
<div className="tournament-winner panel">
<Trophy size={34} />
<div>
<p className="eyebrow">{t("TOURNAMENT CHAMPION")}</p>
<h2>
{event.players.find((p) => p.accountId === event.winnerId)
?.name ?? t("Explorer")}
</h2>
<p>
{t(
"The latest tournament champion holds first place on the global leaderboard for seven days.",
)}
</p>
<Link href="/leaderboard" className="text-link">
{t("Global leaderboard")} →
</Link>
</div>
</div>
)}
{event.status === "cancelled" && (
<p role="status" className="notice">
{t(
"This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.",
)}
</p>
)}
<h2>
{t(
event.status === "finished"
? "Final standings"
: "Tournament standings",
)}
</h2>
<p className="muted">
{t(
"Points count after each mini-round closes. Ties use total distance, then registration order.",
)}
</p>
<div className="leaderboard-table-wrap">
<table className="leaderboard-table">
<thead>
<tr>
<th scope="col">{t("Rank")}</th>
<th scope="col">{t("Explorer")}</th>
<th scope="col">{t("Total points")}</th>
</tr>
</thead>
<tbody>
{event.players.map((player) => (
<tr
key={player.accountId}
className={
player.accountId === event.you ? "your-row" : undefined
}
>
<td>{player.rank}</td>
<th scope="row">
{player.name}
{player.accountId === event.winnerId ? " 🏆" : ""}
</th>
<td>{formatNumber(player.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<RoomChat
id={id}
playerId={event.you}
endpoint={`tournaments/${id}/chat`}
available={joined && ["scheduled", "running"].includes(event.status)}
playing={event.status === "running"}
/>
</>
);
}