diff --git a/src/components/frontend/radio/RadioArena.tsx b/src/components/frontend/radio/RadioArena.tsx
index a08b99f..5ab1392 100644
--- a/src/components/frontend/radio/RadioArena.tsx
+++ b/src/components/frontend/radio/RadioArena.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import type { RadioStats } from "@/app/(frontend)/radio/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { RadioLeaderboard } from "./RadioLeaderboard";
@@ -15,15 +15,29 @@ export function RadioArena({
}) {
const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false);
+ const [mode, setMode] = useState<"sprint" | "watch">("sprint");
+ const [modeHydrated, setModeHydrated] = useState(false);
const activeOperators = useRealtimePresence("radio", playing, true);
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
setPlaying(phase === "playing");
}, []);
+ useEffect(() => {
+ const savedMode = window.localStorage.getItem("radio-traffic-mode");
+ if (savedMode === "sprint" || savedMode === "watch") setMode(savedMode);
+ setModeHydrated(true);
+ }, []);
+
+ useEffect(() => {
+ if (modeHydrated) window.localStorage.setItem("radio-traffic-mode", mode);
+ }, [mode, modeHydrated]);
+
return (
setRefreshKey((key) => key + 1)}
onPhaseChangeAction={onPhaseChange}
/>
@@ -47,6 +61,8 @@ export function RadioArena({
diff --git a/src/components/frontend/radio/RadioLeaderboard.tsx b/src/components/frontend/radio/RadioLeaderboard.tsx
index 5426a75..6ca4ea9 100644
--- a/src/components/frontend/radio/RadioLeaderboard.tsx
+++ b/src/components/frontend/radio/RadioLeaderboard.tsx
@@ -1,12 +1,17 @@
"use client";
import { useEffect, useState } from "react";
-import { Trophy } from "lucide-react";
-import { Button } from "@/components/ui/button";
+import { ArrowDown, ArrowUp, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
+type Mode = "sprint" | "watch";
+
+function isLeaderboardTab(value: string): value is LeaderboardTab {
+ return value === "score" || value === "accuracy" || value === "messages" || value === "time";
+}
interface ApiUser {
id: number;
@@ -22,9 +27,12 @@ interface ApiProfile {
progression?: {
minigames?: {
radioTraffic?: {
- totalMessages?: number | null;
- bestScore?: number | null;
- bestAccuracy?: number | null;
+ sprintTotalMessages?: number | null;
+ sprintBestScore?: number | null;
+ sprintBestAccuracy?: number | null;
+ watchTotalMessages?: number | null;
+ watchBestScore?: number | null;
+ watchBestAccuracy?: number | null;
bestSprintTimeMs?: number | null;
} | null;
} | null;
@@ -42,46 +50,20 @@ interface LeaderboardRow {
isOutsideTopTen?: boolean;
}
-interface TabConfig {
- readonly label: string;
- readonly sort: string;
- readonly matches: (row: LeaderboardRow) => boolean;
- readonly empty: string;
-}
-
interface RadioLeaderboardProps {
refreshKey?: number;
+ mode: Mode;
+ modeHydrated: boolean;
currentUserId?: number | string;
activeOperators?: PresenceParticipant[];
}
-const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[];
const TAB_CONFIG = {
- score: {
- label: "Top Score",
- sort: "-progression.minigames.radioTraffic.bestScore",
- matches: (row) => row.bestScore > 0,
- empty: "No scored transmissions yet. Take the first slot!",
- },
- accuracy: {
- label: "Top Accuracy",
- sort: "-progression.minigames.radioTraffic.bestAccuracy",
- matches: (row) => row.bestAccuracy > 0,
- empty: "No accuracy records yet. Relay a perfect transmission!",
- },
- messages: {
- label: "Most Messages",
- sort: "-progression.minigames.radioTraffic.totalMessages",
- matches: (row) => row.totalMessages > 0,
- empty: "No accurate messages yet. Get on frequency!",
- },
- time: {
- label: "Fastest Sprint",
- sort: "progression.minigames.radioTraffic.bestSprintTimeMs",
- matches: (row) => row.bestSprintTimeMs > 0,
- empty: "No sprint times yet. Complete a 10-message sprint!",
- },
-} satisfies Record;
+ score: { label: "Top Score", empty: "No scored transmissions yet. Take the first slot!" },
+ accuracy: { label: "Top Accuracy", empty: "No accuracy records yet. Relay a perfect transmission!" },
+ messages: { label: "Most Correct", empty: "No correct transmissions yet. Get on frequency!" },
+ time: { label: "Fastest Sprint", empty: "No sprint times yet. Complete a 10-message sprint!" },
+} as const;
function rankClass(index: number) {
if (index === 0) return "font-bold text-amber-400";
@@ -92,6 +74,8 @@ function rankClass(index: number) {
export function RadioLeaderboard({
refreshKey = 0,
+ mode,
+ modeHydrated,
currentUserId,
activeOperators = [],
}: RadioLeaderboardProps) {
@@ -99,13 +83,45 @@ export function RadioLeaderboard({
const [rows, setRows] = useState(null);
const [failed, setFailed] = useState(false);
const tabConfig = TAB_CONFIG[tab];
+ const tabOrder: readonly LeaderboardTab[] =
+ mode === "sprint"
+ ? ["score", "accuracy", "messages", "time"]
+ : ["score", "accuracy", "messages"];
+ const sortDirection = tab === "time" ? "ascending" : "descending";
+ const SortMarker = ({ column }: { column: LeaderboardTab }) => {
+ if (tab !== column) return null;
+
+ const Icon = sortDirection === "ascending" ? ArrowUp : ArrowDown;
+ return (
+
+ );
+ };
useEffect(() => {
+ if (mode === "watch" && tab === "time") setTab("score");
+ }, [mode, tab]);
+
+ useEffect(() => {
+ if (!modeHydrated || (mode === "watch" && tab === "time")) return;
let cancelled = false;
setFailed(false);
setRows(null);
- const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" });
+ const field =
+ tab === "time"
+ ? "bestSprintTimeMs"
+ : mode === "sprint"
+ ? `sprint${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`
+ : `watch${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`;
+ const params = new URLSearchParams({
+ sort: `${tab === "time" ? "" : "-"}progression.minigames.radioTraffic.${field}`,
+ limit: "100",
+ depth: "1",
+ });
fetch(`/api/profiles?${params.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("leaderboard fetch failed");
@@ -119,14 +135,26 @@ export function RadioLeaderboard({
return {
id: doc.id,
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
- totalMessages: radioTraffic.totalMessages ?? 0,
- bestScore: radioTraffic.bestScore ?? 0,
- bestAccuracy: radioTraffic.bestAccuracy ?? 0,
+ totalMessages:
+ radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
+ bestScore: radioTraffic[mode === "sprint" ? "sprintBestScore" : "watchBestScore"] ?? 0,
+ bestAccuracy:
+ radioTraffic[mode === "sprint" ? "sprintBestAccuracy" : "watchBestAccuracy"] ?? 0,
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
};
});
- const visible = mapped.filter(tabConfig.matches).slice(0, 10);
+ const visible = mapped
+ .filter((row) =>
+ (tab === "score"
+ ? row.bestScore
+ : tab === "accuracy"
+ ? row.bestAccuracy
+ : tab === "messages"
+ ? row.totalMessages
+ : row.bestSprintTimeMs) > 0,
+ )
+ .slice(0, 10);
const current = mapped.find((row) => row.isCurrentUser);
if (current && !visible.some((row) => row.id === current.id)) {
visible.push({ ...current, isOutsideTopTen: true });
@@ -140,36 +168,36 @@ export function RadioLeaderboard({
return () => {
cancelled = true;
};
- }, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]);
+ }, [currentUserId, mode, modeHydrated, refreshKey, tab]);
return (
-
Radio Traffic Leaderboard
+
+ Radio Traffic {mode === "sprint" ? "Sprint" : "Watch"} Leaderboard
+
Top 10
-
{
+ if (isLeaderboardTab(value)) setTab(value);
+ }}
>
- {TAB_ORDER.map((value) => (
- setTab(value)}
- >
- {TAB_CONFIG[value].label}
-
- ))}
-
+
+ {tabOrder.map((value) => (
+
+ {TAB_CONFIG[value].label}
+
+ ))}
+
+
@@ -194,15 +222,53 @@ export function RadioLeaderboard({
{tabConfig.empty}
) : (
-
+
#
Operator
- Score
- Accuracy
- Time
- Messages
+
+ Score
+
+
+
+ Accuracy
+
+
+ {mode === "sprint" && (
+
+ Time
+
+
+ )}
+
+ Correct
+
+
@@ -248,9 +314,11 @@ export function RadioLeaderboard({
{(row.bestAccuracy * 100).toFixed(2)}%
-
- {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
-
+ {mode === "sprint" && (
+
+ {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
+
+ )}
{row.totalMessages}
diff --git a/src/components/frontend/radio/RadioTraffic.tsx b/src/components/frontend/radio/RadioTraffic.tsx
index 5c264cf..63854fe 100644
--- a/src/components/frontend/radio/RadioTraffic.tsx
+++ b/src/components/frontend/radio/RadioTraffic.tsx
@@ -27,12 +27,18 @@ type MessageResult = {
readonly accuracy: number;
readonly correct: boolean;
};
+type ReferenceCharacter = { readonly character: string; readonly matched: boolean };
+type IncorrectSprintSubmission = {
+ readonly target: string;
+ readonly characters: readonly ReferenceCharacter[];
+};
type Feedback = { readonly correct: boolean; readonly message: string };
type LastRun = {
readonly mode: Mode;
readonly score: number;
readonly accuracy: number;
readonly correctMessages: number;
+ readonly incorrectMessages: number;
readonly relayedMessages: number;
readonly xpEarned: number;
readonly best: number;
@@ -44,13 +50,9 @@ 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 UNSUCCESSFUL_SUBMIT_COOLDOWN_MS = 750;
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);
@@ -64,6 +66,61 @@ function formatAccuracy(accuracy: number): string {
return `${Math.round(accuracy * 100)}%`;
}
+export function alignReferenceCharacters(
+ target: string,
+ submitted: string,
+): readonly ReferenceCharacter[] {
+ const targetCharacters = Array.from(target);
+ const submittedCharacters = Array.from(submitted);
+ const lcsLengths = Array.from(
+ { length: targetCharacters.length + 1 },
+ () => new Array(submittedCharacters.length + 1).fill(0),
+ );
+
+ for (let targetIndex = targetCharacters.length - 1; targetIndex >= 0; targetIndex -= 1) {
+ for (
+ let submittedIndex = submittedCharacters.length - 1;
+ submittedIndex >= 0;
+ submittedIndex -= 1
+ ) {
+ const charactersMatch = targetCharacters[targetIndex] === submittedCharacters[submittedIndex];
+ lcsLengths[targetIndex][submittedIndex] =
+ charactersMatch
+ ? lcsLengths[targetIndex + 1][submittedIndex + 1] + 1
+ : Math.max(
+ lcsLengths[targetIndex + 1][submittedIndex],
+ lcsLengths[targetIndex][submittedIndex + 1],
+ );
+ }
+ }
+
+ const matchedTargetCharacters = new Array(targetCharacters.length).fill(false);
+ let targetIndex = 0;
+ let submittedIndex = 0;
+ let hasPendingInsertion = false;
+ while (targetIndex < targetCharacters.length && submittedIndex < submittedCharacters.length) {
+ if (targetCharacters[targetIndex] === submittedCharacters[submittedIndex]) {
+ matchedTargetCharacters[targetIndex] = !hasPendingInsertion;
+ hasPendingInsertion = false;
+ targetIndex += 1;
+ submittedIndex += 1;
+ } else if (
+ lcsLengths[targetIndex + 1][submittedIndex] > lcsLengths[targetIndex][submittedIndex + 1]
+ ) {
+ hasPendingInsertion = false;
+ targetIndex += 1;
+ } else {
+ hasPendingInsertion = true;
+ submittedIndex += 1;
+ }
+ }
+
+ return targetCharacters.map((character, index) => ({
+ character,
+ matched: matchedTargetCharacters[index],
+ }));
+}
+
function roundAccuracy(results: readonly MessageResult[]): number {
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;
@@ -80,10 +137,14 @@ function Metric({ label, value }: { label: string; value: string | number }) {
export function RadioTraffic({
initialStats: serverStats,
+ mode,
+ onModeChangeAction,
onGameRecordedAction,
onPhaseChangeAction,
}: {
initialStats: RadioStats;
+ mode: Mode;
+ onModeChangeAction: (mode: Mode) => void;
onGameRecordedAction: () => void;
onPhaseChangeAction: (phase: Phase) => void;
}) {
@@ -104,9 +165,9 @@ export function RadioTraffic({
const lastCountdownTickRef = useRef("");
const countdownEndPlayedRef = useRef(false);
const tickSoundRef = useRef(true);
+ const submitBlockedUntilRef = useRef(0);
+ const incorrectMessagesRef = useRef(0);
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);
@@ -121,6 +182,8 @@ export function RadioTraffic({
const [stats, setStats] = useState(serverStats);
const [lastRun, setLastRun] = useState(null);
const [feedback, setFeedback] = useState(null);
+ const [incorrectSprintSubmission, setIncorrectSprintSubmission] =
+ useState(null);
const [submitting, setSubmitting] = useState(false);
const setPhaseSafe = useCallback(
@@ -133,20 +196,13 @@ export function RadioTraffic({
);
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);
@@ -155,8 +211,17 @@ export function RadioTraffic({
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
+ useEffect(() => {
+ setIncorrectSprintSubmission(null);
+ }, [mode]);
+
const recordResult = useCallback(
- async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => {
+ async (
+ finalResults: readonly MessageResult[],
+ resultMode: Mode,
+ elapsedMs: number | undefined,
+ incorrectMessages: number,
+ ) => {
if (submittingRef.current) return;
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
const correctMessages = finalResults.filter((result) => result.correct).length;
@@ -167,9 +232,13 @@ export function RadioTraffic({
score,
accuracy,
correctMessages,
+ incorrectMessages,
relayedMessages: finalResults.length,
xpEarned: 0,
- best: statsRef.current.bestScore,
+ best:
+ resultMode === "sprint"
+ ? statsRef.current.sprintBestScore
+ : statsRef.current.watchBestScore,
isNewBest: false,
error: "Your session expired. Result not recorded.",
});
@@ -182,6 +251,7 @@ export function RadioTraffic({
score,
messageCount: finalResults.length,
correctMessages,
+ incorrectMessages,
accuracy,
mode: resultMode,
timeMs: elapsedMs,
@@ -190,7 +260,8 @@ export function RadioTraffic({
setSubmitting(false);
if (result.success && result.data) {
- const previousBest = statsRef.current.bestScore;
+ const previousBest =
+ resultMode === "sprint" ? statsRef.current.sprintBestScore : statsRef.current.watchBestScore;
statsRef.current = result.data;
setStats(result.data);
onGameRecordedAction();
@@ -208,9 +279,11 @@ export function RadioTraffic({
score,
accuracy,
correctMessages,
+ incorrectMessages,
relayedMessages: finalResults.length,
xpEarned: result.data.xpEarned,
- best: result.data.bestScore,
+ best:
+ resultMode === "sprint" ? result.data.sprintBestScore : result.data.watchBestScore,
isNewBest: score > previousBest,
});
} else {
@@ -219,9 +292,13 @@ export function RadioTraffic({
score,
accuracy,
correctMessages,
+ incorrectMessages,
relayedMessages: finalResults.length,
xpEarned: 0,
- best: statsRef.current.bestScore,
+ best:
+ resultMode === "sprint"
+ ? statsRef.current.sprintBestScore
+ : statsRef.current.watchBestScore,
isNewBest: false,
error: result.error ?? "Could not record radio traffic.",
});
@@ -231,7 +308,11 @@ export function RadioTraffic({
);
const finishRound = useCallback(
- (finalResults: readonly MessageResult[], resultMode: Mode) => {
+ (
+ finalResults: readonly MessageResult[],
+ resultMode: Mode,
+ incorrectMessages = incorrectMessagesRef.current,
+ ) => {
if (phaseRef.current !== "playing") return;
const lastResult = finalResults[finalResults.length - 1];
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
@@ -243,15 +324,16 @@ export function RadioTraffic({
score: 0,
accuracy: 0,
correctMessages: 0,
+ incorrectMessages,
relayedMessages: 0,
xpEarned: 0,
- best: statsRef.current.bestScore,
+ best: statsRef.current.watchBestScore,
isNewBest: false,
});
return;
}
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
- void recordResult(finalResults, resultMode, elapsedMs);
+ void recordResult(finalResults, resultMode, elapsedMs, incorrectMessages);
},
[recordResult, setPhaseSafe],
);
@@ -322,6 +404,7 @@ export function RadioTraffic({
setInput("");
setResults([]);
keystrokesRef.current = { correct: 0, incorrect: 0 };
+ incorrectMessagesRef.current = 0;
setElapsedMs(0);
const isWatch = mode === "watch";
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
@@ -331,6 +414,7 @@ export function RadioTraffic({
setRemainingMs(nextRemaining);
setLastRun(null);
setFeedback(null);
+ setIncorrectSprintSubmission(null);
messageStartedAtRef.current = now;
setPhaseSafe("playing");
window.setTimeout(() => inputRef.current?.focus(), 0);
@@ -360,9 +444,10 @@ export function RadioTraffic({
(event: FormEvent) => {
event.preventDefault();
if (phaseRef.current !== "playing" || submittingRef.current) return;
+ const now = performance.now();
+ if (now < submitBlockedUntilRef.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;
@@ -375,18 +460,34 @@ export function RadioTraffic({
accuracy,
correct: input === target,
};
- keystrokesRef.current = { correct: 0, incorrect: 0 };
- lastCountdownTickRef.current = "";
+ if (!result.correct) {
+ incorrectMessagesRef.current += 1;
+ keystrokesRef.current = { correct: 0, incorrect: 0 };
+ lastCountdownTickRef.current = "";
+ if (mode === "sprint") {
+ setIncorrectSprintSubmission({
+ target,
+ characters: alignReferenceCharacters(target, input),
+ });
+ }
+ setInput("");
+ playError();
+ setFeedback({ correct: false, message: "Signal mismatch. Try the transmission again." });
+ submitBlockedUntilRef.current = now + UNSUCCESSFUL_SUBMIT_COOLDOWN_MS;
+ if (mode === "watch" && now >= roundDeadlineRef.current) {
+ finishRound(results, mode, incorrectMessagesRef.current);
+ }
+ return;
+ }
+
+ keystrokesRef.current = { correct: 0, incorrect: 0 };
+ lastCountdownTickRef.current = "";
const finalResults = [...results, result];
+ setIncorrectSprintSubmission(null);
setResults(finalResults);
setInput("");
- if (result.correct) {
- playCorrect();
- setFeedback({ correct: true, message: "Transmission accepted." });
- } else {
- playError();
- setFeedback({ correct: false, message: "Signal mismatch. Next transmission incoming." });
- }
+ playCorrect();
+ setFeedback({ correct: true, message: "Transmission accepted." });
const watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
@@ -420,12 +521,17 @@ export function RadioTraffic({
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
const target = messages[messageIndex] ?? "";
+ const sprintReferenceDiff =
+ mode === "sprint" && incorrectSprintSubmission?.target === target
+ ? incorrectSprintSubmission.characters
+ : null;
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 displayCorrect = lastRun?.correctMessages ?? results.length;
+ const displayIncorrect = lastRun?.incorrectMessages ?? incorrectMessagesRef.current;
const displayRelayed = lastRun?.relayedMessages ?? results.length;
const progress =
displayMode === "sprint"
@@ -469,10 +575,10 @@ export function RadioTraffic({
{(phase === "ready" || phase === "over") && (
- setMode("sprint")}>
+ onModeChangeAction("sprint")}>
Sprint · 10 messages
- setMode("watch")}>
+ onModeChangeAction("watch")}>
Watch · 60 seconds
@@ -485,7 +591,30 @@ export function RadioTraffic({
}
{phase === "playing" &&
-
Incoming transmission
{target}
+
+
Incoming transmission
+
+ {sprintReferenceDiff
+ ? sprintReferenceDiff.map(({ character, matched }, index) => (
+
+ {character}
+
+ ))
+ : target}
+
+ {sprintReferenceDiff && (
+
+ Reference text is unchanged. Green characters match your last submission; red characters
+ differ. Typed-only characters are not shown.
+
+ )}
+
);
}