Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
513 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|