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: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "vivaldi",
|
||||||
use: { ...devices["Desktop Chrome"] },
|
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: {
|
webServer: {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,20 @@ interface ActionResult<T = undefined> {
|
||||||
export interface RadioStats {
|
export interface RadioStats {
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
totalMessages: number;
|
totalMessages: number;
|
||||||
|
incorrectMessages: number;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
bestAccuracy: number;
|
bestAccuracy: number;
|
||||||
bestSprintTimeMs: 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;
|
radioTrafficXp: number;
|
||||||
xpEarned: number;
|
xpEarned: number;
|
||||||
}
|
}
|
||||||
|
|
@ -52,8 +63,9 @@ async function authenticate() {
|
||||||
|
|
||||||
export async function submitRadioResult(input: {
|
export async function submitRadioResult(input: {
|
||||||
score: number;
|
score: number;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
correctMessages: number;
|
correctMessages: number;
|
||||||
|
incorrectMessages: number;
|
||||||
accuracy: number;
|
accuracy: number;
|
||||||
mode: RadioMode;
|
mode: RadioMode;
|
||||||
timeMs?: number;
|
timeMs?: number;
|
||||||
|
|
@ -83,6 +95,12 @@ export async function submitRadioResult(input: {
|
||||||
) {
|
) {
|
||||||
return { success: false, error: "Invalid result." };
|
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) {
|
if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) {
|
||||||
return { success: false, error: "Invalid result." };
|
return { success: false, error: "Invalid result." };
|
||||||
}
|
}
|
||||||
|
|
@ -112,9 +130,20 @@ export async function submitRadioResult(input: {
|
||||||
radioTraffic?: {
|
radioTraffic?: {
|
||||||
gamesPlayed?: number;
|
gamesPlayed?: number;
|
||||||
totalMessages?: number;
|
totalMessages?: number;
|
||||||
|
incorrectMessages?: number;
|
||||||
bestScore?: number;
|
bestScore?: number;
|
||||||
bestAccuracy?: number;
|
bestAccuracy?: number;
|
||||||
bestSprintTimeMs?: 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;
|
xpEarned?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -123,8 +152,31 @@ export async function submitRadioResult(input: {
|
||||||
const runXp = Math.round(input.correctMessages * 0.1 * 10) / 10;
|
const runXp = Math.round(input.correctMessages * 0.1 * 10) / 10;
|
||||||
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
const totalMessages = (current.totalMessages ?? 0) + input.correctMessages;
|
const totalMessages = (current.totalMessages ?? 0) + input.correctMessages;
|
||||||
|
const incorrectMessages = (current.incorrectMessages ?? 0) + input.incorrectMessages;
|
||||||
const bestScore = Math.max(current.bestScore ?? 0, score);
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
const bestAccuracy = Math.round(Math.max(current.bestAccuracy ?? 0, input.accuracy) * 10000) / 10000;
|
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 previousBestSprintTimeMs = current.bestSprintTimeMs ?? 0;
|
||||||
const bestSprintTimeMs =
|
const bestSprintTimeMs =
|
||||||
input.mode === "sprint" && typeof sprintTime === "number"
|
input.mode === "sprint" && typeof sprintTime === "number"
|
||||||
|
|
@ -145,9 +197,20 @@ export async function submitRadioResult(input: {
|
||||||
radioTraffic: {
|
radioTraffic: {
|
||||||
gamesPlayed,
|
gamesPlayed,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
|
incorrectMessages,
|
||||||
bestScore,
|
bestScore,
|
||||||
bestAccuracy,
|
bestAccuracy,
|
||||||
bestSprintTimeMs,
|
bestSprintTimeMs,
|
||||||
|
sprintGamesPlayed,
|
||||||
|
sprintTotalMessages,
|
||||||
|
sprintIncorrectMessages,
|
||||||
|
sprintBestScore,
|
||||||
|
sprintBestAccuracy,
|
||||||
|
watchGamesPlayed,
|
||||||
|
watchTotalMessages,
|
||||||
|
watchIncorrectMessages,
|
||||||
|
watchBestScore,
|
||||||
|
watchBestAccuracy,
|
||||||
xpEarned: radioTrafficXp,
|
xpEarned: radioTrafficXp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -190,9 +253,20 @@ export async function submitRadioResult(input: {
|
||||||
data: {
|
data: {
|
||||||
gamesPlayed,
|
gamesPlayed,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
|
incorrectMessages,
|
||||||
bestScore,
|
bestScore,
|
||||||
bestAccuracy,
|
bestAccuracy,
|
||||||
bestSprintTimeMs,
|
bestSprintTimeMs,
|
||||||
|
sprintGamesPlayed,
|
||||||
|
sprintTotalMessages,
|
||||||
|
sprintIncorrectMessages,
|
||||||
|
sprintBestScore,
|
||||||
|
sprintBestAccuracy,
|
||||||
|
watchGamesPlayed,
|
||||||
|
watchTotalMessages,
|
||||||
|
watchIncorrectMessages,
|
||||||
|
watchBestScore,
|
||||||
|
watchBestAccuracy,
|
||||||
radioTrafficXp,
|
radioTrafficXp,
|
||||||
xpEarned: runXp,
|
xpEarned: runXp,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,20 @@ export default async function RadioPage() {
|
||||||
const stats = {
|
const stats = {
|
||||||
gamesPlayed: radioTraffic.gamesPlayed ?? 0,
|
gamesPlayed: radioTraffic.gamesPlayed ?? 0,
|
||||||
totalMessages: radioTraffic.totalMessages ?? 0,
|
totalMessages: radioTraffic.totalMessages ?? 0,
|
||||||
|
incorrectMessages: radioTraffic.incorrectMessages ?? 0,
|
||||||
bestScore: radioTraffic.bestScore ?? 0,
|
bestScore: radioTraffic.bestScore ?? 0,
|
||||||
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
|
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
|
||||||
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 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,
|
radioTrafficXp: radioTraffic.xpEarned ?? 0,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,12 @@ export const Profiles: CollectionConfig = {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "incorrectMessages",
|
||||||
|
label: "Incorrect Messages",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "bestScore",
|
name: "bestScore",
|
||||||
label: "Best Score",
|
label: "Best Score",
|
||||||
|
|
@ -408,6 +414,66 @@ export const Profiles: CollectionConfig = {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0,
|
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",
|
name: "xpEarned",
|
||||||
label: "XP Earned",
|
label: "XP Earned",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { RadioStats } from "@/app/(frontend)/radio/actions";
|
import type { RadioStats } from "@/app/(frontend)/radio/actions";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { RadioLeaderboard } from "./RadioLeaderboard";
|
import { RadioLeaderboard } from "./RadioLeaderboard";
|
||||||
|
|
@ -15,15 +15,29 @@ export function RadioArena({
|
||||||
}) {
|
}) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"sprint" | "watch">("sprint");
|
||||||
|
const [modeHydrated, setModeHydrated] = useState(false);
|
||||||
const activeOperators = useRealtimePresence("radio", playing, true);
|
const activeOperators = useRealtimePresence("radio", playing, true);
|
||||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
setPlaying(phase === "playing");
|
setPlaying(phase === "playing");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedMode = window.localStorage.getItem("radio-traffic-mode");
|
||||||
|
if (savedMode === "sprint" || savedMode === "watch") setMode(savedMode);
|
||||||
|
setModeHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (modeHydrated) window.localStorage.setItem("radio-traffic-mode", mode);
|
||||||
|
}, [mode, modeHydrated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<RadioTraffic
|
<RadioTraffic
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
|
mode={mode}
|
||||||
|
onModeChangeAction={setMode}
|
||||||
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
||||||
onPhaseChangeAction={onPhaseChange}
|
onPhaseChangeAction={onPhaseChange}
|
||||||
/>
|
/>
|
||||||
|
|
@ -47,6 +61,8 @@ export function RadioArena({
|
||||||
</div>
|
</div>
|
||||||
<RadioLeaderboard
|
<RadioLeaderboard
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
mode={mode}
|
||||||
|
modeHydrated={modeHydrated}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
activeOperators={activeOperators}
|
activeOperators={activeOperators}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trophy } from "lucide-react";
|
import { ArrowDown, ArrowUp, Trophy } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
|
||||||
type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
|
type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
|
||||||
|
type Mode = "sprint" | "watch";
|
||||||
|
|
||||||
|
function isLeaderboardTab(value: string): value is LeaderboardTab {
|
||||||
|
return value === "score" || value === "accuracy" || value === "messages" || value === "time";
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiUser {
|
interface ApiUser {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -22,9 +27,12 @@ interface ApiProfile {
|
||||||
progression?: {
|
progression?: {
|
||||||
minigames?: {
|
minigames?: {
|
||||||
radioTraffic?: {
|
radioTraffic?: {
|
||||||
totalMessages?: number | null;
|
sprintTotalMessages?: number | null;
|
||||||
bestScore?: number | null;
|
sprintBestScore?: number | null;
|
||||||
bestAccuracy?: number | null;
|
sprintBestAccuracy?: number | null;
|
||||||
|
watchTotalMessages?: number | null;
|
||||||
|
watchBestScore?: number | null;
|
||||||
|
watchBestAccuracy?: number | null;
|
||||||
bestSprintTimeMs?: number | null;
|
bestSprintTimeMs?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
@ -42,46 +50,20 @@ interface LeaderboardRow {
|
||||||
isOutsideTopTen?: boolean;
|
isOutsideTopTen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabConfig {
|
|
||||||
readonly label: string;
|
|
||||||
readonly sort: string;
|
|
||||||
readonly matches: (row: LeaderboardRow) => boolean;
|
|
||||||
readonly empty: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadioLeaderboardProps {
|
interface RadioLeaderboardProps {
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
|
mode: Mode;
|
||||||
|
modeHydrated: boolean;
|
||||||
currentUserId?: number | string;
|
currentUserId?: number | string;
|
||||||
activeOperators?: PresenceParticipant[];
|
activeOperators?: PresenceParticipant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[];
|
|
||||||
const TAB_CONFIG = {
|
const TAB_CONFIG = {
|
||||||
score: {
|
score: { label: "Top Score", empty: "No scored transmissions yet. Take the first slot!" },
|
||||||
label: "Top Score",
|
accuracy: { label: "Top Accuracy", empty: "No accuracy records yet. Relay a perfect transmission!" },
|
||||||
sort: "-progression.minigames.radioTraffic.bestScore",
|
messages: { label: "Most Correct", empty: "No correct transmissions yet. Get on frequency!" },
|
||||||
matches: (row) => row.bestScore > 0,
|
time: { label: "Fastest Sprint", empty: "No sprint times yet. Complete a 10-message sprint!" },
|
||||||
empty: "No scored transmissions yet. Take the first slot!",
|
} as const;
|
||||||
},
|
|
||||||
accuracy: {
|
|
||||||
label: "Top Accuracy",
|
|
||||||
sort: "-progression.minigames.radioTraffic.bestAccuracy",
|
|
||||||
matches: (row) => row.bestAccuracy > 0,
|
|
||||||
empty: "No accuracy records yet. Relay a perfect transmission!",
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
label: "Most Messages",
|
|
||||||
sort: "-progression.minigames.radioTraffic.totalMessages",
|
|
||||||
matches: (row) => row.totalMessages > 0,
|
|
||||||
empty: "No accurate messages yet. Get on frequency!",
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
label: "Fastest Sprint",
|
|
||||||
sort: "progression.minigames.radioTraffic.bestSprintTimeMs",
|
|
||||||
matches: (row) => row.bestSprintTimeMs > 0,
|
|
||||||
empty: "No sprint times yet. Complete a 10-message sprint!",
|
|
||||||
},
|
|
||||||
} satisfies Record<LeaderboardTab, TabConfig>;
|
|
||||||
|
|
||||||
function rankClass(index: number) {
|
function rankClass(index: number) {
|
||||||
if (index === 0) return "font-bold text-amber-400";
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
|
@ -92,6 +74,8 @@ function rankClass(index: number) {
|
||||||
|
|
||||||
export function RadioLeaderboard({
|
export function RadioLeaderboard({
|
||||||
refreshKey = 0,
|
refreshKey = 0,
|
||||||
|
mode,
|
||||||
|
modeHydrated,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
activeOperators = [],
|
activeOperators = [],
|
||||||
}: RadioLeaderboardProps) {
|
}: RadioLeaderboardProps) {
|
||||||
|
|
@ -99,13 +83,45 @@ export function RadioLeaderboard({
|
||||||
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const tabConfig = TAB_CONFIG[tab];
|
const tabConfig = TAB_CONFIG[tab];
|
||||||
|
const tabOrder: readonly LeaderboardTab[] =
|
||||||
|
mode === "sprint"
|
||||||
|
? ["score", "accuracy", "messages", "time"]
|
||||||
|
: ["score", "accuracy", "messages"];
|
||||||
|
const sortDirection = tab === "time" ? "ascending" : "descending";
|
||||||
|
const SortMarker = ({ column }: { column: LeaderboardTab }) => {
|
||||||
|
if (tab !== column) return null;
|
||||||
|
|
||||||
|
const Icon = sortDirection === "ascending" ? ArrowUp : ArrowDown;
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-1 inline-block size-3 text-primary"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode === "watch" && tab === "time") setTab("score");
|
||||||
|
}, [mode, tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modeHydrated || (mode === "watch" && tab === "time")) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
setRows(null);
|
setRows(null);
|
||||||
|
|
||||||
const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" });
|
const field =
|
||||||
|
tab === "time"
|
||||||
|
? "bestSprintTimeMs"
|
||||||
|
: mode === "sprint"
|
||||||
|
? `sprint${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`
|
||||||
|
: `watch${tab === "score" ? "BestScore" : tab === "accuracy" ? "BestAccuracy" : "TotalMessages"}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: `${tab === "time" ? "" : "-"}progression.minigames.radioTraffic.${field}`,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
fetch(`/api/profiles?${params.toString()}`)
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error("leaderboard fetch failed");
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
|
@ -119,14 +135,26 @@ export function RadioLeaderboard({
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
|
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
|
||||||
totalMessages: radioTraffic.totalMessages ?? 0,
|
totalMessages:
|
||||||
bestScore: radioTraffic.bestScore ?? 0,
|
radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
|
||||||
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
|
bestScore: radioTraffic[mode === "sprint" ? "sprintBestScore" : "watchBestScore"] ?? 0,
|
||||||
|
bestAccuracy:
|
||||||
|
radioTraffic[mode === "sprint" ? "sprintBestAccuracy" : "watchBestAccuracy"] ?? 0,
|
||||||
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
||||||
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const visible = mapped.filter(tabConfig.matches).slice(0, 10);
|
const visible = mapped
|
||||||
|
.filter((row) =>
|
||||||
|
(tab === "score"
|
||||||
|
? row.bestScore
|
||||||
|
: tab === "accuracy"
|
||||||
|
? row.bestAccuracy
|
||||||
|
: tab === "messages"
|
||||||
|
? row.totalMessages
|
||||||
|
: row.bestSprintTimeMs) > 0,
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
const current = mapped.find((row) => row.isCurrentUser);
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
if (current && !visible.some((row) => row.id === current.id)) {
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
visible.push({ ...current, isOutsideTopTen: true });
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
|
@ -140,36 +168,36 @@ export function RadioLeaderboard({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]);
|
}, [currentUserId, mode, modeHydrated, refreshKey, tab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
<div className="border-b border-border px-4 py-3">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="size-4 text-amber-400" />
|
<Trophy className="size-4 text-amber-400" />
|
||||||
<h2 className="text-sm font-semibold">Radio Traffic Leaderboard</h2>
|
<h2 className="text-sm font-semibold">
|
||||||
|
Radio Traffic {mode === "sprint" ? "Sprint" : "Watch"} Leaderboard
|
||||||
|
</h2>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Top 10</span>
|
<span className="ml-auto text-xs text-muted-foreground">Top 10</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Tabs
|
||||||
className="mt-3 grid grid-cols-4 gap-1 rounded-lg bg-muted p-1"
|
value={tab}
|
||||||
role="tablist"
|
onValueChange={(value) => {
|
||||||
aria-label="Radio traffic leaderboard"
|
if (isLeaderboardTab(value)) setTab(value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{TAB_ORDER.map((value) => (
|
<TabsList
|
||||||
<Button
|
id="radio-traffic-leaderboard-tabs"
|
||||||
key={value}
|
aria-label="Radio traffic leaderboard"
|
||||||
type="button"
|
className={`mt-3 grid w-full ${mode === "sprint" ? "grid-cols-4" : "grid-cols-3"}`}
|
||||||
role="tab"
|
>
|
||||||
aria-selected={tab === value}
|
{tabOrder.map((value) => (
|
||||||
variant={tab === value ? "secondary" : "ghost"}
|
<TabsTrigger key={value} value={value} className="min-w-0 px-2 text-xs">
|
||||||
size="sm"
|
{TAB_CONFIG[value].label}
|
||||||
className="px-2 text-xs"
|
</TabsTrigger>
|
||||||
onClick={() => setTab(value)}
|
))}
|
||||||
>
|
</TabsList>
|
||||||
{TAB_CONFIG[value].label}
|
</Tabs>
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
|
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
|
||||||
<span className="font-medium text-emerald-400">
|
<span className="font-medium text-emerald-400">
|
||||||
|
|
@ -194,15 +222,53 @@ export function RadioLeaderboard({
|
||||||
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
|
<p className="px-4 py-4 text-sm text-muted-foreground">{tabConfig.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table id="radio-traffic-leaderboard-table" className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="px-4 py-2 font-medium">#</th>
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
<th className="px-2 py-2 font-medium">Operator</th>
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
<th className="px-2 py-2 text-right font-medium">Score</th>
|
<th
|
||||||
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
|
aria-sort={tab === "score" ? sortDirection : "none"}
|
||||||
<th className="px-2 py-2 text-right font-medium">Time</th>
|
className={cn(
|
||||||
<th className="px-4 py-2 text-right font-medium">Messages</th>
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "score" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Score
|
||||||
|
<SortMarker column="score" />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "accuracy" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "accuracy" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Accuracy
|
||||||
|
<SortMarker column="accuracy" />
|
||||||
|
</th>
|
||||||
|
{mode === "sprint" && (
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "time" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2 text-right font-medium",
|
||||||
|
tab === "time" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
<SortMarker column="time" />
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th
|
||||||
|
aria-sort={tab === "messages" ? sortDirection : "none"}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-right font-medium",
|
||||||
|
tab === "messages" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Correct
|
||||||
|
<SortMarker column="messages" />
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|
@ -248,9 +314,11 @@ export function RadioLeaderboard({
|
||||||
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
{(row.bestAccuracy * 100).toFixed(2)}%
|
{(row.bestAccuracy * 100).toFixed(2)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-right tabular-nums">
|
{mode === "sprint" && (
|
||||||
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
<td className="px-2 py-2 text-right tabular-nums">
|
||||||
</td>
|
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
{row.totalMessages}
|
{row.totalMessages}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,18 @@ type MessageResult = {
|
||||||
readonly accuracy: number;
|
readonly accuracy: number;
|
||||||
readonly correct: boolean;
|
readonly correct: boolean;
|
||||||
};
|
};
|
||||||
|
type ReferenceCharacter = { readonly character: string; readonly matched: boolean };
|
||||||
|
type IncorrectSprintSubmission = {
|
||||||
|
readonly target: string;
|
||||||
|
readonly characters: readonly ReferenceCharacter[];
|
||||||
|
};
|
||||||
type Feedback = { readonly correct: boolean; readonly message: string };
|
type Feedback = { readonly correct: boolean; readonly message: string };
|
||||||
type LastRun = {
|
type LastRun = {
|
||||||
readonly mode: Mode;
|
readonly mode: Mode;
|
||||||
readonly score: number;
|
readonly score: number;
|
||||||
readonly accuracy: number;
|
readonly accuracy: number;
|
||||||
readonly correctMessages: number;
|
readonly correctMessages: number;
|
||||||
|
readonly incorrectMessages: number;
|
||||||
readonly relayedMessages: number;
|
readonly relayedMessages: number;
|
||||||
readonly xpEarned: number;
|
readonly xpEarned: number;
|
||||||
readonly best: number;
|
readonly best: number;
|
||||||
|
|
@ -44,13 +50,9 @@ const WATCH_DURATION_MS = 60_000;
|
||||||
const WATCH_MAX_MESSAGES = 30;
|
const WATCH_MAX_MESSAGES = 30;
|
||||||
const FAST_TICK_THRESHOLD_MS = 10_000;
|
const FAST_TICK_THRESHOLD_MS = 10_000;
|
||||||
const CRITICAL_TICK_THRESHOLD_MS = 3_000;
|
const CRITICAL_TICK_THRESHOLD_MS = 3_000;
|
||||||
const MODE_STORAGE_KEY = "radio-traffic-mode";
|
const UNSUCCESSFUL_SUBMIT_COOLDOWN_MS = 750;
|
||||||
const TICK_SOUND_STORAGE_KEY = "radio-traffic-tick-sound";
|
const TICK_SOUND_STORAGE_KEY = "radio-traffic-tick-sound";
|
||||||
|
|
||||||
function isMode(value: string | null): value is Mode {
|
|
||||||
return value === "sprint" || value === "watch";
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageScore(accuracy: number, timeMs: number): number {
|
function messageScore(accuracy: number, timeMs: number): number {
|
||||||
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
|
const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000));
|
||||||
return Math.round(accuracy * 100 * speed);
|
return Math.round(accuracy * 100 * speed);
|
||||||
|
|
@ -64,6 +66,61 @@ function formatAccuracy(accuracy: number): string {
|
||||||
return `${Math.round(accuracy * 100)}%`;
|
return `${Math.round(accuracy * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function alignReferenceCharacters(
|
||||||
|
target: string,
|
||||||
|
submitted: string,
|
||||||
|
): readonly ReferenceCharacter[] {
|
||||||
|
const targetCharacters = Array.from(target);
|
||||||
|
const submittedCharacters = Array.from(submitted);
|
||||||
|
const lcsLengths = Array.from(
|
||||||
|
{ length: targetCharacters.length + 1 },
|
||||||
|
() => new Array<number>(submittedCharacters.length + 1).fill(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let targetIndex = targetCharacters.length - 1; targetIndex >= 0; targetIndex -= 1) {
|
||||||
|
for (
|
||||||
|
let submittedIndex = submittedCharacters.length - 1;
|
||||||
|
submittedIndex >= 0;
|
||||||
|
submittedIndex -= 1
|
||||||
|
) {
|
||||||
|
const charactersMatch = targetCharacters[targetIndex] === submittedCharacters[submittedIndex];
|
||||||
|
lcsLengths[targetIndex][submittedIndex] =
|
||||||
|
charactersMatch
|
||||||
|
? lcsLengths[targetIndex + 1][submittedIndex + 1] + 1
|
||||||
|
: Math.max(
|
||||||
|
lcsLengths[targetIndex + 1][submittedIndex],
|
||||||
|
lcsLengths[targetIndex][submittedIndex + 1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTargetCharacters = new Array<boolean>(targetCharacters.length).fill(false);
|
||||||
|
let targetIndex = 0;
|
||||||
|
let submittedIndex = 0;
|
||||||
|
let hasPendingInsertion = false;
|
||||||
|
while (targetIndex < targetCharacters.length && submittedIndex < submittedCharacters.length) {
|
||||||
|
if (targetCharacters[targetIndex] === submittedCharacters[submittedIndex]) {
|
||||||
|
matchedTargetCharacters[targetIndex] = !hasPendingInsertion;
|
||||||
|
hasPendingInsertion = false;
|
||||||
|
targetIndex += 1;
|
||||||
|
submittedIndex += 1;
|
||||||
|
} else if (
|
||||||
|
lcsLengths[targetIndex + 1][submittedIndex] > lcsLengths[targetIndex][submittedIndex + 1]
|
||||||
|
) {
|
||||||
|
hasPendingInsertion = false;
|
||||||
|
targetIndex += 1;
|
||||||
|
} else {
|
||||||
|
hasPendingInsertion = true;
|
||||||
|
submittedIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetCharacters.map((character, index) => ({
|
||||||
|
character,
|
||||||
|
matched: matchedTargetCharacters[index],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function roundAccuracy(results: readonly MessageResult[]): number {
|
function roundAccuracy(results: readonly MessageResult[]): number {
|
||||||
const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0);
|
const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0);
|
||||||
return total === 0 ? 0 : results.reduce((sum, result) => sum + result.correctKeys, 0) / total;
|
return total === 0 ? 0 : results.reduce((sum, result) => sum + result.correctKeys, 0) / total;
|
||||||
|
|
@ -80,10 +137,14 @@ function Metric({ label, value }: { label: string; value: string | number }) {
|
||||||
|
|
||||||
export function RadioTraffic({
|
export function RadioTraffic({
|
||||||
initialStats: serverStats,
|
initialStats: serverStats,
|
||||||
|
mode,
|
||||||
|
onModeChangeAction,
|
||||||
onGameRecordedAction,
|
onGameRecordedAction,
|
||||||
onPhaseChangeAction,
|
onPhaseChangeAction,
|
||||||
}: {
|
}: {
|
||||||
initialStats: RadioStats;
|
initialStats: RadioStats;
|
||||||
|
mode: Mode;
|
||||||
|
onModeChangeAction: (mode: Mode) => void;
|
||||||
onGameRecordedAction: () => void;
|
onGameRecordedAction: () => void;
|
||||||
onPhaseChangeAction: (phase: Phase) => void;
|
onPhaseChangeAction: (phase: Phase) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -104,9 +165,9 @@ export function RadioTraffic({
|
||||||
const lastCountdownTickRef = useRef("");
|
const lastCountdownTickRef = useRef("");
|
||||||
const countdownEndPlayedRef = useRef(false);
|
const countdownEndPlayedRef = useRef(false);
|
||||||
const tickSoundRef = useRef(true);
|
const tickSoundRef = useRef(true);
|
||||||
|
const submitBlockedUntilRef = useRef(0);
|
||||||
|
const incorrectMessagesRef = useRef(0);
|
||||||
const [phase, setPhase] = useState<Phase>("ready");
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
const [mode, setMode] = useState<Mode>("sprint");
|
|
||||||
const [modeHydrated, setModeHydrated] = useState(false);
|
|
||||||
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
||||||
const [messages, setMessages] = useState<readonly string[]>(() => {
|
const [messages, setMessages] = useState<readonly string[]>(() => {
|
||||||
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
||||||
|
|
@ -121,6 +182,8 @@ export function RadioTraffic({
|
||||||
const [stats, setStats] = useState<RadioStats>(serverStats);
|
const [stats, setStats] = useState<RadioStats>(serverStats);
|
||||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||||
|
const [incorrectSprintSubmission, setIncorrectSprintSubmission] =
|
||||||
|
useState<IncorrectSprintSubmission | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const setPhaseSafe = useCallback(
|
const setPhaseSafe = useCallback(
|
||||||
|
|
@ -133,20 +196,13 @@ export function RadioTraffic({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedMode = window.localStorage.getItem(MODE_STORAGE_KEY);
|
|
||||||
if (isMode(savedMode)) setMode(savedMode);
|
|
||||||
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
|
const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY);
|
||||||
if (savedTickSound === "false") {
|
if (savedTickSound === "false") {
|
||||||
tickSoundRef.current = false;
|
tickSoundRef.current = false;
|
||||||
setTickSoundEnabled(false);
|
setTickSoundEnabled(false);
|
||||||
}
|
}
|
||||||
setModeHydrated(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (modeHydrated) window.localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
||||||
}, [mode, modeHydrated]);
|
|
||||||
|
|
||||||
const handleTickSoundChange = useCallback((enabled: boolean) => {
|
const handleTickSoundChange = useCallback((enabled: boolean) => {
|
||||||
tickSoundRef.current = enabled;
|
tickSoundRef.current = enabled;
|
||||||
setTickSoundEnabled(enabled);
|
setTickSoundEnabled(enabled);
|
||||||
|
|
@ -155,8 +211,17 @@ export function RadioTraffic({
|
||||||
|
|
||||||
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
|
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const recordResult = useCallback(
|
const recordResult = useCallback(
|
||||||
async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => {
|
async (
|
||||||
|
finalResults: readonly MessageResult[],
|
||||||
|
resultMode: Mode,
|
||||||
|
elapsedMs: number | undefined,
|
||||||
|
incorrectMessages: number,
|
||||||
|
) => {
|
||||||
if (submittingRef.current) return;
|
if (submittingRef.current) return;
|
||||||
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
|
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
|
||||||
const correctMessages = finalResults.filter((result) => result.correct).length;
|
const correctMessages = finalResults.filter((result) => result.correct).length;
|
||||||
|
|
@ -167,9 +232,13 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best:
|
||||||
|
resultMode === "sprint"
|
||||||
|
? statsRef.current.sprintBestScore
|
||||||
|
: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
error: "Your session expired. Result not recorded.",
|
error: "Your session expired. Result not recorded.",
|
||||||
});
|
});
|
||||||
|
|
@ -182,6 +251,7 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
messageCount: finalResults.length,
|
messageCount: finalResults.length,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
accuracy,
|
accuracy,
|
||||||
mode: resultMode,
|
mode: resultMode,
|
||||||
timeMs: elapsedMs,
|
timeMs: elapsedMs,
|
||||||
|
|
@ -190,7 +260,8 @@ export function RadioTraffic({
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const previousBest = statsRef.current.bestScore;
|
const previousBest =
|
||||||
|
resultMode === "sprint" ? statsRef.current.sprintBestScore : statsRef.current.watchBestScore;
|
||||||
statsRef.current = result.data;
|
statsRef.current = result.data;
|
||||||
setStats(result.data);
|
setStats(result.data);
|
||||||
onGameRecordedAction();
|
onGameRecordedAction();
|
||||||
|
|
@ -208,9 +279,11 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: result.data.xpEarned,
|
xpEarned: result.data.xpEarned,
|
||||||
best: result.data.bestScore,
|
best:
|
||||||
|
resultMode === "sprint" ? result.data.sprintBestScore : result.data.watchBestScore,
|
||||||
isNewBest: score > previousBest,
|
isNewBest: score > previousBest,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,9 +292,13 @@ export function RadioTraffic({
|
||||||
score,
|
score,
|
||||||
accuracy,
|
accuracy,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: finalResults.length,
|
relayedMessages: finalResults.length,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best:
|
||||||
|
resultMode === "sprint"
|
||||||
|
? statsRef.current.sprintBestScore
|
||||||
|
: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
error: result.error ?? "Could not record radio traffic.",
|
error: result.error ?? "Could not record radio traffic.",
|
||||||
});
|
});
|
||||||
|
|
@ -231,7 +308,11 @@ export function RadioTraffic({
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishRound = useCallback(
|
const finishRound = useCallback(
|
||||||
(finalResults: readonly MessageResult[], resultMode: Mode) => {
|
(
|
||||||
|
finalResults: readonly MessageResult[],
|
||||||
|
resultMode: Mode,
|
||||||
|
incorrectMessages = incorrectMessagesRef.current,
|
||||||
|
) => {
|
||||||
if (phaseRef.current !== "playing") return;
|
if (phaseRef.current !== "playing") return;
|
||||||
const lastResult = finalResults[finalResults.length - 1];
|
const lastResult = finalResults[finalResults.length - 1];
|
||||||
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
|
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
|
||||||
|
|
@ -243,15 +324,16 @@ export function RadioTraffic({
|
||||||
score: 0,
|
score: 0,
|
||||||
accuracy: 0,
|
accuracy: 0,
|
||||||
correctMessages: 0,
|
correctMessages: 0,
|
||||||
|
incorrectMessages,
|
||||||
relayedMessages: 0,
|
relayedMessages: 0,
|
||||||
xpEarned: 0,
|
xpEarned: 0,
|
||||||
best: statsRef.current.bestScore,
|
best: statsRef.current.watchBestScore,
|
||||||
isNewBest: false,
|
isNewBest: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
|
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
|
||||||
void recordResult(finalResults, resultMode, elapsedMs);
|
void recordResult(finalResults, resultMode, elapsedMs, incorrectMessages);
|
||||||
},
|
},
|
||||||
[recordResult, setPhaseSafe],
|
[recordResult, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
@ -322,6 +404,7 @@ export function RadioTraffic({
|
||||||
setInput("");
|
setInput("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
incorrectMessagesRef.current = 0;
|
||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
const isWatch = mode === "watch";
|
const isWatch = mode === "watch";
|
||||||
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
|
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
|
||||||
|
|
@ -331,6 +414,7 @@ export function RadioTraffic({
|
||||||
setRemainingMs(nextRemaining);
|
setRemainingMs(nextRemaining);
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
messageStartedAtRef.current = now;
|
messageStartedAtRef.current = now;
|
||||||
setPhaseSafe("playing");
|
setPhaseSafe("playing");
|
||||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
|
@ -360,9 +444,10 @@ export function RadioTraffic({
|
||||||
(event: FormEvent<HTMLFormElement>) => {
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (phaseRef.current !== "playing" || submittingRef.current) return;
|
if (phaseRef.current !== "playing" || submittingRef.current) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now < submitBlockedUntilRef.current) return;
|
||||||
const target = messages[messageIndex];
|
const target = messages[messageIndex];
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const now = performance.now();
|
|
||||||
const timeMs = Math.max(1, now - messageStartedAtRef.current);
|
const timeMs = Math.max(1, now - messageStartedAtRef.current);
|
||||||
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
|
const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current;
|
||||||
const keyTotal = correctKeys + incorrectKeys;
|
const keyTotal = correctKeys + incorrectKeys;
|
||||||
|
|
@ -375,18 +460,34 @@ export function RadioTraffic({
|
||||||
accuracy,
|
accuracy,
|
||||||
correct: input === target,
|
correct: input === target,
|
||||||
};
|
};
|
||||||
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
if (!result.correct) {
|
||||||
lastCountdownTickRef.current = "";
|
incorrectMessagesRef.current += 1;
|
||||||
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
lastCountdownTickRef.current = "";
|
||||||
|
if (mode === "sprint") {
|
||||||
|
setIncorrectSprintSubmission({
|
||||||
|
target,
|
||||||
|
characters: alignReferenceCharacters(target, input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInput("");
|
||||||
|
playError();
|
||||||
|
setFeedback({ correct: false, message: "Signal mismatch. Try the transmission again." });
|
||||||
|
submitBlockedUntilRef.current = now + UNSUCCESSFUL_SUBMIT_COOLDOWN_MS;
|
||||||
|
if (mode === "watch" && now >= roundDeadlineRef.current) {
|
||||||
|
finishRound(results, mode, incorrectMessagesRef.current);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
lastCountdownTickRef.current = "";
|
||||||
const finalResults = [...results, result];
|
const finalResults = [...results, result];
|
||||||
|
setIncorrectSprintSubmission(null);
|
||||||
setResults(finalResults);
|
setResults(finalResults);
|
||||||
setInput("");
|
setInput("");
|
||||||
if (result.correct) {
|
playCorrect();
|
||||||
playCorrect();
|
setFeedback({ correct: true, message: "Transmission accepted." });
|
||||||
setFeedback({ correct: true, message: "Transmission accepted." });
|
|
||||||
} else {
|
|
||||||
playError();
|
|
||||||
setFeedback({ correct: false, message: "Signal mismatch. Next transmission incoming." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
|
const watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
|
||||||
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
|
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
|
||||||
|
|
@ -420,12 +521,17 @@ export function RadioTraffic({
|
||||||
|
|
||||||
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
|
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
|
||||||
const target = messages[messageIndex] ?? "";
|
const target = messages[messageIndex] ?? "";
|
||||||
|
const sprintReferenceDiff =
|
||||||
|
mode === "sprint" && incorrectSprintSubmission?.target === target
|
||||||
|
? incorrectSprintSubmission.characters
|
||||||
|
: null;
|
||||||
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
|
const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect;
|
||||||
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
|
const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0;
|
||||||
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
|
const provisionalScore = results.reduce((sum, result) => sum + result.score, 0);
|
||||||
const displayScore = lastRun?.score ?? provisionalScore;
|
const displayScore = lastRun?.score ?? provisionalScore;
|
||||||
const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results);
|
const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results);
|
||||||
const displayCorrect = lastRun?.correctMessages ?? results.filter((result) => result.correct).length;
|
const displayCorrect = lastRun?.correctMessages ?? results.length;
|
||||||
|
const displayIncorrect = lastRun?.incorrectMessages ?? incorrectMessagesRef.current;
|
||||||
const displayRelayed = lastRun?.relayedMessages ?? results.length;
|
const displayRelayed = lastRun?.relayedMessages ?? results.length;
|
||||||
const progress =
|
const progress =
|
||||||
displayMode === "sprint"
|
displayMode === "sprint"
|
||||||
|
|
@ -469,10 +575,10 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{(phase === "ready" || phase === "over") && (
|
{(phase === "ready" || phase === "over") && (
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2" role="group" aria-label="Radio traffic mode">
|
<div className="mt-3 grid grid-cols-2 gap-2" role="group" aria-label="Radio traffic mode">
|
||||||
<Button type="button" size="sm" variant={mode === "sprint" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("sprint")}>
|
<Button type="button" size="sm" variant={mode === "sprint" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("sprint")}>
|
||||||
Sprint · 10 messages
|
Sprint · 10 messages
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("watch")}>
|
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => onModeChangeAction("watch")}>
|
||||||
Watch · 60 seconds
|
Watch · 60 seconds
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -485,7 +591,30 @@ export function RadioTraffic({
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{phase === "playing" && <div className="flex flex-col gap-4 pt-4">
|
{phase === "playing" && <div className="flex flex-col gap-4 pt-4">
|
||||||
<div className="rounded-lg border border-border bg-background p-4"><p className="text-xs uppercase tracking-wider text-muted-foreground">Incoming transmission</p><p className="mt-3 break-words font-mono text-base leading-relaxed text-foreground">{target}</p></div>
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-muted-foreground">Incoming transmission</p>
|
||||||
|
<p
|
||||||
|
className="mt-3 break-words font-mono text-base leading-relaxed text-foreground"
|
||||||
|
aria-describedby={sprintReferenceDiff ? "radio-traffic-diff-description" : undefined}
|
||||||
|
>
|
||||||
|
{sprintReferenceDiff
|
||||||
|
? sprintReferenceDiff.map(({ character, matched }, index) => (
|
||||||
|
<span
|
||||||
|
key={`${index}-${character}`}
|
||||||
|
className={matched ? "text-emerald-500" : "text-destructive"}
|
||||||
|
>
|
||||||
|
{character}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: target}
|
||||||
|
</p>
|
||||||
|
{sprintReferenceDiff && (
|
||||||
|
<span id="radio-traffic-diff-description" className="sr-only" role="status">
|
||||||
|
Reference text is unchanged. Green characters match your last submission; red characters
|
||||||
|
differ. Typed-only characters are not shown.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<form className="flex flex-col gap-2" onSubmit={submitCurrent}>
|
<form className="flex flex-col gap-2" onSubmit={submitCurrent}>
|
||||||
<label htmlFor="radio-traffic-input" className="text-sm font-medium">Type exact transmission</label>
|
<label htmlFor="radio-traffic-input" className="text-sm font-medium">Type exact transmission</label>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
|
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
|
||||||
|
|
@ -497,7 +626,7 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{phase === "over" && <div className="flex flex-col items-center gap-4 py-8 text-center">
|
{phase === "over" && <div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||||
<div className="flex items-center gap-2 text-emerald-400"><Check className="size-5" /><span className="font-bold uppercase tracking-widest">{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}</span></div>
|
<div className="flex items-center gap-2 text-emerald-400"><Check className="size-5" /><span className="font-bold uppercase tracking-widest">{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}</span></div>
|
||||||
<div className="grid w-full grid-cols-2 gap-3 sm:grid-cols-4"><Metric label="Score" value={displayScore} /><Metric label="Accuracy" value={formatAccuracy(displayAccuracy)} /><Metric label="Messages" value={displayMode === "sprint" ? `${displayCorrect}/${SPRINT_MESSAGE_COUNT}` : displayRelayed} /><Metric label="Best" value={lastRun?.best ?? stats.bestScore} /></div>
|
<div className="grid w-full grid-cols-2 gap-3 sm:grid-cols-5"><Metric label="Score" value={displayScore} /><Metric label="Accuracy" value={formatAccuracy(displayAccuracy)} /><Metric label="Correct" value={displayMode === "sprint" ? `${displayCorrect}/${SPRINT_MESSAGE_COUNT}` : displayRelayed} /><Metric label="Incorrect" value={displayIncorrect} /><Metric label="Best" value={lastRun?.best ?? stats.bestScore} /></div>
|
||||||
{lastRun?.isNewBest && <Badge variant="outline" className="border-amber-400/40 text-amber-300">New best</Badge>}
|
{lastRun?.isNewBest && <Badge variant="outline" className="border-amber-400/40 text-amber-300">New best</Badge>}
|
||||||
{lastRun?.xpEarned ? <span className="text-sm font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span> : null}
|
{lastRun?.xpEarned ? <span className="text-sm font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span> : null}
|
||||||
{lastRun?.error && <p className="text-xs text-destructive">{lastRun.error}</p>}
|
{lastRun?.error && <p className="text-xs text-destructive">{lastRun.error}</p>}
|
||||||
|
|
@ -507,7 +636,7 @@ export function RadioTraffic({
|
||||||
|
|
||||||
{expired && <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-background/95 p-6 text-center"><span className="text-2xl font-black uppercase tracking-widest text-amber-400">Session Ending Soon</span><span className="max-w-[260px] text-xs text-muted-foreground">Your session is about to end. The app will refresh when it expires.</span><LoginLink className="mt-1"><Button type="button" size="sm">Log in</Button></LoginLink></div>}
|
{expired && <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-background/95 p-6 text-center"><span className="text-2xl font-black uppercase tracking-widest text-amber-400">Session Ending Soon</span><span className="max-w-[260px] text-xs text-muted-foreground">Your session is about to end. The app will refresh when it expires.</span><LoginLink className="mt-1"><Button type="button" size="sm">Log in</Button></LoginLink></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground"><div className="flex items-center gap-1.5"><span>Games</span><span className="font-semibold text-foreground">{stats.gamesPlayed}</span></div><div className="flex items-center gap-1.5"><span>Messages</span><span className="font-semibold text-foreground">{stats.totalMessages}</span></div><div className="flex items-center gap-1.5"><span>Best run</span><span className="font-semibold text-foreground">{stats.bestScore}</span></div><div className="flex items-center gap-1.5"><span>XP from traffic</span><span className="font-semibold text-foreground">{stats.radioTrafficXp}</span></div></div>
|
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground"><div className="flex items-center gap-1.5"><span>Games</span><span className="font-semibold text-foreground">{stats.gamesPlayed}</span></div><div className="flex items-center gap-1.5"><span>Correct messages</span><span className="font-semibold text-foreground">{stats.totalMessages}</span></div><div className="flex items-center gap-1.5"><span>Incorrect messages</span><span className="font-semibold text-foreground">{stats.incorrectMessages}</span></div><div className="flex items-center gap-1.5"><span>Best run</span><span className="font-semibold text-foreground">{stats.bestScore}</span></div><div className="flex items-center gap-1.5"><span>XP from traffic</span><span className="font-semibold text-foreground">{stats.radioTrafficXp}</span></div></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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_035544 from './20260903_035544';
|
||||||
import * as migration_20260903_044047 from './20260903_044047';
|
import * as migration_20260903_044047 from './20260903_044047';
|
||||||
import * as migration_20260903_053419 from './20260903_053419';
|
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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -118,6 +120,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260903_053419.up,
|
up: migration_20260903_053419.up,
|
||||||
down: migration_20260903_053419.down,
|
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?: {
|
radioTraffic?: {
|
||||||
gamesPlayed?: number | null;
|
gamesPlayed?: number | null;
|
||||||
totalMessages?: number | null;
|
totalMessages?: number | null;
|
||||||
|
incorrectMessages?: number | null;
|
||||||
bestScore?: number | null;
|
bestScore?: number | null;
|
||||||
bestAccuracy?: number | null;
|
bestAccuracy?: number | null;
|
||||||
bestSprintTimeMs?: 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;
|
xpEarned?: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -3805,9 +3816,20 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
| {
|
| {
|
||||||
gamesPlayed?: T;
|
gamesPlayed?: T;
|
||||||
totalMessages?: T;
|
totalMessages?: T;
|
||||||
|
incorrectMessages?: T;
|
||||||
bestScore?: T;
|
bestScore?: T;
|
||||||
bestAccuracy?: T;
|
bestAccuracy?: T;
|
||||||
bestSprintTimeMs?: 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;
|
xpEarned?: T;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue