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>
This commit is contained in:
parent
a11d310e8e
commit
0d58b54977
3 changed files with 690 additions and 0 deletions
513
src/components/frontend/radio/RadioTraffic.tsx
Normal file
513
src/components/frontend/radio/RadioTraffic.tsx
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
55
src/components/frontend/radio/messageGenerator.ts
Normal file
55
src/components/frontend/radio/messageGenerator.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
type SlotName = "CALLSIGN" | "NUMBER" | "ACTION" | "LOCATION" | "STATUS" | "CLOSING";
|
||||
type MessageSlot = { readonly name: SlotName; readonly separator: string; readonly options: readonly string[] };
|
||||
|
||||
export const SPRINT_MESSAGE_COUNT = 10;
|
||||
const MESSAGE_SLOTS = [
|
||||
{
|
||||
name: "CALLSIGN",
|
||||
separator: "",
|
||||
options: ["ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHO", "FOXTROT", "VIKING", "SABER", "EAGLE", "RAVEN", "TIGER", "HAMMER", "WOLF", "FALCON", "ORION", "SENTINEL", "GHOST", "VIPER", "COBRA", "JAGUAR", "REAPER", "TITAN", "ATLAS", "COMET"],
|
||||
},
|
||||
{
|
||||
name: "NUMBER",
|
||||
separator: " ",
|
||||
options: ["ACTUAL", "ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN"],
|
||||
},
|
||||
{
|
||||
name: "ACTION",
|
||||
separator: ", ",
|
||||
options: ["EN ROUTE TO", "HOLD AT", "APPROACHING", "REQUESTING EVAC FROM", "MOVING TO", "REPORTING FROM", "CONTACT AT", "SECURE", "SWEEP AND CLEAR", "MOVE TO DEFEND", "REGROUP AT", "DIVERT TO", "REQUESTING FIRE MISSION AT", "PICKING UP AT", "DROPPING OFF AT", "AWAITING ORDERS AT", "PROCEED TO", "FALL BACK TO", "CONVERGE ON", "SCREENING AT"],
|
||||
},
|
||||
{
|
||||
name: "LOCATION",
|
||||
separator: " ",
|
||||
options: ["GRID 341-512", "GRID BRAVO ONE SIX", "SECTOR K-7", "CHECKPOINT ALPHA", "CHECKPOINT CHARLIE", "OVERWATCH POINT", "NORTH RIDGELINE", "OBJECTIVE DELTA", "SUPPLY ROAD", "EASTERN APPROACH", "SOUTH TRENCHLINE", "SITE 19", "FOB LIMA", "EGRESS", "WATCHPOST", "MAIN GATE", "HELI PAD", "BRIDGE CROSSING", "WESTERN VALLEY", "HILL 402", "RIVERBEND", "OLD MINE ROAD", "STAGING AREA", "PERIMETER LIMA"],
|
||||
},
|
||||
{
|
||||
name: "STATUS",
|
||||
separator: ", ",
|
||||
options: ["STATUS GREEN", "STATUS AMBER", "WEAPONS FREE", "UNDER CONTACT", "NEED MEDICAL", "ALL CLEAR", "FUEL STATE GREEN", "PACKAGE SECURE", "WE HAVE A MAN DOWN", "SITUATION CRITICAL", "LOST VISUAL", "CONTACT LOST", "RADAR CONTACTS DETECTED", "HOSTILES INBOUND", "AMMO STATE LOW", "RUNNING LOW ON FUEL", "NEED REINFORCEMENTS", "HOLDING POSITION", "ENEMY ACTIVITY INCREASED", "MEDICAL EXTRACTION READY", "PERIMETER SECURE", "NO MOVEMENT DETECTED"],
|
||||
},
|
||||
{ name: "CLOSING", separator: ", ", options: ["OVER", "DO YOU COPY? OVER", "HOW COPY? OVER", "REQUESTING READBACK, OVER", "OUT", "STAND BY, OVER", "AWAITING YOUR CALL, OVER", "READY WHEN YOU ARE, OVER", "SEND IT WHEN READY, OVER", "WILL WAIT FOR YOUR WORD, OVER", "STANDING BY FOR ORDERS, OVER"] },
|
||||
] as const satisfies readonly MessageSlot[];
|
||||
|
||||
function composeMessage(): string {
|
||||
return `${MESSAGE_SLOTS.map((slot) => `${slot.separator}${slot.options[Math.floor(Math.random() * slot.options.length)] ?? ""}`).join("")}.`;
|
||||
}
|
||||
|
||||
export function nextRadioMessage(used: ReadonlySet<string>): string {
|
||||
let message = composeMessage();
|
||||
while (used.has(message)) message = composeMessage();
|
||||
return message;
|
||||
}
|
||||
|
||||
export function createRadioRound(
|
||||
previousMessage: string | null = null,
|
||||
count: number = SPRINT_MESSAGE_COUNT,
|
||||
): readonly string[] {
|
||||
const messages = new Set<string>();
|
||||
while (messages.size < count) {
|
||||
const message = nextRadioMessage(messages);
|
||||
if (message === previousMessage && messages.size < count - 1) continue;
|
||||
messages.add(message);
|
||||
}
|
||||
return [...messages];
|
||||
}
|
||||
122
src/components/frontend/radio/sounds.ts
Normal file
122
src/components/frontend/radio/sounds.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const AudioCtor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtor) return null;
|
||||
if (!audioCtx) audioCtx = new AudioCtor();
|
||||
if (audioCtx.state === "suspended") {
|
||||
void audioCtx.resume();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
type ToneOptions = {
|
||||
readonly frequency: number;
|
||||
readonly endFrequency?: number;
|
||||
readonly duration: number;
|
||||
readonly type?: OscillatorType;
|
||||
readonly volume?: number;
|
||||
readonly delay?: number;
|
||||
};
|
||||
|
||||
function playTone(ctx: AudioContext, opts: ToneOptions) {
|
||||
const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = opts;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const t0 = ctx.currentTime + delay;
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(Math.max(frequency, 1), t0);
|
||||
if (endFrequency && endFrequency !== frequency) {
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(endFrequency, 1), t0 + duration);
|
||||
}
|
||||
gain.gain.setValueAtTime(0.0001, t0);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + duration + 0.05);
|
||||
}
|
||||
|
||||
function playNoise(
|
||||
ctx: AudioContext,
|
||||
opts: { readonly duration: number; readonly volume?: number; readonly delay?: number; readonly lowpass?: number },
|
||||
) {
|
||||
const { duration, volume = 0.2, delay = 0, lowpass = 800 } = opts;
|
||||
const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration));
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i += 1) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(lowpass, ctx.currentTime + delay);
|
||||
const gain = ctx.createGain();
|
||||
const t0 = ctx.currentTime + delay;
|
||||
gain.gain.setValueAtTime(0.0001, t0);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
source.start(t0);
|
||||
}
|
||||
|
||||
export function playKeystroke() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 520, duration: 0.025, type: "square", volume: 0.025 });
|
||||
}
|
||||
|
||||
export function playCorrect() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 620, endFrequency: 920, duration: 0.08, type: "sine", volume: 0.12 });
|
||||
}
|
||||
|
||||
export function playError() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 150, endFrequency: 80, duration: 0.16, type: "square", volume: 0.14 });
|
||||
playNoise(ctx, { duration: 0.1, volume: 0.08, lowpass: 500 });
|
||||
}
|
||||
|
||||
export function playRoundComplete() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 523, duration: 0.1, type: "sine", volume: 0.16 });
|
||||
playTone(ctx, { frequency: 659, duration: 0.1, type: "sine", volume: 0.16, delay: 0.1 });
|
||||
playTone(ctx, { frequency: 784, duration: 0.18, type: "sine", volume: 0.18, delay: 0.2 });
|
||||
}
|
||||
|
||||
export function playXp() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 });
|
||||
playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 });
|
||||
}
|
||||
|
||||
export function playCountdownTick(intensity: "normal" | "fast" | "critical") {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
if (intensity === "normal") {
|
||||
playTone(ctx, { frequency: 880, duration: 0.03, type: "square", volume: 0.05 });
|
||||
} else if (intensity === "fast") {
|
||||
playTone(ctx, { frequency: 1046, duration: 0.03, type: "square", volume: 0.07 });
|
||||
} else {
|
||||
playTone(ctx, { frequency: 1318, duration: 0.045, type: "square", volume: 0.11 });
|
||||
}
|
||||
}
|
||||
|
||||
export function playCountdownEnd() {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 1318, endFrequency: 240, duration: 0.75, type: "sine", volume: 0.16 });
|
||||
playTone(ctx, { frequency: 659, endFrequency: 120, duration: 0.75, type: "triangle", volume: 0.1 });
|
||||
}
|
||||
Loading…
Reference in a new issue