1
0
Fork 0
polaris-task-force/src/components/frontend/radio/RadioTraffic.tsx
Z8MB1E 0d58b54977 feat(radio): add radio traffic gameplay
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 01:59:49 -04:00

513 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { createRadioRound, nextRadioMessage, SPRINT_MESSAGE_COUNT } from "./messageGenerator";
import { playCountdownEnd, playCountdownTick, playCorrect, playError, playKeystroke, playRoundComplete, playXp } from "./sounds";
type Phase = "ready" | "playing" | "over";
type Mode = "sprint" | "watch";
type MessageResult = {
readonly target: string;
readonly score: number;
readonly correctKeys: number;
readonly incorrectKeys: number;
readonly accuracy: number;
readonly correct: boolean;
};
type Feedback = { readonly correct: boolean; readonly message: string };
type LastRun = {
readonly mode: Mode;
readonly score: number;
readonly accuracy: number;
readonly correctMessages: number;
readonly relayedMessages: number;
readonly xpEarned: number;
readonly best: number;
readonly isNewBest: boolean;
readonly error?: string;
};
const WATCH_DURATION_MS = 60_000;
const WATCH_MAX_MESSAGES = 30;
const FAST_TICK_THRESHOLD_MS = 10_000;
const CRITICAL_TICK_THRESHOLD_MS = 3_000;
const MODE_STORAGE_KEY = "radio-traffic-mode";
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 {
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
return Math.round(accuracy * 100 * speed);
}
function formatTime(timeMs: number): string {
return `${(timeMs / 1000).toFixed(1)}s`;
}
function formatAccuracy(accuracy: number): string {
return `${Math.round(accuracy * 100)}%`;
}
function roundAccuracy(results: readonly MessageResult[]): number {
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;
}
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-border bg-background p-3">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
</div>
);
}
export function RadioTraffic({
initialStats: serverStats,
onGameRecordedAction,
onPhaseChangeAction,
}: {
initialStats: RadioStats;
onGameRecordedAction: () => void;
onPhaseChangeAction: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const inputRef = useRef<HTMLInputElement | null>(null);
const phaseRef = useRef<Phase>("ready");
const roundStartedAtRef = useRef(0);
const roundDeadlineRef = useRef(0);
const messageStartedAtRef = useRef(0);
const remainingMsRef = useRef(0);
const submittingRef = useRef(false);
const statsRef = useRef<RadioStats>(serverStats);
const previousRoundLastMessageRef = useRef<string | null>(null);
const watchUsedRef = useRef<Set<string>>(new Set());
const watchExpiryNotifiedRef = useRef(false);
const keystrokesRef = useRef({ correct: 0, incorrect: 0 });
const lastCountdownTickRef = useRef("");
const countdownEndPlayedRef = useRef(false);
const tickSoundRef = useRef(true);
const [phase, setPhase] = useState<Phase>("ready");
const [mode, setMode] = useState<Mode>("sprint");
const [modeHydrated, setModeHydrated] = useState(false);
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
const [messages, setMessages] = useState<readonly string[]>(() => {
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
return initialRound;
});
const [messageIndex, setMessageIndex] = useState(0);
const [input, setInput] = useState("");
const [results, setResults] = useState<readonly MessageResult[]>([]);
const [elapsedMs, setElapsedMs] = useState(0);
const [remainingMs, setRemainingMs] = useState(0);
const [stats, setStats] = useState<RadioStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [submitting, setSubmitting] = useState(false);
const setPhaseSafe = useCallback(
(next: Phase) => {
phaseRef.current = next;
setPhase(next);
onPhaseChangeAction(next);
},
[onPhaseChangeAction],
);
useEffect(() => {
const savedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
if (isMode(savedMode)) setMode(savedMode);
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
if (savedTickSound === "false") {
tickSoundRef.current = false;
setTickSoundEnabled(false);
}
setModeHydrated(true);
}, []);
useEffect(() => {
if (modeHydrated) window.localStorage.setItem(MODE_STORAGE_KEY, mode);
}, [mode, modeHydrated]);
const handleTickSoundChange = useCallback((enabled: boolean) => {
tickSoundRef.current = enabled;
setTickSoundEnabled(enabled);
window.localStorage.setItem(TICK_SOUND_STORAGE_KEY, String(enabled));
}, []);
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
const recordResult = useCallback(
async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => {
if (submittingRef.current) return;
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
const correctMessages = finalResults.filter((result) => result.correct).length;
const accuracy = roundAccuracy(finalResults);
if (expiredRef.current) {
setLastRun({
mode: resultMode,
score,
accuracy,
correctMessages,
relayedMessages: finalResults.length,
xpEarned: 0,
best: statsRef.current.bestScore,
isNewBest: false,
error: "Your session expired. Result not recorded.",
});
return;
}
submittingRef.current = true;
setSubmitting(true);
const result = await submitRadioResult({
score,
messageCount: finalResults.length,
correctMessages,
accuracy,
mode: resultMode,
timeMs: elapsedMs,
});
submittingRef.current = false;
setSubmitting(false);
if (result.success && result.data) {
const previousBest = statsRef.current.bestScore;
statsRef.current = result.data;
setStats(result.data);
onGameRecordedAction();
if (result.data.xpEarned > 0) {
playXp();
window.dispatchEvent(
new CustomEvent(PROFILE_UPDATE_EVENT, {
detail: { reason: "xp", xpGained: result.data.xpEarned },
}),
);
window.setTimeout(() => router.refresh(), 1700);
}
setLastRun({
mode: resultMode,
score,
accuracy,
correctMessages,
relayedMessages: finalResults.length,
xpEarned: result.data.xpEarned,
best: result.data.bestScore,
isNewBest: score > previousBest,
});
} else {
setLastRun({
mode: resultMode,
score,
accuracy,
correctMessages,
relayedMessages: finalResults.length,
xpEarned: 0,
best: statsRef.current.bestScore,
isNewBest: false,
error: result.error ?? "Could not record radio traffic.",
});
}
},
[expiredRef, onGameRecordedAction, router],
);
const finishRound = useCallback(
(finalResults: readonly MessageResult[], resultMode: Mode) => {
if (phaseRef.current !== "playing") return;
const lastResult = finalResults[finalResults.length - 1];
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
setPhaseSafe("over");
playRoundComplete();
if (resultMode === "watch" && finalResults.length === 0) {
setLastRun({
mode: "watch",
score: 0,
accuracy: 0,
correctMessages: 0,
relayedMessages: 0,
xpEarned: 0,
best: statsRef.current.bestScore,
isNewBest: false,
});
return;
}
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
void recordResult(finalResults, resultMode, elapsedMs);
},
[recordResult, setPhaseSafe],
);
useEffect(() => {
if (phase !== "playing") return;
const timer = window.setInterval(() => {
const now = performance.now();
if (mode === "sprint") {
setElapsedMs(now - roundStartedAtRef.current);
return;
}
const nextRemaining = Math.max(0, roundDeadlineRef.current - now);
remainingMsRef.current = nextRemaining;
setRemainingMs(nextRemaining);
const tickIntensity =
nextRemaining <= CRITICAL_TICK_THRESHOLD_MS
? "critical"
: nextRemaining <= FAST_TICK_THRESHOLD_MS
? "fast"
: "normal";
const tickSlotMs = tickIntensity === "critical" ? 250 : tickIntensity === "fast" ? 500 : 1000;
const tickKey = `${tickIntensity}:${Math.floor(nextRemaining / tickSlotMs)}`;
if (tickKey !== lastCountdownTickRef.current) {
lastCountdownTickRef.current = tickKey;
if (tickSoundRef.current) playCountdownTick(tickIntensity);
}
if (nextRemaining === 0) {
if (!countdownEndPlayedRef.current) {
countdownEndPlayedRef.current = true;
if (tickSoundRef.current) playCountdownEnd();
}
if (inputRef.current?.value && !watchExpiryNotifiedRef.current) {
watchExpiryNotifiedRef.current = true;
setFeedback({ correct: false, message: "Time expired. Press Enter to log the current transmission." });
}
}
}, 100);
return () => window.clearInterval(timer);
}, [mode, phase]);
useEffect(() => {
if (mode !== "watch" || phase !== "playing" || remainingMs > 0 || input.length > 0) return;
finishRound(results, "watch");
}, [finishRound, input.length, mode, phase, remainingMs, results]);
const rejectInsertion = useCallback(() => {
setFeedback({ correct: false, message: "Typing only. Paste and drop are disabled." });
}, []);
const startRound = useCallback(() => {
if (expiredRef.current || submittingRef.current) return;
const now = performance.now();
let nextMessages: readonly string[];
watchUsedRef.current = new Set();
watchExpiryNotifiedRef.current = false;
countdownEndPlayedRef.current = false;
if (mode === "sprint") {
nextMessages = createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null;
} else {
const firstMessage = nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(firstMessage);
nextMessages = [firstMessage];
}
setMessages(nextMessages);
setMessageIndex(0);
setInput("");
setResults([]);
keystrokesRef.current = { correct: 0, incorrect: 0 };
setElapsedMs(0);
const isWatch = mode === "watch";
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
roundStartedAtRef.current = now;
roundDeadlineRef.current = isWatch ? now + WATCH_DURATION_MS : 0;
remainingMsRef.current = nextRemaining;
setRemainingMs(nextRemaining);
setLastRun(null);
setFeedback(null);
messageStartedAtRef.current = now;
setPhaseSafe("playing");
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [expiredRef, mode, setPhaseSafe]);
const handleKeystroke = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (phaseRef.current !== "playing") return;
const currentInput = inputRef.current?.value ?? "";
const currentTarget = messages[messageIndex] ?? "";
if (event.key === "Backspace") {
if (currentInput.length > 0) keystrokesRef.current.incorrect += 1;
return;
}
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
const expected = currentTarget[currentInput.length];
if (expected !== undefined && event.key.toUpperCase() === expected) {
keystrokesRef.current.correct += 1;
} else {
keystrokesRef.current.incorrect += 1;
}
},
[messages, messageIndex],
);
const submitCurrent = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (phaseRef.current !== "playing" || submittingRef.current) return;
const target = messages[messageIndex];
if (!target) return;
const now = performance.now();
const timeMs = Math.max(1, now - messageStartedAtRef.current);
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
const keyTotal = correctKeys + incorrectKeys;
const accuracy = keyTotal > 0 ? correctKeys / keyTotal : 0;
const result: MessageResult = {
target,
score: messageScore(accuracy, timeMs),
correctKeys,
incorrectKeys,
accuracy,
correct: input === target,
};
keystrokesRef.current = { correct: 0, incorrect: 0 };
lastCountdownTickRef.current = "";
const finalResults = [...results, result];
setResults(finalResults);
setInput("");
if (result.correct) {
playCorrect();
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 watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
const shouldFinish =
mode === "sprint"
? messageIndex + 1 >= messages.length
: watchExpired || watchLimitReached;
if (shouldFinish) {
if (mode === "watch") {
remainingMsRef.current = Math.max(0, roundDeadlineRef.current - now);
setRemainingMs(remainingMsRef.current);
} else {
setElapsedMs(now - roundStartedAtRef.current);
}
finishRound(finalResults, mode);
return;
}
if (mode === "watch") {
watchUsedRef.current.add(target);
const nextTarget = nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(nextTarget);
setMessages((current) => [...current, nextTarget]);
}
setMessageIndex((index) => index + 1);
messageStartedAtRef.current = now;
window.setTimeout(() => inputRef.current?.focus(), 0);
},
[finishRound, input, messageIndex, messages, mode, results],
);
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
const target = messages[messageIndex] ?? "";
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
const displayScore = lastRun?.score ?? provisionalScore;
const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results);
const displayCorrect = lastRun?.correctMessages ?? results.filter((result) => result.correct).length;
const displayRelayed = lastRun?.relayedMessages ?? results.length;
const progress =
displayMode === "sprint"
? ((phase === "over" ? messages.length : messageIndex) / messages.length) * 100
: Math.min(100, Math.max(0, ((WATCH_DURATION_MS - remainingMs) / WATCH_DURATION_MS) * 100));
const headerLabel =
phase === "ready"
? "Frequency standby"
: displayMode === "watch"
? `Relayed ${displayRelayed}`
: `Message ${Math.min(messageIndex + 1, messages.length)} of ${messages.length}`;
const modeSelectorDisabled = phase === "playing";
return (
<div className="flex w-full max-w-[620px] flex-col items-center gap-4">
<div className="text-center">
<div className="flex items-center justify-center gap-2">
<Radio className="size-5 text-muted-foreground" />
<h2 className="text-2xl font-bold uppercase tracking-widest">Radio Traffic</h2>
</div>
<p className="text-sm text-muted-foreground">Intercept clean transmissions under pressure.</p>
</div>
<div className="relative w-full rounded-xl border border-border bg-card p-4 shadow-lg sm:p-5">
<div className="flex items-center justify-between gap-3 text-xs uppercase tracking-wider text-muted-foreground">
<span className="flex items-center gap-1.5"><Target className="size-3.5" />{headerLabel}</span>
<span className="flex items-center gap-1.5 font-mono tabular-nums"><Clock3 className="size-3.5" />{formatTime(displayMode === "watch" ? remainingMs : elapsedMs)}</span>
</div>
{displayMode === "sprint" && (
<Progress value={progress} aria-label={`Radio traffic progress: ${messageIndex} of ${messages.length} messages`} className="mt-3" />
)}
{displayMode === "watch" && (
<Progress value={progress} aria-label={`Watch time elapsed: ${formatTime(WATCH_DURATION_MS - remainingMs)}`} className="mt-3" />
)}
<div className="mt-3 flex items-center justify-between">
<label htmlFor="radio-tick-sound" title="Only plays during Watch mode countdowns" className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Volume2 className="size-3.5" /> Countdown tick sound
</label>
<Switch id="radio-tick-sound" checked={tickSoundEnabled} onCheckedChange={handleTickSoundChange} />
</div>
{(phase === "ready" || phase === "over") && (
<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")}>
Sprint · 10 messages
</Button>
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("watch")}>
Watch · 60 seconds
</Button>
</div>
)}
{phase === "ready" && <div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="rounded-full border border-border bg-background p-4 text-emerald-400"><Radio className="size-8 animate-pulse" /></div>
<div className="flex flex-col gap-1"><p className="font-semibold uppercase tracking-wider">Incoming traffic queued</p><p className="text-sm text-muted-foreground">Type each transmission exactly, then press Enter to relay it.</p></div>
<Button type="button" onClick={startRound} disabled={expired}>Start intercept</Button>
</div>}
{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>
<form className="flex flex-col gap-2" onSubmit={submitCurrent}>
<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>
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
</form>
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
</div>}
{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="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>
{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?.error && <p className="text-xs text-destructive">{lastRun.error}</p>}
{submitting && <p className="text-xs text-muted-foreground">Recording result…</p>}
<Button type="button" onClick={startRound} disabled={submitting || expired}>Play Again</Button>
</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 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>
);
}