1
0
Fork 0

feat(radio): improve mode gameplay and leaderboards

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-03 16:03:13 -04:00
parent 0b012f07d8
commit 9baf84ee32
3 changed files with 324 additions and 111 deletions

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { RadioStats } from "@/app/(frontend)/radio/actions"; import type { RadioStats } from "@/app/(frontend)/radio/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { RadioLeaderboard } from "./RadioLeaderboard"; import { RadioLeaderboard } from "./RadioLeaderboard";
@ -15,15 +15,29 @@ export function RadioArena({
}) { }) {
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [mode, setMode] = useState<"sprint" | "watch">("sprint");
const [modeHydrated, setModeHydrated] = useState(false);
const activeOperators = useRealtimePresence("radio", playing, true); const activeOperators = useRealtimePresence("radio", playing, true);
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
setPlaying(phase === "playing"); setPlaying(phase === "playing");
}, []); }, []);
useEffect(() => {
const savedMode = window.localStorage.getItem("radio-traffic-mode");
if (savedMode === "sprint" || savedMode === "watch") setMode(savedMode);
setModeHydrated(true);
}, []);
useEffect(() => {
if (modeHydrated) window.localStorage.setItem("radio-traffic-mode", mode);
}, [mode, modeHydrated]);
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<RadioTraffic <RadioTraffic
initialStats={initialStats} initialStats={initialStats}
mode={mode}
onModeChangeAction={setMode}
onGameRecordedAction={() => setRefreshKey((key) => key + 1)} onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
onPhaseChangeAction={onPhaseChange} onPhaseChangeAction={onPhaseChange}
/> />
@ -47,6 +61,8 @@ export function RadioArena({
</div> </div>
<RadioLeaderboard <RadioLeaderboard
refreshKey={refreshKey} refreshKey={refreshKey}
mode={mode}
modeHydrated={modeHydrated}
currentUserId={currentUserId} currentUserId={currentUserId}
activeOperators={activeOperators} activeOperators={activeOperators}
/> />

View file

@ -1,12 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trophy } from "lucide-react"; import { ArrowDown, ArrowUp, Trophy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
type LeaderboardTab = "score" | "accuracy" | "messages" | "time"; type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
type Mode = "sprint" | "watch";
function isLeaderboardTab(value: string): value is LeaderboardTab {
return value === "score" || value === "accuracy" || value === "messages" || value === "time";
}
interface ApiUser { interface ApiUser {
id: number; id: number;
@ -22,9 +27,12 @@ interface ApiProfile {
progression?: { progression?: {
minigames?: { minigames?: {
radioTraffic?: { radioTraffic?: {
totalMessages?: number | null; sprintTotalMessages?: number | null;
bestScore?: number | null; sprintBestScore?: number | null;
bestAccuracy?: number | null; sprintBestAccuracy?: number | null;
watchTotalMessages?: number | null;
watchBestScore?: number | null;
watchBestAccuracy?: number | null;
bestSprintTimeMs?: number | null; bestSprintTimeMs?: number | null;
} | null; } | null;
} | null; } | null;
@ -42,46 +50,20 @@ interface LeaderboardRow {
isOutsideTopTen?: boolean; isOutsideTopTen?: boolean;
} }
interface TabConfig {
readonly label: string;
readonly sort: string;
readonly matches: (row: LeaderboardRow) => boolean;
readonly empty: string;
}
interface RadioLeaderboardProps { interface RadioLeaderboardProps {
refreshKey?: number; refreshKey?: number;
mode: Mode;
modeHydrated: boolean;
currentUserId?: number | string; currentUserId?: number | string;
activeOperators?: PresenceParticipant[]; activeOperators?: PresenceParticipant[];
} }
const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[];
const TAB_CONFIG = { const TAB_CONFIG = {
score: { score: { label: "Top Score", empty: "No scored transmissions yet. Take the first slot!" },
label: "Top Score", accuracy: { label: "Top Accuracy", empty: "No accuracy records yet. Relay a perfect transmission!" },
sort: "-progression.minigames.radioTraffic.bestScore", messages: { label: "Most Correct", empty: "No correct transmissions yet. Get on frequency!" },
matches: (row) => row.bestScore > 0, time: { label: "Fastest Sprint", empty: "No sprint times yet. Complete a 10-message sprint!" },
empty: "No scored transmissions yet. Take the first slot!", } as const;
},
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<LeaderboardTab, TabConfig>;
function rankClass(index: number) { function rankClass(index: number) {
if (index === 0) return "font-bold text-amber-400"; if (index === 0) return "font-bold text-amber-400";
@ -92,6 +74,8 @@ function rankClass(index: number) {
export function RadioLeaderboard({ export function RadioLeaderboard({
refreshKey = 0, refreshKey = 0,
mode,
modeHydrated,
currentUserId, currentUserId,
activeOperators = [], activeOperators = [],
}: RadioLeaderboardProps) { }: RadioLeaderboardProps) {
@ -99,13 +83,45 @@ export function RadioLeaderboard({
const [rows, setRows] = useState<LeaderboardRow[] | null>(null); const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const tabConfig = TAB_CONFIG[tab]; const tabConfig = TAB_CONFIG[tab];
const tabOrder: readonly LeaderboardTab[] =
mode === "sprint"
? ["score", "accuracy", "messages", "time"]
: ["score", "accuracy", "messages"];
const sortDirection = tab === "time" ? "ascending" : "descending";
const SortMarker = ({ column }: { column: LeaderboardTab }) => {
if (tab !== column) return null;
const Icon = sortDirection === "ascending" ? ArrowUp : ArrowDown;
return (
<Icon
aria-hidden="true"
className="ml-1 inline-block size-3 text-primary"
strokeWidth={2.5}
/>
);
};
useEffect(() => { useEffect(() => {
if (mode === "watch" && tab === "time") setTab("score");
}, [mode, tab]);
useEffect(() => {
if (!modeHydrated || (mode === "watch" && tab === "time")) return;
let cancelled = false; let cancelled = false;
setFailed(false); setFailed(false);
setRows(null); setRows(null);
const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" }); const field =
tab === "time"
? "bestSprintTimeMs"
: mode === "sprint"
? `sprint${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`
: `watch${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`;
const params = new URLSearchParams({
sort: `${tab === "time" ? "" : "-"}progression.minigames.radioTraffic.${field}`,
limit: "100",
depth: "1",
});
fetch(`/api/profiles?${params.toString()}`) fetch(`/api/profiles?${params.toString()}`)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("leaderboard fetch failed"); if (!res.ok) throw new Error("leaderboard fetch failed");
@ -119,14 +135,26 @@ export function RadioLeaderboard({
return { return {
id: doc.id, id: doc.id,
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`, name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
totalMessages: radioTraffic.totalMessages ?? 0, totalMessages:
bestScore: radioTraffic.bestScore ?? 0, radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
bestAccuracy: radioTraffic.bestAccuracy ?? 0, bestScore: radioTraffic[mode === "sprint" ? "sprintBestScore" : "watchBestScore"] ?? 0,
bestAccuracy:
radioTraffic[mode === "sprint" ? "sprintBestAccuracy" : "watchBestAccuracy"] ?? 0,
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0, bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
isCurrentUser: user ? String(user.id) === String(currentUserId) : false, isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
}; };
}); });
const visible = mapped.filter(tabConfig.matches).slice(0, 10); const visible = mapped
.filter((row) =>
(tab === "score"
? row.bestScore
: tab === "accuracy"
? row.bestAccuracy
: tab === "messages"
? row.totalMessages
: row.bestSprintTimeMs) > 0,
)
.slice(0, 10);
const current = mapped.find((row) => row.isCurrentUser); const current = mapped.find((row) => row.isCurrentUser);
if (current && !visible.some((row) => row.id === current.id)) { if (current && !visible.some((row) => row.id === current.id)) {
visible.push({ ...current, isOutsideTopTen: true }); visible.push({ ...current, isOutsideTopTen: true });
@ -140,36 +168,36 @@ export function RadioLeaderboard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]); }, [currentUserId, mode, modeHydrated, refreshKey, tab]);
return ( return (
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card"> <div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
<div className="border-b border-border px-4 py-3"> <div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Trophy className="size-4 text-amber-400" /> <Trophy className="size-4 text-amber-400" />
<h2 className="text-sm font-semibold">Radio Traffic Leaderboard</h2> <h2 className="text-sm font-semibold">
Radio Traffic {mode === "sprint" ? "Sprint" : "Watch"} Leaderboard
</h2>
<span className="ml-auto text-xs text-muted-foreground">Top 10</span> <span className="ml-auto text-xs text-muted-foreground">Top 10</span>
</div> </div>
<div <Tabs
className="mt-3 grid grid-cols-4 gap-1 rounded-lg bg-muted p-1" value={tab}
role="tablist" onValueChange={(value) => {
aria-label="Radio traffic leaderboard" if (isLeaderboardTab(value)) setTab(value);
}}
> >
{TAB_ORDER.map((value) => ( <TabsList
<Button id="radio-traffic-leaderboard-tabs"
key={value} aria-label="Radio traffic leaderboard"
type="button" className={`mt-3 grid w-full ${mode === "sprint" ? "grid-cols-4" : "grid-cols-3"}`}
role="tab" >
aria-selected={tab === value} {tabOrder.map((value) => (
variant={tab === value ? "secondary" : "ghost"} <TabsTrigger key={value} value={value} className="min-w-0 px-2 text-xs">
size="sm" {TAB_CONFIG[value].label}
className="px-2 text-xs" </TabsTrigger>
onClick={() => setTab(value)} ))}
> </TabsList>
{TAB_CONFIG[value].label} </Tabs>
</Button>
))}
</div>
</div> </div>
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite"> <div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
<span className="font-medium text-emerald-400"> <span className="font-medium text-emerald-400">
@ -194,15 +222,53 @@ export function RadioLeaderboard({
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p> <p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table id="radio-traffic-leaderboard-table" className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground"> <tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2 font-medium">#</th> <th className="px-4 py-2 font-medium">#</th>
<th className="px-2 py-2 font-medium">Operator</th> <th className="px-2 py-2 font-medium">Operator</th>
<th className="px-2 py-2 text-right font-medium">Score</th> <th
<th className="px-2 py-2 text-right font-medium">Accuracy</th> aria-sort={tab === "score" ? sortDirection : "none"}
<th className="px-2 py-2 text-right font-medium">Time</th> className={cn(
<th className="px-4 py-2 text-right font-medium">Messages</th> "px-2 py-2 text-right font-medium",
tab === "score" && "text-foreground",
)}
>
Score
<SortMarker column="score" />
</th>
<th
aria-sort={tab === "accuracy" ? sortDirection : "none"}
className={cn(
"px-2 py-2 text-right font-medium",
tab === "accuracy" && "text-foreground",
)}
>
Accuracy
<SortMarker column="accuracy" />
</th>
{mode === "sprint" && (
<th
aria-sort={tab === "time" ? sortDirection : "none"}
className={cn(
"px-2 py-2 text-right font-medium",
tab === "time" && "text-foreground",
)}
>
Time
<SortMarker column="time" />
</th>
)}
<th
aria-sort={tab === "messages" ? sortDirection : "none"}
className={cn(
"px-4 py-2 text-right font-medium",
tab === "messages" && "text-foreground",
)}
>
Correct
<SortMarker column="messages" />
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
@ -248,9 +314,11 @@ export function RadioLeaderboard({
<td className="px-2 py-2 text-right font-semibold tabular-nums"> <td className="px-2 py-2 text-right font-semibold tabular-nums">
{(row.bestAccuracy * 100).toFixed(2)}% {(row.bestAccuracy * 100).toFixed(2)}%
</td> </td>
<td className="px-2 py-2 text-right tabular-nums"> {mode === "sprint" && (
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"} <td className="px-2 py-2 text-right tabular-nums">
</td> {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
</td>
)}
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground"> <td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
{row.totalMessages} {row.totalMessages}
</td> </td>

View file

@ -27,12 +27,18 @@ type MessageResult = {
readonly accuracy: number; readonly accuracy: number;
readonly correct: boolean; readonly correct: boolean;
}; };
type ReferenceCharacter = { readonly character: string; readonly matched: boolean };
type IncorrectSprintSubmission = {
readonly target: string;
readonly characters: readonly ReferenceCharacter[];
};
type Feedback = { readonly correct: boolean; readonly message: string }; type Feedback = { readonly correct: boolean; readonly message: string };
type LastRun = { type LastRun = {
readonly mode: Mode; readonly mode: Mode;
readonly score: number; readonly score: number;
readonly accuracy: number; readonly accuracy: number;
readonly correctMessages: number; readonly correctMessages: number;
readonly incorrectMessages: number;
readonly relayedMessages: number; readonly relayedMessages: number;
readonly xpEarned: number; readonly xpEarned: number;
readonly best: number; readonly best: number;
@ -44,13 +50,9 @@ const WATCH_DURATION_MS = 60_000;
const WATCH_MAX_MESSAGES = 30; const WATCH_MAX_MESSAGES = 30;
const FAST_TICK_THRESHOLD_MS = 10_000; const FAST_TICK_THRESHOLD_MS = 10_000;
const CRITICAL_TICK_THRESHOLD_MS = 3_000; const CRITICAL_TICK_THRESHOLD_MS = 3_000;
const MODE_STORAGE_KEY = "radio-traffic-mode"; const UNSUCCESSFUL_SUBMIT_COOLDOWN_MS = 750;
const TICK_SOUND_STORAGE_KEY = "radio-traffic-tick-sound"; const TICK_SOUND_STORAGE_KEY = "radio-traffic-tick-sound";
function isMode(value: string | null): value is Mode {
return value === "sprint" || value === "watch";
}
function messageScore(accuracy: number, timeMs: number): number { function messageScore(accuracy: number, timeMs: number): number {
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000)); const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
return Math.round(accuracy * 100 * speed); return Math.round(accuracy * 100 * speed);
@ -64,6 +66,61 @@ function formatAccuracy(accuracy: number): string {
return `${Math.round(accuracy * 100)}%`; return `${Math.round(accuracy * 100)}%`;
} }
export function alignReferenceCharacters(
target: string,
submitted: string,
): readonly ReferenceCharacter[] {
const targetCharacters = Array.from(target);
const submittedCharacters = Array.from(submitted);
const lcsLengths = Array.from(
{ length: targetCharacters.length + 1 },
() => new Array<number>(submittedCharacters.length + 1).fill(0),
);
for (let targetIndex = targetCharacters.length - 1; targetIndex >= 0; targetIndex -= 1) {
for (
let submittedIndex = submittedCharacters.length - 1;
submittedIndex >= 0;
submittedIndex -= 1
) {
const charactersMatch = targetCharacters[targetIndex] === submittedCharacters[submittedIndex];
lcsLengths[targetIndex][submittedIndex] =
charactersMatch
? lcsLengths[targetIndex + 1][submittedIndex + 1] + 1
: Math.max(
lcsLengths[targetIndex + 1][submittedIndex],
lcsLengths[targetIndex][submittedIndex + 1],
);
}
}
const matchedTargetCharacters = new Array<boolean>(targetCharacters.length).fill(false);
let targetIndex = 0;
let submittedIndex = 0;
let hasPendingInsertion = false;
while (targetIndex < targetCharacters.length && submittedIndex < submittedCharacters.length) {
if (targetCharacters[targetIndex] === submittedCharacters[submittedIndex]) {
matchedTargetCharacters[targetIndex] = !hasPendingInsertion;
hasPendingInsertion = false;
targetIndex += 1;
submittedIndex += 1;
} else if (
lcsLengths[targetIndex + 1][submittedIndex] > lcsLengths[targetIndex][submittedIndex + 1]
) {
hasPendingInsertion = false;
targetIndex += 1;
} else {
hasPendingInsertion = true;
submittedIndex += 1;
}
}
return targetCharacters.map((character, index) => ({
character,
matched: matchedTargetCharacters[index],
}));
}
function roundAccuracy(results: readonly MessageResult[]): number { function roundAccuracy(results: readonly MessageResult[]): number {
const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0); const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0);
return total === 0 ? 0 : results.reduce((sum, result) => sum + result.correctKeys, 0) / total; return total === 0 ? 0 : results.reduce((sum, result) => sum + result.correctKeys, 0) / total;
@ -80,10 +137,14 @@ function Metric({ label, value }: { label: string; value: string | number }) {
export function RadioTraffic({ export function RadioTraffic({
initialStats: serverStats, initialStats: serverStats,
mode,
onModeChangeAction,
onGameRecordedAction, onGameRecordedAction,
onPhaseChangeAction, onPhaseChangeAction,
}: { }: {
initialStats: RadioStats; initialStats: RadioStats;
mode: Mode;
onModeChangeAction: (mode: Mode) => void;
onGameRecordedAction: () => void; onGameRecordedAction: () => void;
onPhaseChangeAction: (phase: Phase) => void; onPhaseChangeAction: (phase: Phase) => void;
}) { }) {
@ -104,9 +165,9 @@ export function RadioTraffic({
const lastCountdownTickRef = useRef(""); const lastCountdownTickRef = useRef("");
const countdownEndPlayedRef = useRef(false); const countdownEndPlayedRef = useRef(false);
const tickSoundRef = useRef(true); const tickSoundRef = useRef(true);
const submitBlockedUntilRef = useRef(0);
const incorrectMessagesRef = useRef(0);
const [phase, setPhase] = useState<Phase>("ready"); const [phase, setPhase] = useState<Phase>("ready");
const [mode, setMode] = useState<Mode>("sprint");
const [modeHydrated, setModeHydrated] = useState(false);
const [tickSoundEnabled, setTickSoundEnabled] = useState(true); const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
const [messages, setMessages] = useState<readonly string[]>(() => { const [messages, setMessages] = useState<readonly string[]>(() => {
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT); const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
@ -121,6 +182,8 @@ export function RadioTraffic({
const [stats, setStats] = useState<RadioStats>(serverStats); const [stats, setStats] = useState<RadioStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null); const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [feedback, setFeedback] = useState<Feedback | null>(null); const [feedback, setFeedback] = useState<Feedback | null>(null);
const [incorrectSprintSubmission, setIncorrectSprintSubmission] =
useState<IncorrectSprintSubmission | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const setPhaseSafe = useCallback( const setPhaseSafe = useCallback(
@ -133,20 +196,13 @@ export function RadioTraffic({
); );
useEffect(() => { useEffect(() => {
const savedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
if (isMode(savedMode)) setMode(savedMode);
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY); const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
if (savedTickSound === "false") { if (savedTickSound === "false") {
tickSoundRef.current = false; tickSoundRef.current = false;
setTickSoundEnabled(false); setTickSoundEnabled(false);
} }
setModeHydrated(true);
}, []); }, []);
useEffect(() => {
if (modeHydrated) window.localStorage.setItem(MODE_STORAGE_KEY, mode);
}, [mode, modeHydrated]);
const handleTickSoundChange = useCallback((enabled: boolean) => { const handleTickSoundChange = useCallback((enabled: boolean) => {
tickSoundRef.current = enabled; tickSoundRef.current = enabled;
setTickSoundEnabled(enabled); setTickSoundEnabled(enabled);
@ -155,8 +211,17 @@ export function RadioTraffic({
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]); useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
useEffect(() => {
setIncorrectSprintSubmission(null);
}, [mode]);
const recordResult = useCallback( const recordResult = useCallback(
async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => { async (
finalResults: readonly MessageResult[],
resultMode: Mode,
elapsedMs: number | undefined,
incorrectMessages: number,
) => {
if (submittingRef.current) return; if (submittingRef.current) return;
const score = finalResults.reduce((sum, result) => sum + result.score, 0); const score = finalResults.reduce((sum, result) => sum + result.score, 0);
const correctMessages = finalResults.filter((result) => result.correct).length; const correctMessages = finalResults.filter((result) => result.correct).length;
@ -167,9 +232,13 @@ export function RadioTraffic({
score, score,
accuracy, accuracy,
correctMessages, correctMessages,
incorrectMessages,
relayedMessages: finalResults.length, relayedMessages: finalResults.length,
xpEarned: 0, xpEarned: 0,
best: statsRef.current.bestScore, best:
resultMode === "sprint"
? statsRef.current.sprintBestScore
: statsRef.current.watchBestScore,
isNewBest: false, isNewBest: false,
error: "Your session expired. Result not recorded.", error: "Your session expired. Result not recorded.",
}); });
@ -182,6 +251,7 @@ export function RadioTraffic({
score, score,
messageCount: finalResults.length, messageCount: finalResults.length,
correctMessages, correctMessages,
incorrectMessages,
accuracy, accuracy,
mode: resultMode, mode: resultMode,
timeMs: elapsedMs, timeMs: elapsedMs,
@ -190,7 +260,8 @@ export function RadioTraffic({
setSubmitting(false); setSubmitting(false);
if (result.success && result.data) { if (result.success && result.data) {
const previousBest = statsRef.current.bestScore; const previousBest =
resultMode === "sprint" ? statsRef.current.sprintBestScore : statsRef.current.watchBestScore;
statsRef.current = result.data; statsRef.current = result.data;
setStats(result.data); setStats(result.data);
onGameRecordedAction(); onGameRecordedAction();
@ -208,9 +279,11 @@ export function RadioTraffic({
score, score,
accuracy, accuracy,
correctMessages, correctMessages,
incorrectMessages,
relayedMessages: finalResults.length, relayedMessages: finalResults.length,
xpEarned: result.data.xpEarned, xpEarned: result.data.xpEarned,
best: result.data.bestScore, best:
resultMode === "sprint" ? result.data.sprintBestScore : result.data.watchBestScore,
isNewBest: score > previousBest, isNewBest: score > previousBest,
}); });
} else { } else {
@ -219,9 +292,13 @@ export function RadioTraffic({
score, score,
accuracy, accuracy,
correctMessages, correctMessages,
incorrectMessages,
relayedMessages: finalResults.length, relayedMessages: finalResults.length,
xpEarned: 0, xpEarned: 0,
best: statsRef.current.bestScore, best:
resultMode === "sprint"
? statsRef.current.sprintBestScore
: statsRef.current.watchBestScore,
isNewBest: false, isNewBest: false,
error: result.error ?? "Could not record radio traffic.", error: result.error ?? "Could not record radio traffic.",
}); });
@ -231,7 +308,11 @@ export function RadioTraffic({
); );
const finishRound = useCallback( const finishRound = useCallback(
(finalResults: readonly MessageResult[], resultMode: Mode) => { (
finalResults: readonly MessageResult[],
resultMode: Mode,
incorrectMessages = incorrectMessagesRef.current,
) => {
if (phaseRef.current !== "playing") return; if (phaseRef.current !== "playing") return;
const lastResult = finalResults[finalResults.length - 1]; const lastResult = finalResults[finalResults.length - 1];
if (lastResult) previousRoundLastMessageRef.current = lastResult.target; if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
@ -243,15 +324,16 @@ export function RadioTraffic({
score: 0, score: 0,
accuracy: 0, accuracy: 0,
correctMessages: 0, correctMessages: 0,
incorrectMessages,
relayedMessages: 0, relayedMessages: 0,
xpEarned: 0, xpEarned: 0,
best: statsRef.current.bestScore, best: statsRef.current.watchBestScore,
isNewBest: false, isNewBest: false,
}); });
return; return;
} }
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined; const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
void recordResult(finalResults, resultMode, elapsedMs); void recordResult(finalResults, resultMode, elapsedMs, incorrectMessages);
}, },
[recordResult, setPhaseSafe], [recordResult, setPhaseSafe],
); );
@ -322,6 +404,7 @@ export function RadioTraffic({
setInput(""); setInput("");
setResults([]); setResults([]);
keystrokesRef.current = { correct: 0, incorrect: 0 }; keystrokesRef.current = { correct: 0, incorrect: 0 };
incorrectMessagesRef.current = 0;
setElapsedMs(0); setElapsedMs(0);
const isWatch = mode === "watch"; const isWatch = mode === "watch";
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0; const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
@ -331,6 +414,7 @@ export function RadioTraffic({
setRemainingMs(nextRemaining); setRemainingMs(nextRemaining);
setLastRun(null); setLastRun(null);
setFeedback(null); setFeedback(null);
setIncorrectSprintSubmission(null);
messageStartedAtRef.current = now; messageStartedAtRef.current = now;
setPhaseSafe("playing"); setPhaseSafe("playing");
window.setTimeout(() => inputRef.current?.focus(), 0); window.setTimeout(() => inputRef.current?.focus(), 0);
@ -360,9 +444,10 @@ export function RadioTraffic({
(event: FormEvent<HTMLFormElement>) => { (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (phaseRef.current !== "playing" || submittingRef.current) return; if (phaseRef.current !== "playing" || submittingRef.current) return;
const now = performance.now();
if (now < submitBlockedUntilRef.current) return;
const target = messages[messageIndex]; const target = messages[messageIndex];
if (!target) return; if (!target) return;
const now = performance.now();
const timeMs = Math.max(1, now - messageStartedAtRef.current); const timeMs = Math.max(1, now - messageStartedAtRef.current);
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current; const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
const keyTotal = correctKeys + incorrectKeys; const keyTotal = correctKeys + incorrectKeys;
@ -375,18 +460,34 @@ export function RadioTraffic({
accuracy, accuracy,
correct: input === target, correct: input === target,
}; };
keystrokesRef.current = { correct: 0, incorrect: 0 }; if (!result.correct) {
lastCountdownTickRef.current = ""; incorrectMessagesRef.current += 1;
keystrokesRef.current = { correct: 0, incorrect: 0 };
lastCountdownTickRef.current = "";
if (mode === "sprint") {
setIncorrectSprintSubmission({
target,
characters: alignReferenceCharacters(target, input),
});
}
setInput("");
playError();
setFeedback({ correct: false, message: "Signal mismatch. Try the transmission again." });
submitBlockedUntilRef.current = now + UNSUCCESSFUL_SUBMIT_COOLDOWN_MS;
if (mode === "watch" && now >= roundDeadlineRef.current) {
finishRound(results, mode, incorrectMessagesRef.current);
}
return;
}
keystrokesRef.current = { correct: 0, incorrect: 0 };
lastCountdownTickRef.current = "";
const finalResults = [...results, result]; const finalResults = [...results, result];
setIncorrectSprintSubmission(null);
setResults(finalResults); setResults(finalResults);
setInput(""); setInput("");
if (result.correct) { playCorrect();
playCorrect(); setFeedback({ correct: true, message: "Transmission accepted." });
setFeedback({ correct: true, message: "Transmission accepted." });
} else {
playError();
setFeedback({ correct: false, message: "Signal mismatch. Next transmission incoming." });
}
const watchExpired = mode === "watch" && now >= roundDeadlineRef.current; const watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES; const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
@ -420,12 +521,17 @@ export function RadioTraffic({
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode; const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
const target = messages[messageIndex] ?? ""; const target = messages[messageIndex] ?? "";
const sprintReferenceDiff =
mode === "sprint" && incorrectSprintSubmission?.target === target
? incorrectSprintSubmission.characters
: null;
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect; const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0; const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0); const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
const displayScore = lastRun?.score ?? provisionalScore; const displayScore = lastRun?.score ?? provisionalScore;
const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results); const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results);
const displayCorrect = lastRun?.correctMessages ?? results.filter((result) => result.correct).length; const displayCorrect = lastRun?.correctMessages ?? results.length;
const displayIncorrect = lastRun?.incorrectMessages ?? incorrectMessagesRef.current;
const displayRelayed = lastRun?.relayedMessages ?? results.length; const displayRelayed = lastRun?.relayedMessages ?? results.length;
const progress = const progress =
displayMode === "sprint" displayMode === "sprint"
@ -469,10 +575,10 @@ export function RadioTraffic({
{(phase === "ready" || phase === "over") && ( {(phase === "ready" || phase === "over") && (
<div className="mt-3 grid grid-cols-2 gap-2" role="group" aria-label="Radio traffic mode"> <div className="mt-3 grid grid-cols-2 gap-2" role="group" aria-label="Radio traffic mode">
<Button type="button" size="sm" variant={mode === "sprint" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("sprint")}> <Button type="button" size="sm" variant={mode === "sprint" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("sprint")}>
Sprint · 10 messages Sprint · 10 messages
</Button> </Button>
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("watch")}> <Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("watch")}>
Watch · 60 seconds Watch · 60 seconds
</Button> </Button>
</div> </div>
@ -485,7 +591,30 @@ export function RadioTraffic({
</div>} </div>}
{phase === "playing" && <div className="flex flex-col gap-4 pt-4"> {phase === "playing" && <div className="flex flex-col gap-4 pt-4">
<div className="rounded-lg border border-border bg-background p-4"><p className="text-xs uppercase tracking-wider text-muted-foreground">Incoming transmission</p><p className="mt-3 break-words font-mono text-base leading-relaxed text-foreground">{target}</p></div> <div className="rounded-lg border border-border bg-background p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Incoming transmission</p>
<p
className="mt-3 break-words font-mono text-base leading-relaxed text-foreground"
aria-describedby={sprintReferenceDiff ? "radio-traffic-diff-description" : undefined}
>
{sprintReferenceDiff
? sprintReferenceDiff.map(({ character, matched }, index) => (
<span
key={`${index}-${character}`}
className={matched ? "text-emerald-500" : "text-destructive"}
>
{character}
</span>
))
: target}
</p>
{sprintReferenceDiff && (
<span id="radio-traffic-diff-description" className="sr-only" role="status">
Reference text is unchanged. Green characters match your last submission; red characters
differ. Typed-only characters are not shown.
</span>
)}
</div>
<form className="flex flex-col gap-2" onSubmit={submitCurrent}> <form className="flex flex-col gap-2" onSubmit={submitCurrent}>
<label htmlFor="radio-traffic-input" className="text-sm font-medium">Type exact transmission</label> <label htmlFor="radio-traffic-input" className="text-sm font-medium">Type exact transmission</label>
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div> <div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
@ -497,7 +626,7 @@ export function RadioTraffic({
{phase === "over" && <div className="flex flex-col items-center gap-4 py-8 text-center"> {phase === "over" && <div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex items-center gap-2 text-emerald-400"><Check className="size-5" /><span className="font-bold uppercase tracking-widest">{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}</span></div> <div className="flex items-center gap-2 text-emerald-400"><Check className="size-5" /><span className="font-bold uppercase tracking-widest">{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}</span></div>
<div className="grid w-full grid-cols-2 gap-3 sm:grid-cols-4"><Metric label="Score" value={displayScore} /><Metric label="Accuracy" value={formatAccuracy(displayAccuracy)} /><Metric label="Messages" value={displayMode === "sprint" ? `${displayCorrect}/${SPRINT_MESSAGE_COUNT}` : displayRelayed} /><Metric label="Best" value={lastRun?.best ?? stats.bestScore} /></div> <div className="grid w-full grid-cols-2 gap-3 sm:grid-cols-5"><Metric label="Score" value={displayScore} /><Metric label="Accuracy" value={formatAccuracy(displayAccuracy)} /><Metric label="Correct" value={displayMode === "sprint" ? `${displayCorrect}/${SPRINT_MESSAGE_COUNT}` : displayRelayed} /><Metric label="Incorrect" value={displayIncorrect} /><Metric label="Best" value={lastRun?.best ?? stats.bestScore} /></div>
{lastRun?.isNewBest && <Badge variant="outline" className="border-amber-400/40 text-amber-300">New best</Badge>} {lastRun?.isNewBest && <Badge variant="outline" className="border-amber-400/40 text-amber-300">New best</Badge>}
{lastRun?.xpEarned ? <span className="text-sm font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span> : null} {lastRun?.xpEarned ? <span className="text-sm font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span> : null}
{lastRun?.error && <p className="text-xs text-destructive">{lastRun.error}</p>} {lastRun?.error && <p className="text-xs text-destructive">{lastRun.error}</p>}
@ -507,7 +636,7 @@ export function RadioTraffic({
{expired && <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-background/95 p-6 text-center"><span className="text-2xl font-black uppercase tracking-widest text-amber-400">Session Ending Soon</span><span className="max-w-[260px] text-xs text-muted-foreground">Your session is about to end. The app will refresh when it expires.</span><LoginLink className="mt-1"><Button type="button" size="sm">Log in</Button></LoginLink></div>} {expired && <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-background/95 p-6 text-center"><span className="text-2xl font-black uppercase tracking-widest text-amber-400">Session Ending Soon</span><span className="max-w-[260px] text-xs text-muted-foreground">Your session is about to end. The app will refresh when it expires.</span><LoginLink className="mt-1"><Button type="button" size="sm">Log in</Button></LoginLink></div>}
</div> </div>
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground"><div className="flex items-center gap-1.5"><span>Games</span><span className="font-semibold text-foreground">{stats.gamesPlayed}</span></div><div className="flex items-center gap-1.5"><span>Messages</span><span className="font-semibold text-foreground">{stats.totalMessages}</span></div><div className="flex items-center gap-1.5"><span>Best run</span><span className="font-semibold text-foreground">{stats.bestScore}</span></div><div className="flex items-center gap-1.5"><span>XP from traffic</span><span className="font-semibold text-foreground">{stats.radioTrafficXp}</span></div></div> <div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground"><div className="flex items-center gap-1.5"><span>Games</span><span className="font-semibold text-foreground">{stats.gamesPlayed}</span></div><div className="flex items-center gap-1.5"><span>Correct messages</span><span className="font-semibold text-foreground">{stats.totalMessages}</span></div><div className="flex items-center gap-1.5"><span>Incorrect messages</span><span className="font-semibold text-foreground">{stats.incorrectMessages}</span></div><div className="flex items-center gap-1.5"><span>Best run</span><span className="font-semibold text-foreground">{stats.bestScore}</span></div><div className="flex items-center gap-1.5"><span>XP from traffic</span><span className="font-semibold text-foreground">{stats.radioTrafficXp}</span></div></div>
</div> </div>
); );
} }