diff --git a/src/components/frontend/radio/RadioArena.tsx b/src/components/frontend/radio/RadioArena.tsx new file mode 100644 index 0000000..a08b99f --- /dev/null +++ b/src/components/frontend/radio/RadioArena.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { RadioStats } from "@/app/(frontend)/radio/actions"; +import { useRealtimePresence } from "@/hooks/useRealtimePresence"; +import { RadioLeaderboard } from "./RadioLeaderboard"; +import { RadioTraffic } from "./RadioTraffic"; + +export function RadioArena({ + initialStats, + currentUserId, +}: { + initialStats: RadioStats; + currentUserId: number | string; +}) { + const [refreshKey, setRefreshKey] = useState(0); + const [playing, setPlaying] = useState(false); + const activeOperators = useRealtimePresence("radio", playing, true); + const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { + setPlaying(phase === "playing"); + }, []); + + return ( +
+ setRefreshKey((key) => key + 1)} + onPhaseChangeAction={onPhaseChange} + /> +
+

How scoring works

+
    +
  • + Accuracy tracks every keystroke; each wrong character or backspace counts against it. Each message scores accuracy × speed multiplier, up to ~120 points for a fast perfect message. +
  • +
  • + Sprint: clear exactly 10 messages as fast as possible; maximum score is 1200. +
  • +
  • + Watch: relay as many messages as possible in 60 seconds; score is unbounded in practice. +
  • +
  • + XP: earn 0.1 per perfectly-typed message. +
  • +
  • The lifetime leaderboard ranks Top Score, Top Accuracy, Fastest Sprint, and Most Messages.
  • +
+
+ +
+ ); +} diff --git a/src/components/frontend/radio/RadioLeaderboard.tsx b/src/components/frontend/radio/RadioLeaderboard.tsx new file mode 100644 index 0000000..5426a75 --- /dev/null +++ b/src/components/frontend/radio/RadioLeaderboard.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Trophy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; + +type LeaderboardTab = "score" | "accuracy" | "messages" | "time"; + +interface ApiUser { + id: number; + username?: string | null; + displayName?: string | null; + payloadDisplayName?: string | null; +} + +interface ApiProfile { + id: number; + profileTitle?: string | null; + user?: ApiUser | number | null; + progression?: { + minigames?: { + radioTraffic?: { + totalMessages?: number | null; + bestScore?: number | null; + bestAccuracy?: number | null; + bestSprintTimeMs?: number | null; + } | null; + } | null; + } | null; +} + +interface LeaderboardRow { + id: number; + name: string; + totalMessages: number; + bestScore: number; + bestAccuracy: number; + bestSprintTimeMs: number; + isCurrentUser: boolean; + isOutsideTopTen?: boolean; +} + +interface TabConfig { + readonly label: string; + readonly sort: string; + readonly matches: (row: LeaderboardRow) => boolean; + readonly empty: string; +} + +interface RadioLeaderboardProps { + refreshKey?: number; + currentUserId?: number | string; + activeOperators?: PresenceParticipant[]; +} + +const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[]; +const TAB_CONFIG = { + score: { + label: "Top Score", + sort: "-progression.minigames.radioTraffic.bestScore", + matches: (row) => row.bestScore > 0, + empty: "No scored transmissions yet. Take the first slot!", + }, + accuracy: { + label: "Top Accuracy", + sort: "-progression.minigames.radioTraffic.bestAccuracy", + matches: (row) => row.bestAccuracy > 0, + empty: "No accuracy records yet. Relay a perfect transmission!", + }, + messages: { + label: "Most Messages", + sort: "-progression.minigames.radioTraffic.totalMessages", + matches: (row) => row.totalMessages > 0, + empty: "No accurate messages yet. Get on frequency!", + }, + time: { + label: "Fastest Sprint", + sort: "progression.minigames.radioTraffic.bestSprintTimeMs", + matches: (row) => row.bestSprintTimeMs > 0, + empty: "No sprint times yet. Complete a 10-message sprint!", + }, +} satisfies Record; + +function rankClass(index: number) { + if (index === 0) return "font-bold text-amber-400"; + if (index === 1) return "font-semibold text-slate-300"; + if (index === 2) return "font-semibold text-amber-700"; + return "text-muted-foreground"; +} + +export function RadioLeaderboard({ + refreshKey = 0, + currentUserId, + activeOperators = [], +}: RadioLeaderboardProps) { + const [tab, setTab] = useState("score"); + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(false); + const tabConfig = TAB_CONFIG[tab]; + + useEffect(() => { + let cancelled = false; + setFailed(false); + setRows(null); + + const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" }); + fetch(`/api/profiles?${params.toString()}`) + .then((res) => { + if (!res.ok) throw new Error("leaderboard fetch failed"); + return res.json() as Promise<{ docs?: ApiProfile[] }>; + }) + .then((data) => { + if (cancelled) return; + const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => { + const radioTraffic = doc.progression?.minigames?.radioTraffic ?? {}; + const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null; + return { + id: doc.id, + name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`, + totalMessages: radioTraffic.totalMessages ?? 0, + bestScore: radioTraffic.bestScore ?? 0, + bestAccuracy: radioTraffic.bestAccuracy ?? 0, + bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0, + isCurrentUser: user ? String(user.id) === String(currentUserId) : false, + }; + }); + const visible = mapped.filter(tabConfig.matches).slice(0, 10); + const current = mapped.find((row) => row.isCurrentUser); + if (current && !visible.some((row) => row.id === current.id)) { + visible.push({ ...current, isOutsideTopTen: true }); + } + setRows(visible); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]); + + return ( +
+
+
+ +

Radio Traffic Leaderboard

+ Top 10 +
+
+ {TAB_ORDER.map((value) => ( + + ))} +
+
+
+ + {activeOperators.length} {activeOperators.length === 1 ? "operator" : "operators"} on frequency + + {activeOperators.length > 0 && ( + + ({activeOperators.map((operator) => operator.name).join(", ")}) + + )} +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

{tabConfig.empty}

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
#OperatorScoreAccuracyTimeMessages
+ {row.isOutsideTopTen ? "—" : index + 1} + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.isOutsideTopTen && ( + + unranked + + )} + {activeOperators.some((operator) => operator.id === row.id) && ( + + )} + + + {row.bestScore.toFixed(2)} + + {(row.bestAccuracy * 100).toFixed(2)}% + + {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"} + + {row.totalMessages} +
+
+ )} +
+ ); +}