feat(radio): improve mode gameplay and leaderboards
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
0b012f07d8
commit
9baf84ee32
3 changed files with 324 additions and 111 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { RadioStats } from "@/app/(frontend)/radio/actions";
|
import type { RadioStats } from "@/app/(frontend)/radio/actions";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { RadioLeaderboard } from "./RadioLeaderboard";
|
import { RadioLeaderboard } from "./RadioLeaderboard";
|
||||||
|
|
@ -15,15 +15,29 @@ export function RadioArena({
|
||||||
}) {
|
}) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"sprint" | "watch">("sprint");
|
||||||
|
const [modeHydrated, setModeHydrated] = useState(false);
|
||||||
const activeOperators = useRealtimePresence("radio", playing, true);
|
const activeOperators = useRealtimePresence("radio", playing, true);
|
||||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
setPlaying(phase === "playing");
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<RadioTraffic
|
<RadioTraffic
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
|
mode={mode}
|
||||||
|
onModeChangeAction={setMode}
|
||||||
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
||||||
onPhaseChangeAction={onPhaseChange}
|
onPhaseChangeAction={onPhaseChange}
|
||||||
/>
|
/>
|
||||||
|
|
@ -47,6 +61,8 @@ export function RadioArena({
|
||||||
</div>
|
</div>
|
||||||
<RadioLeaderboard
|
<RadioLeaderboard
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
mode={mode}
|
||||||
|
modeHydrated={modeHydrated}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
activeOperators={activeOperators}
|
activeOperators={activeOperators}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trophy } from "lucide-react";
|
import { ArrowDown, ArrowUp, Trophy } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
|
||||||
type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
|
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 {
|
interface ApiUser {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -22,9 +27,12 @@ interface ApiProfile {
|
||||||
progression?: {
|
progression?: {
|
||||||
minigames?: {
|
minigames?: {
|
||||||
radioTraffic?: {
|
radioTraffic?: {
|
||||||
totalMessages?: number | null;
|
sprintTotalMessages?: number | null;
|
||||||
bestScore?: number | null;
|
sprintBestScore?: number | null;
|
||||||
bestAccuracy?: number | null;
|
sprintBestAccuracy?: number | null;
|
||||||
|
watchTotalMessages?: number | null;
|
||||||
|
watchBestScore?: number | null;
|
||||||
|
watchBestAccuracy?: number | null;
|
||||||
bestSprintTimeMs?: number | null;
|
bestSprintTimeMs?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
@ -42,46 +50,20 @@ interface LeaderboardRow {
|
||||||
isOutsideTopTen?: boolean;
|
isOutsideTopTen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabConfig {
|
|
||||||
readonly label: string;
|
|
||||||
readonly sort: string;
|
|
||||||
readonly matches: (row: LeaderboardRow) => boolean;
|
|
||||||
readonly empty: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadioLeaderboardProps {
|
interface RadioLeaderboardProps {
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
|
mode: Mode;
|
||||||
|
modeHydrated: boolean;
|
||||||
currentUserId?: number | string;
|
currentUserId?: number | string;
|
||||||
activeOperators?: PresenceParticipant[];
|
activeOperators?: PresenceParticipant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[];
|
|
||||||
const TAB_CONFIG = {
|
const TAB_CONFIG = {
|
||||||
score: {
|
score: { label: "Top Score", empty: "No scored transmissions yet. Take the first slot!" },
|
||||||
label: "Top Score",
|
accuracy: { label: "Top Accuracy", empty: "No accuracy records yet. Relay a perfect transmission!" },
|
||||||
sort: "-progression.minigames.radioTraffic.bestScore",
|
messages: { label: "Most Correct", empty: "No correct transmissions yet. Get on frequency!" },
|
||||||
matches: (row) => row.bestScore > 0,
|
time: { label: "Fastest Sprint", empty: "No sprint times yet. Complete a 10-message sprint!" },
|
||||||
empty: "No scored transmissions yet. Take the first slot!",
|
} as const;
|
||||||
},
|
|
||||||
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<LeaderboardTab, TabConfig>;
|
|
||||||
|
|
||||||
function rankClass(index: number) {
|
function rankClass(index: number) {
|
||||||
if (index === 0) return "font-bold text-amber-400";
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
|
@ -92,6 +74,8 @@ function rankClass(index: number) {
|
||||||
|
|
||||||
export function RadioLeaderboard({
|
export function RadioLeaderboard({
|
||||||
refreshKey = 0,
|
refreshKey = 0,
|
||||||
|
mode,
|
||||||
|
modeHydrated,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
activeOperators = [],
|
activeOperators = [],
|
||||||
}: RadioLeaderboardProps) {
|
}: RadioLeaderboardProps) {
|
||||||
|
|
@ -99,13 +83,45 @@ export function RadioLeaderboard({
|
||||||
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const tabConfig = TAB_CONFIG[tab];
|
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 (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-1 inline-block size-3 text-primary"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode === "watch" && tab === "time") setTab("score");
|
||||||
|
}, [mode, tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modeHydrated || (mode === "watch" && tab === "time")) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
setRows(null);
|
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()}`)
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error("leaderboard fetch failed");
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
|
@ -119,14 +135,26 @@ export function RadioLeaderboard({
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
|
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
|
||||||
totalMessages: radioTraffic.totalMessages ?? 0,
|
totalMessages:
|
||||||
bestScore: radioTraffic.bestScore ?? 0,
|
radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
|
||||||
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
|
bestScore: radioTraffic[mode === "sprint" ? "sprintBestScore" : "watchBestScore"] ?? 0,
|
||||||
|
bestAccuracy:
|
||||||
|
radioTraffic[mode === "sprint" ? "sprintBestAccuracy" : "watchBestAccuracy"] ?? 0,
|
||||||
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
||||||
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
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);
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
if (current && !visible.some((row) => row.id === current.id)) {
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
visible.push({ ...current, isOutsideTopTen: true });
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
|
@ -140,36 +168,36 @@ export function RadioLeaderboard({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]);
|
}, [currentUserId, mode, modeHydrated, refreshKey, tab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
<div className="border-b border-border px-4 py-3">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="size-4 text-amber-400" />
|
<Trophy className="size-4 text-amber-400" />
|
||||||
<h2 className="text-sm font-semibold">Radio Traffic Leaderboard</h2>
|
<h2 className="text-sm font-semibold">
|
||||||
|
Radio Traffic {mode === "sprint" ? "Sprint" : "Watch"} Leaderboard
|
||||||
|
</h2>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Top 10</span>
|
<span className="ml-auto text-xs text-muted-foreground">Top 10</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Tabs
|
||||||
className="mt-3 grid grid-cols-4 gap-1 rounded-lg bg-muted p-1"
|
value={tab}
|
||||||
role="tablist"
|
onValueChange={(value) => {
|
||||||
aria-label="Radio traffic leaderboard"
|
if (isLeaderboardTab(value)) setTab(value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{TAB_ORDER.map((value) => (
|
<TabsList
|
||||||
<Button
|
id="radio-traffic-leaderboard-tabs"
|
||||||
key={value}
|
aria-label="Radio traffic leaderboard"
|
||||||
type="button"
|
className={`mt-3 grid w-full ${mode === "sprint" ? "grid-cols-4" : "grid-cols-3"}`}
|
||||||
role="tab"
|
>
|
||||||
aria-selected={tab === value}
|
{tabOrder.map((value) => (
|
||||||
variant={tab === value ? "secondary" : "ghost"}
|
<TabsTrigger key={value} value={value} className="min-w-0 px-2 text-xs">
|
||||||
size="sm"
|
{TAB_CONFIG[value].label}
|
||||||
className="px-2 text-xs"
|
</TabsTrigger>
|
||||||
onClick={() => setTab(value)}
|
))}
|
||||||
>
|
</TabsList>
|
||||||
{TAB_CONFIG[value].label}
|
</Tabs>
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
|
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
|
||||||
<span className="font-medium text-emerald-400">
|
<span className="font-medium text-emerald-400">
|
||||||
|
|
@ -194,15 +222,53 @@ export function RadioLeaderboard({
|
||||||
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
|
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table id="radio-traffic-leaderboard-table" className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="px-4 py-2 font-medium">#</th>
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
<th className="px-2 py-2 font-medium">Operator</th>
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
<th className="px-2 py-2 text-right font-medium">Score</th>
|
<th
|
||||||
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
|
aria-sort={tab === "score" ? sortDirection : "none"}
|
||||||
<th className="px-2 py-2 text-right font-medium">Time</th>
|
className={cn(
|
||||||
<th className="px-4 py-2 text-right font-medium">Messages</th>
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "score" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Score
|
||||||
|
<SortMarker column="score" />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "accuracy" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "accuracy" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Accuracy
|
||||||
|
<SortMarker column="accuracy" />
|
||||||
|
</th>
|
||||||
|
{mode === "sprint" && (
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "time" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "time" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
<SortMarker column="time" />
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "messages" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-right font-medium",
|
||||||
|
tab === "messages" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Correct
|
||||||
|
<SortMarker column="messages" />
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|
@ -248,9 +314,11 @@ export function RadioLeaderboard({
|
||||||
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
{(row.bestAccuracy * 100).toFixed(2)}%
|
{(row.bestAccuracy * 100).toFixed(2)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-right tabular-nums">
|
{mode === "sprint" && (
|
||||||
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
<td className="px-2 py-2 text-right tabular-nums">
|
||||||
</td>
|
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
{row.totalMessages}
|
{row.totalMessages}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,18 @@ type MessageResult = {
|
||||||
readonly accuracy: number;
|
readonly accuracy: number;
|
||||||
readonly correct: boolean;
|
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 Feedback = { readonly correct: boolean; readonly message: string };
|
||||||
type LastRun = {
|
type LastRun = {
|
||||||
readonly mode: Mode;
|
readonly mode: Mode;
|
||||||
readonly score: number;
|
readonly score: number;
|
||||||
readonly accuracy: number;
|
readonly accuracy: number;
|
||||||
readonly correctMessages: number;
|
readonly correctMessages: number;
|
||||||
|
readonly incorrectMessages: number;
|
||||||
readonly relayedMessages: number;
|
readonly relayedMessages: number;
|
||||||
readonly xpEarned: number;
|
readonly xpEarned: number;
|
||||||
readonly best: number;
|
readonly best: number;
|
||||||
|
|
@ -44,13 +50,9 @@ const WATCH_DURATION_MS = 60_000;
|
||||||
const WATCH_MAX_MESSAGES = 30;
|
const WATCH_MAX_MESSAGES = 30;
|
||||||
const FAST_TICK_THRESHOLD_MS = 10_000;
|
const FAST_TICK_THRESHOLD_MS = 10_000;
|
||||||
const CRITICAL_TICK_THRESHOLD_MS = 3_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";
|
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 {
|
function messageScore(accuracy: number, timeMs: number): number {
|
||||||
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
|
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
|
||||||
return Math.round(accuracy * 100 * speed);
|
return Math.round(accuracy * 100 * speed);
|
||||||
|
|
@ -64,6 +66,61 @@ function formatAccuracy(accuracy: number): string {
|
||||||
return `${Math.round(accuracy * 100)}%`;
|
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<number>(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<boolean>(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 {
|
function roundAccuracy(results: readonly MessageResult[]): number {
|
||||||
const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0);
|
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;
|
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({
|
export function RadioTraffic({
|
||||||
initialStats: serverStats,
|
initialStats: serverStats,
|
||||||
|
mode,
|
||||||
|
onModeChangeAction,
|
||||||
onGameRecordedAction,
|
onGameRecordedAction,
|
||||||
onPhaseChangeAction,
|
onPhaseChangeAction,
|
||||||
}: {
|
}: {
|
||||||
initialStats: RadioStats;
|
initialStats: RadioStats;
|
||||||
|
mode: Mode;
|
||||||
|
onModeChangeAction: (mode: Mode) => void;
|
||||||
onGameRecordedAction: () => void;
|
onGameRecordedAction: () => void;
|
||||||
onPhaseChangeAction: (phase: Phase) => void;
|
onPhaseChangeAction: (phase: Phase) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -104,9 +165,9 @@ export function RadioTraffic({
|
||||||
const lastCountdownTickRef = useRef("");
|
const lastCountdownTickRef = useRef("");
|
||||||
const countdownEndPlayedRef = useRef(false);
|
const countdownEndPlayedRef = useRef(false);
|
||||||
const tickSoundRef = useRef(true);
|
const tickSoundRef = useRef(true);
|
||||||
|
const submitBlockedUntilRef = useRef(0);
|
||||||
|
const incorrectMessagesRef = useRef(0);
|
||||||
const [phase, setPhase] = useState<Phase>("ready");
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
const [mode, setMode] = useState<Mode>("sprint");
|
|
||||||
const [modeHydrated, setModeHydrated] = useState(false);
|
|
||||||
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
||||||
const [messages, setMessages] = useState<readonly string[]>(() => {
|
const [messages, setMessages] = useState<readonly string[]>(() => {
|
||||||
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
||||||
|
|
@ -121,6 +182,8 @@ export function RadioTraffic({
|
||||||
const [stats, setStats] = useState<RadioStats>(serverStats);
|
const [stats, setStats] = useState<RadioStats>(serverStats);
|
||||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||||
|
const [incorrectSprintSubmission, setIncorrectSprintSubmission] =
|
||||||
|
useState<IncorrectSprintSubmission | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const setPhaseSafe = useCallback(
|
const setPhaseSafe = useCallback(
|
||||||
|
|
@ -133,20 +196,13 @@ export function RadioTraffic({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
|
|
||||||
if (isMode(savedMode)) setMode(savedMode);
|
|
||||||
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
|
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
|
||||||
if (savedTickSound === "false") {
|
if (savedTickSound === "false") {
|
||||||
tickSoundRef.current = false;
|
tickSoundRef.current = false;
|
||||||
setTickSoundEnabled(false);
|
setTickSoundEnabled(false);
|
||||||
}
|
}
|
||||||
setModeHydrated(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (modeHydrated) window.localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
||||||
}, [mode, modeHydrated]);
|
|
||||||
|
|
||||||
const handleTickSoundChange = useCallback((enabled: boolean) => {
|
const handleTickSoundChange = useCallback((enabled: boolean) => {
|
||||||
tickSoundRef.current = enabled;
|
tickSoundRef.current = enabled;
|
||||||
setTickSoundEnabled(enabled);
|
setTickSoundEnabled(enabled);
|
||||||
|
|
@ -155,8 +211,17 @@ export function RadioTraffic({
|
||||||
|
|
||||||
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
|
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const recordResult = useCallback(
|
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;
|
if (submittingRef.current) return;
|
||||||
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
|
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
|
||||||
const correctMessages = finalResults.filter((result) => result.correct).length;
|
const correctMessages = finalResults.filter((result) => result.correct).length;
|
||||||
|
|
@ -167,9 +232,13 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best:
|
||||||
|
resultMode === "sprint"
|
||||||
|
? statsRef.current.sprintBestScore
|
||||||
|
: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
error: "Your session expired. Result not recorded.",
|
error: "Your session expired. Result not recorded.",
|
||||||
});
|
});
|
||||||
|
|
@ -182,6 +251,7 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
messageCount: finalResults.length,
|
messageCount: finalResults.length,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
accuracy,
|
accuracy,
|
||||||
mode: resultMode,
|
mode: resultMode,
|
||||||
timeMs: elapsedMs,
|
timeMs: elapsedMs,
|
||||||
|
|
@ -190,7 +260,8 @@ export function RadioTraffic({
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const previousBest = statsRef.current.bestScore;
|
const previousBest =
|
||||||
|
resultMode === "sprint" ? statsRef.current.sprintBestScore : statsRef.current.watchBestScore;
|
||||||
statsRef.current = result.data;
|
statsRef.current = result.data;
|
||||||
setStats(result.data);
|
setStats(result.data);
|
||||||
onGameRecordedAction();
|
onGameRecordedAction();
|
||||||
|
|
@ -208,9 +279,11 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: result.data.xpEarned,
|
xpEarned: result.data.xpEarned,
|
||||||
best: result.data.bestScore,
|
best:
|
||||||
|
resultMode === "sprint" ? result.data.sprintBestScore : result.data.watchBestScore,
|
||||||
isNewBest: score > previousBest,
|
isNewBest: score > previousBest,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,9 +292,13 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best:
|
||||||
|
resultMode === "sprint"
|
||||||
|
? statsRef.current.sprintBestScore
|
||||||
|
: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
error: result.error ?? "Could not record radio traffic.",
|
error: result.error ?? "Could not record radio traffic.",
|
||||||
});
|
});
|
||||||
|
|
@ -231,7 +308,11 @@ export function RadioTraffic({
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishRound = useCallback(
|
const finishRound = useCallback(
|
||||||
(finalResults: readonly MessageResult[], resultMode: Mode) => {
|
(
|
||||||
|
finalResults: readonly MessageResult[],
|
||||||
|
resultMode: Mode,
|
||||||
|
incorrectMessages = incorrectMessagesRef.current,
|
||||||
|
) => {
|
||||||
if (phaseRef.current !== "playing") return;
|
if (phaseRef.current !== "playing") return;
|
||||||
const lastResult = finalResults[finalResults.length - 1];
|
const lastResult = finalResults[finalResults.length - 1];
|
||||||
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
|
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
|
||||||
|
|
@ -243,15 +324,16 @@ export function RadioTraffic({
|
||||||
score: 0,
|
score: 0,
|
||||||
accuracy: 0,
|
accuracy: 0,
|
||||||
correctMessages: 0,
|
correctMessages: 0,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: 0,
|
relayedMessages: 0,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
|
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
|
||||||
void recordResult(finalResults, resultMode, elapsedMs);
|
void recordResult(finalResults, resultMode, elapsedMs, incorrectMessages);
|
||||||
},
|
},
|
||||||
[recordResult, setPhaseSafe],
|
[recordResult, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
@ -322,6 +404,7 @@ export function RadioTraffic({
|
||||||
setInput("");
|
setInput("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
incorrectMessagesRef.current = 0;
|
||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
const isWatch = mode === "watch";
|
const isWatch = mode === "watch";
|
||||||
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
|
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
|
||||||
|
|
@ -331,6 +414,7 @@ export function RadioTraffic({
|
||||||
setRemainingMs(nextRemaining);
|
setRemainingMs(nextRemaining);
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
messageStartedAtRef.current = now;
|
messageStartedAtRef.current = now;
|
||||||
setPhaseSafe("playing");
|
setPhaseSafe("playing");
|
||||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
|
@ -360,9 +444,10 @@ export function RadioTraffic({
|
||||||
(event: FormEvent<HTMLFormElement>) => {
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (phaseRef.current !== "playing" || submittingRef.current) return;
|
if (phaseRef.current !== "playing" || submittingRef.current) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now < submitBlockedUntilRef.current) return;
|
||||||
const target = messages[messageIndex];
|
const target = messages[messageIndex];
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const now = performance.now();
|
|
||||||
const timeMs = Math.max(1, now - messageStartedAtRef.current);
|
const timeMs = Math.max(1, now - messageStartedAtRef.current);
|
||||||
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
|
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
|
||||||
const keyTotal = correctKeys + incorrectKeys;
|
const keyTotal = correctKeys + incorrectKeys;
|
||||||
|
|
@ -375,18 +460,34 @@ export function RadioTraffic({
|
||||||
accuracy,
|
accuracy,
|
||||||
correct: input === target,
|
correct: input === target,
|
||||||
};
|
};
|
||||||
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
if (!result.correct) {
|
||||||
lastCountdownTickRef.current = "";
|
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];
|
const finalResults = [...results, result];
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
setResults(finalResults);
|
setResults(finalResults);
|
||||||
setInput("");
|
setInput("");
|
||||||
if (result.correct) {
|
playCorrect();
|
||||||
playCorrect();
|
setFeedback({ correct: true, message: "Transmission accepted." });
|
||||||
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 watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
|
||||||
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
|
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 displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
|
||||||
const target = messages[messageIndex] ?? "";
|
const target = messages[messageIndex] ?? "";
|
||||||
|
const sprintReferenceDiff =
|
||||||
|
mode === "sprint" && incorrectSprintSubmission?.target === target
|
||||||
|
? incorrectSprintSubmission.characters
|
||||||
|
: null;
|
||||||
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
|
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
|
||||||
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
|
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
|
||||||
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
|
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
|
||||||
const displayScore = lastRun?.score ?? provisionalScore;
|
const displayScore = lastRun?.score ?? provisionalScore;
|
||||||
const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results);
|
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 displayRelayed = lastRun?.relayedMessages ?? results.length;
|
||||||
const progress =
|
const progress =
|
||||||
displayMode === "sprint"
|
displayMode === "sprint"
|
||||||
|
|
@ -469,10 +575,10 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{(phase === "ready" || phase === "over") && (
|
{(phase === "ready" || phase === "over") && (
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2" role="group" aria-label="Radio traffic mode">
|
<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")}>
|
<Button type="button" size="sm" variant={mode === "sprint" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("sprint")}>
|
||||||
Sprint · 10 messages
|
Sprint · 10 messages
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("watch")}>
|
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("watch")}>
|
||||||
Watch · 60 seconds
|
Watch · 60 seconds
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -485,7 +591,30 @@ export function RadioTraffic({
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{phase === "playing" && <div className="flex flex-col gap-4 pt-4">
|
{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>
|
<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"
|
||||||
|
aria-describedby={sprintReferenceDiff ? "radio-traffic-diff-description" : undefined}
|
||||||
|
>
|
||||||
|
{sprintReferenceDiff
|
||||||
|
? sprintReferenceDiff.map(({ character, matched }, index) => (
|
||||||
|
<span
|
||||||
|
key={`${index}-${character}`}
|
||||||
|
className={matched ? "text-emerald-500" : "text-destructive"}
|
||||||
|
>
|
||||||
|
{character}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: target}
|
||||||
|
</p>
|
||||||
|
{sprintReferenceDiff && (
|
||||||
|
<span id="radio-traffic-diff-description" className="sr-only" role="status">
|
||||||
|
Reference text is unchanged. Green characters match your last submission; red characters
|
||||||
|
differ. Typed-only characters are not shown.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<form className="flex flex-col gap-2" onSubmit={submitCurrent}>
|
<form className="flex flex-col gap-2" onSubmit={submitCurrent}>
|
||||||
<label htmlFor="radio-traffic-input" className="text-sm font-medium">Type exact transmission</label>
|
<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>
|
<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>
|
||||||
|
|
@ -497,7 +626,7 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{phase === "over" && <div className="flex flex-col items-center gap-4 py-8 text-center">
|
{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="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>
|
<div className="grid w-full grid-cols-2 gap-3 sm:grid-cols-5"><Metric label="Score" value={displayScore} /><Metric label="Accuracy" value={formatAccuracy(displayAccuracy)} /><Metric label="Correct" value={displayMode === "sprint" ? `${displayCorrect}/${SPRINT_MESSAGE_COUNT}` : displayRelayed} /><Metric label="Incorrect" value={displayIncorrect} /><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?.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?.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>}
|
{lastRun?.error && <p className="text-xs text-destructive">{lastRun.error}</p>}
|
||||||
|
|
@ -507,7 +636,7 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{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>}
|
{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>
|
||||||
<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 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>Correct messages</span><span className="font-semibold text-foreground">{stats.totalMessages}</span></div><div className="flex items-center gap-1.5"><span>Incorrect messages</span><span className="font-semibold text-foreground">{stats.incorrectMessages}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue