1
0
Fork 0

Compare commits

...

7 commits

Author SHA1 Message Date
72ef20a044 feat(radio): expose mode-specific stats
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:03:28 -04:00
d0ab6d4d8b chore(test): configure Vivaldi Playwright runner
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:03:18 -04:00
9baf84ee32 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>
2026-09-03 16:03:13 -04:00
0b012f07d8 feat(radio): record mode-specific traffic results
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:03:10 -04:00
4703a24dfe chore(payload): regenerate radio traffic profile types
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:03:06 -04:00
29273131de chore(payload): add radio traffic mode migrations
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:03:00 -04:00
fe45958a80 feat(profiles): track radio traffic mode statistics
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 16:02:55 -04:00
11 changed files with 573 additions and 116 deletions

View file

@ -29,8 +29,24 @@ export default defineConfig({
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
name: "vivaldi",
use: {
...devices["Desktop Chrome"],
launchOptions: {
executablePath: "/usr/bin/vivaldi",
args: [
"--headless=new",
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-first-run",
"--noerrdialogs",
"--ozone-platform=headless",
"--ozone-override-screen-size=1280,720",
"--use-angle=swiftshader-webgl",
],
},
},
},
],
webServer: {

View file

@ -15,9 +15,20 @@ interface ActionResult<T = undefined> {
export interface RadioStats {
gamesPlayed: number;
totalMessages: number;
incorrectMessages: number;
bestScore: number;
bestAccuracy: number;
bestSprintTimeMs: number;
sprintGamesPlayed: number;
sprintTotalMessages: number;
sprintIncorrectMessages: number;
sprintBestScore: number;
sprintBestAccuracy: number;
watchGamesPlayed: number;
watchTotalMessages: number;
watchIncorrectMessages: number;
watchBestScore: number;
watchBestAccuracy: number;
radioTrafficXp: number;
xpEarned: number;
}
@ -54,6 +65,7 @@ export async function submitRadioResult(input: {
score: number;
messageCount: number;
correctMessages: number;
incorrectMessages: number;
accuracy: number;
mode: RadioMode;
timeMs?: number;
@ -83,6 +95,12 @@ export async function submitRadioResult(input: {
) {
return { success: false, error: "Invalid result." };
}
if (!Number.isInteger(input.incorrectMessages) || input.incorrectMessages < 0) {
return { success: false, error: "Invalid result." };
}
if (input.mode === "sprint" && input.correctMessages === 0) {
return { success: false, error: "A Sprint needs at least one correct transmission." };
}
if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) {
return { success: false, error: "Invalid result." };
}
@ -112,9 +130,20 @@ export async function submitRadioResult(input: {
radioTraffic?: {
gamesPlayed?: number;
totalMessages?: number;
incorrectMessages?: number;
bestScore?: number;
bestAccuracy?: number;
bestSprintTimeMs?: number;
sprintGamesPlayed?: number;
sprintTotalMessages?: number;
sprintIncorrectMessages?: number;
sprintBestScore?: number;
sprintBestAccuracy?: number;
watchGamesPlayed?: number;
watchTotalMessages?: number;
watchIncorrectMessages?: number;
watchBestScore?: number;
watchBestAccuracy?: number;
xpEarned?: number;
};
};
@ -123,8 +152,31 @@ export async function submitRadioResult(input: {
const runXp = Math.round(input.correctMessages * 0.1 * 10) / 10;
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
const totalMessages = (current.totalMessages ?? 0) + input.correctMessages;
const incorrectMessages = (current.incorrectMessages ?? 0) + input.incorrectMessages;
const bestScore = Math.max(current.bestScore ?? 0, score);
const bestAccuracy = Math.round(Math.max(current.bestAccuracy ?? 0, input.accuracy) * 10000) / 10000;
const sprintGamesPlayed = (current.sprintGamesPlayed ?? 0) + (input.mode === "sprint" ? 1 : 0);
const sprintTotalMessages =
(current.sprintTotalMessages ?? 0) + (input.mode === "sprint" ? input.correctMessages : 0);
const sprintIncorrectMessages =
(current.sprintIncorrectMessages ?? 0) + (input.mode === "sprint" ? input.incorrectMessages : 0);
const sprintBestScore =
input.mode === "sprint" ? Math.max(current.sprintBestScore ?? 0, score) : (current.sprintBestScore ?? 0);
const sprintBestAccuracy =
input.mode === "sprint"
? Math.round(Math.max(current.sprintBestAccuracy ?? 0, input.accuracy) * 10000) / 10000
: (current.sprintBestAccuracy ?? 0);
const watchGamesPlayed = (current.watchGamesPlayed ?? 0) + (input.mode === "watch" ? 1 : 0);
const watchTotalMessages =
(current.watchTotalMessages ?? 0) + (input.mode === "watch" ? input.correctMessages : 0);
const watchIncorrectMessages =
(current.watchIncorrectMessages ?? 0) + (input.mode === "watch" ? input.incorrectMessages : 0);
const watchBestScore =
input.mode === "watch" ? Math.max(current.watchBestScore ?? 0, score) : (current.watchBestScore ?? 0);
const watchBestAccuracy =
input.mode === "watch"
? Math.round(Math.max(current.watchBestAccuracy ?? 0, input.accuracy) * 10000) / 10000
: (current.watchBestAccuracy ?? 0);
const previousBestSprintTimeMs = current.bestSprintTimeMs ?? 0;
const bestSprintTimeMs =
input.mode === "sprint" && typeof sprintTime === "number"
@ -145,9 +197,20 @@ export async function submitRadioResult(input: {
radioTraffic: {
gamesPlayed,
totalMessages,
incorrectMessages,
bestScore,
bestAccuracy,
bestSprintTimeMs,
sprintGamesPlayed,
sprintTotalMessages,
sprintIncorrectMessages,
sprintBestScore,
sprintBestAccuracy,
watchGamesPlayed,
watchTotalMessages,
watchIncorrectMessages,
watchBestScore,
watchBestAccuracy,
xpEarned: radioTrafficXp,
},
},
@ -190,9 +253,20 @@ export async function submitRadioResult(input: {
data: {
gamesPlayed,
totalMessages,
incorrectMessages,
bestScore,
bestAccuracy,
bestSprintTimeMs,
sprintGamesPlayed,
sprintTotalMessages,
sprintIncorrectMessages,
sprintBestScore,
sprintBestAccuracy,
watchGamesPlayed,
watchTotalMessages,
watchIncorrectMessages,
watchBestScore,
watchBestAccuracy,
radioTrafficXp,
xpEarned: runXp,
},

View file

@ -38,9 +38,20 @@ export default async function RadioPage() {
const stats = {
gamesPlayed: radioTraffic.gamesPlayed ?? 0,
totalMessages: radioTraffic.totalMessages ?? 0,
incorrectMessages: radioTraffic.incorrectMessages ?? 0,
bestScore: radioTraffic.bestScore ?? 0,
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
sprintGamesPlayed: radioTraffic.sprintGamesPlayed ?? 0,
sprintTotalMessages: radioTraffic.sprintTotalMessages ?? 0,
sprintIncorrectMessages: radioTraffic.sprintIncorrectMessages ?? 0,
sprintBestScore: radioTraffic.sprintBestScore ?? 0,
sprintBestAccuracy: radioTraffic.sprintBestAccuracy ?? 0,
watchGamesPlayed: radioTraffic.watchGamesPlayed ?? 0,
watchTotalMessages: radioTraffic.watchTotalMessages ?? 0,
watchIncorrectMessages: radioTraffic.watchIncorrectMessages ?? 0,
watchBestScore: radioTraffic.watchBestScore ?? 0,
watchBestAccuracy: radioTraffic.watchBestAccuracy ?? 0,
radioTrafficXp: radioTraffic.xpEarned ?? 0,
xpEarned: 0,
};

View file

@ -390,6 +390,12 @@ export const Profiles: CollectionConfig = {
type: "number",
defaultValue: 0,
},
{
name: "incorrectMessages",
label: "Incorrect Messages",
type: "number",
defaultValue: 0,
},
{
name: "bestScore",
label: "Best Score",
@ -408,6 +414,66 @@ export const Profiles: CollectionConfig = {
type: "number",
defaultValue: 0,
},
{
name: "sprintGamesPlayed",
label: "Sprint Games Played",
type: "number",
defaultValue: 0,
},
{
name: "sprintTotalMessages",
label: "Sprint Total Messages",
type: "number",
defaultValue: 0,
},
{
name: "sprintIncorrectMessages",
label: "Sprint Incorrect Messages",
type: "number",
defaultValue: 0,
},
{
name: "sprintBestScore",
label: "Sprint Best Score",
type: "number",
defaultValue: 0,
},
{
name: "sprintBestAccuracy",
label: "Sprint Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "watchGamesPlayed",
label: "Watch Games Played",
type: "number",
defaultValue: 0,
},
{
name: "watchTotalMessages",
label: "Watch Total Messages",
type: "number",
defaultValue: 0,
},
{
name: "watchIncorrectMessages",
label: "Watch Incorrect Messages",
type: "number",
defaultValue: 0,
},
{
name: "watchBestScore",
label: "Watch Best Score",
type: "number",
defaultValue: 0,
},
{
name: "watchBestAccuracy",
label: "Watch Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "xpEarned",
label: "XP Earned",

View file

@ -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 (
<div className="flex flex-col items-center gap-6">
<RadioTraffic
initialStats={initialStats}
mode={mode}
onModeChangeAction={setMode}
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
onPhaseChangeAction={onPhaseChange}
/>
@ -47,6 +61,8 @@ export function RadioArena({
</div>
<RadioLeaderboard
refreshKey={refreshKey}
mode={mode}
modeHydrated={modeHydrated}
currentUserId={currentUserId}
activeOperators={activeOperators}
/>

View file

@ -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<LeaderboardTab, TabConfig>;
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<LeaderboardRow[] | null>(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 (
<Icon
aria-hidden="true"
className="ml-1 inline-block size-3 text-primary"
strokeWidth={2.5}
/>
);
};
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 (
<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="flex items-center gap-2">
<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>
</div>
<div
className="mt-3 grid grid-cols-4 gap-1 rounded-lg bg-muted p-1"
role="tablist"
<Tabs
value={tab}
onValueChange={(value) => {
if (isLeaderboardTab(value)) setTab(value);
}}
>
<TabsList
id="radio-traffic-leaderboard-tabs"
aria-label="Radio traffic leaderboard"
className={`mt-3 grid w-full ${mode === "sprint" ? "grid-cols-4" : "grid-cols-3"}`}
>
{TAB_ORDER.map((value) => (
<Button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
variant={tab === value ? "secondary" : "ghost"}
size="sm"
className="px-2 text-xs"
onClick={() => setTab(value)}
>
{tabOrder.map((value) => (
<TabsTrigger key={value} value={value} className="min-w-0 px-2 text-xs">
{TAB_CONFIG[value].label}
</Button>
</TabsTrigger>
))}
</div>
</TabsList>
</Tabs>
</div>
<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">
@ -194,15 +222,53 @@ export function RadioLeaderboard({
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table id="radio-traffic-leaderboard-table" className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
<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 text-right font-medium">Score</th>
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
<th className="px-2 py-2 text-right font-medium">Time</th>
<th className="px-4 py-2 text-right font-medium">Messages</th>
<th
aria-sort={tab === "score" ? sortDirection : "none"}
className={cn(
"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>
</thead>
<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">
{(row.bestAccuracy * 100).toFixed(2)}%
</td>
{mode === "sprint" && (
<td className="px-2 py-2 text-right tabular-nums">
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
</td>
)}
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
{row.totalMessages}
</td>

View file

@ -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<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 {
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<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);
@ -121,6 +182,8 @@ export function RadioTraffic({
const [stats, setStats] = useState<RadioStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [incorrectSprintSubmission, setIncorrectSprintSubmission] =
useState<IncorrectSprintSubmission | null>(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<HTMLFormElement>) => {
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,
};
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." });
}
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") && (
<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
</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
</Button>
</div>
@ -485,7 +591,30 @@ export function RadioTraffic({
</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>
<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}>
<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>
@ -497,7 +626,7 @@ export function RadioTraffic({
{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>
<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?.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>}
@ -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>}
</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>
);
}

View file

@ -0,0 +1,24 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
const columns = [
'progression_minigames_radio_traffic_sprint_games_played',
'progression_minigames_radio_traffic_sprint_total_messages',
'progression_minigames_radio_traffic_sprint_best_score',
'progression_minigames_radio_traffic_sprint_best_accuracy',
'progression_minigames_radio_traffic_watch_games_played',
'progression_minigames_radio_traffic_watch_total_messages',
'progression_minigames_radio_traffic_watch_best_score',
'progression_minigames_radio_traffic_watch_best_accuracy',
] as const
export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const column of columns) {
await db.execute(sql.raw(`ALTER TABLE "profiles" ADD COLUMN "${column}" numeric DEFAULT 0;`))
}
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
for (const column of [...columns].reverse()) {
await db.execute(sql.raw(`ALTER TABLE "profiles" DROP COLUMN "${column}";`))
}
}

View file

@ -0,0 +1,19 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
const columns = [
'progression_minigames_radio_traffic_incorrect_messages',
'progression_minigames_radio_traffic_sprint_incorrect_messages',
'progression_minigames_radio_traffic_watch_incorrect_messages',
] as const
export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const column of columns) {
await db.execute(sql.raw(`ALTER TABLE "profiles" ADD COLUMN "${column}" numeric DEFAULT 0;`))
}
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
for (const column of [...columns].reverse()) {
await db.execute(sql.raw(`ALTER TABLE "profiles" DROP COLUMN "${column}";`))
}
}

View file

@ -18,6 +18,8 @@ import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195
import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
export const migrations = [
{
@ -118,6 +120,16 @@ export const migrations = [
{
up: migration_20260903_053419.up,
down: migration_20260903_053419.down,
name: '20260903_053419'
name: '20260903_053419',
},
{
up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down,
name: '20260903_060000_add_radio_mode_stats',
},
{
up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts',
},
];

View file

@ -1787,9 +1787,20 @@ export interface Profile {
radioTraffic?: {
gamesPlayed?: number | null;
totalMessages?: number | null;
incorrectMessages?: number | null;
bestScore?: number | null;
bestAccuracy?: number | null;
bestSprintTimeMs?: number | null;
sprintGamesPlayed?: number | null;
sprintTotalMessages?: number | null;
sprintIncorrectMessages?: number | null;
sprintBestScore?: number | null;
sprintBestAccuracy?: number | null;
watchGamesPlayed?: number | null;
watchTotalMessages?: number | null;
watchIncorrectMessages?: number | null;
watchBestScore?: number | null;
watchBestAccuracy?: number | null;
xpEarned?: number | null;
};
};
@ -3805,9 +3816,20 @@ export interface ProfilesSelect<T extends boolean = true> {
| {
gamesPlayed?: T;
totalMessages?: T;
incorrectMessages?: T;
bestScore?: T;
bestAccuracy?: T;
bestSprintTimeMs?: T;
sprintGamesPlayed?: T;
sprintTotalMessages?: T;
sprintIncorrectMessages?: T;
sprintBestScore?: T;
sprintBestAccuracy?: T;
watchGamesPlayed?: T;
watchTotalMessages?: T;
watchIncorrectMessages?: T;
watchBestScore?: T;
watchBestAccuracy?: T;
xpEarned?: T;
};
};