"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 (
{label}
{value}
); } 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(null); const phaseRef = useRef("ready"); const roundStartedAtRef = useRef(0); const roundDeadlineRef = useRef(0); const messageStartedAtRef = useRef(0); const remainingMsRef = useRef(0); const submittingRef = useRef(false); const statsRef = useRef(serverStats); const previousRoundLastMessageRef = useRef(null); const watchUsedRef = useRef>(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("ready"); const [mode, setMode] = useState("sprint"); const [modeHydrated, setModeHydrated] = useState(false); const [tickSoundEnabled, setTickSoundEnabled] = useState(true); const [messages, setMessages] = useState(() => { 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([]); const [elapsedMs, setElapsedMs] = useState(0); const [remainingMs, setRemainingMs] = useState(0); const [stats, setStats] = useState(serverStats); const [lastRun, setLastRun] = useState(null); const [feedback, setFeedback] = useState(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) => { 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) => { 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 (

Radio Traffic

Intercept clean transmissions under pressure.

{headerLabel} {formatTime(displayMode === "watch" ? remainingMs : elapsedMs)}
{displayMode === "sprint" && ( )} {displayMode === "watch" && ( )}
{(phase === "ready" || phase === "over") && (
)} {phase === "ready" &&

Incoming traffic queued

Type each transmission exactly, then press Enter to relay it.

} {phase === "playing" &&

Incoming transmission

{target}

{ 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" />

Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.

Current accuracy {liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}Round score {provisionalScore}Completed {results.length}
{feedback &&

{feedback.message}

}
} {phase === "over" &&
{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}
{lastRun?.isNewBest && New best} {lastRun?.xpEarned ? +{lastRun.xpEarned} XP : null} {lastRun?.error &&

{lastRun.error}

} {submitting &&

Recording result…

}
} {expired &&
Session Ending SoonYour session is about to end. The app will refresh when it expires.
}
Games{stats.gamesPlayed}
Messages{stats.totalMessages}
Best run{stats.bestScore}
XP from traffic{stats.radioTrafficXp}
); }