Compare commits
7 commits
533ab4914c
...
72ef20a044
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ef20a044 | |||
| d0ab6d4d8b | |||
| 9baf84ee32 | |||
| 0b012f07d8 | |||
| 4703a24dfe | |||
| 29273131de | |||
| fe45958a80 |
11 changed files with 573 additions and 116 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -52,8 +63,9 @@ async function authenticate() {
|
|||
|
||||
export async function submitRadioResult(input: {
|
||||
score: number;
|
||||
messageCount: number;
|
||||
correctMessages: 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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
aria-label="Radio traffic leaderboard"
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
if (isLeaderboardTab(value)) setTab(value);
|
||||
}}
|
||||
>
|
||||
{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)}
|
||||
>
|
||||
{TAB_CONFIG[value].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<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"}`}
|
||||
>
|
||||
{tabOrder.map((value) => (
|
||||
<TabsTrigger key={value} value={value} className="min-w-0 px-2 text-xs">
|
||||
{TAB_CONFIG[value].label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</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>
|
||||
<td className="px-2 py-2 text-right tabular-nums">
|
||||
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
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") && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
src/migrations/20260903_060000_add_radio_mode_stats.ts
Normal file
24
src/migrations/20260903_060000_add_radio_mode_stats.ts
Normal 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}";`))
|
||||
}
|
||||
}
|
||||
19
src/migrations/20260903_061000_add_radio_incorrect_counts.ts
Normal file
19
src/migrations/20260903_061000_add_radio_incorrect_counts.ts
Normal 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}";`))
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue