Compare commits
10 commits
90195286c3
...
d65573358f
| Author | SHA1 | Date | |
|---|---|---|---|
| d65573358f | |||
| 2bff75a6de | |||
| 488fadd3dc | |||
| 02b073e494 | |||
| 30e94603fb | |||
| f98e9aaca5 | |||
| f71a10ce05 | |||
| 0d58b54977 | |||
| a11d310e8e | |||
| fe38054606 |
20 changed files with 63565 additions and 9 deletions
207
src/app/(frontend)/radio/actions.ts
Normal file
207
src/app/(frontend)/radio/actions.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
totalMessages: number;
|
||||||
|
bestScore: number;
|
||||||
|
bestAccuracy: number;
|
||||||
|
bestSprintTimeMs: number;
|
||||||
|
radioTrafficXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPRINT_MESSAGE_COUNT = 10;
|
||||||
|
const MAX_WATCH_MESSAGES = 30;
|
||||||
|
const MAX_SCORE_PER_GAME = 5000;
|
||||||
|
const MAX_SPRINT_TIME_MS = 600_000;
|
||||||
|
|
||||||
|
type RadioMode = "sprint" | "watch";
|
||||||
|
|
||||||
|
function isRadioMode(value: string): value is RadioMode {
|
||||||
|
return value === "sprint" || value === "watch";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitRadioResult(input: {
|
||||||
|
score: number;
|
||||||
|
messageCount: number;
|
||||||
|
correctMessages: number;
|
||||||
|
accuracy: number;
|
||||||
|
mode: RadioMode;
|
||||||
|
timeMs?: number;
|
||||||
|
}): Promise<ActionResult<RadioStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (!isRadioMode(input.mode)) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
const score = Math.floor(input.score);
|
||||||
|
if (!Number.isFinite(score) || score < 0 || score > MAX_SCORE_PER_GAME) {
|
||||||
|
return { success: false, error: "Invalid score." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.messageCount) ||
|
||||||
|
(input.mode === "sprint"
|
||||||
|
? input.messageCount !== SPRINT_MESSAGE_COUNT
|
||||||
|
: input.messageCount < 1 || input.messageCount > MAX_WATCH_MESSAGES)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.correctMessages) ||
|
||||||
|
input.correctMessages < 0 ||
|
||||||
|
input.correctMessages > input.messageCount
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
const sprintTime = input.timeMs;
|
||||||
|
if (
|
||||||
|
input.mode === "sprint" &&
|
||||||
|
(typeof sprintTime !== "number" || !Number.isFinite(sprintTime) || sprintTime <= 0 || sprintTime > MAX_SPRINT_TIME_MS)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, error: "No profile found for this user." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
minigames?: {
|
||||||
|
radioTraffic?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
totalMessages?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
bestAccuracy?: number;
|
||||||
|
bestSprintTimeMs?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.radioTraffic ?? {};
|
||||||
|
const runXp = Math.round(input.correctMessages * 0.1 * 10) / 10;
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const totalMessages = (current.totalMessages ?? 0) + input.correctMessages;
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const bestAccuracy = Math.round(Math.max(current.bestAccuracy ?? 0, input.accuracy) * 10000) / 10000;
|
||||||
|
const previousBestSprintTimeMs = current.bestSprintTimeMs ?? 0;
|
||||||
|
const bestSprintTimeMs =
|
||||||
|
input.mode === "sprint" && typeof sprintTime === "number"
|
||||||
|
? Math.min(previousBestSprintTimeMs > 0 ? previousBestSprintTimeMs : Infinity, Math.round(sprintTime))
|
||||||
|
: previousBestSprintTimeMs;
|
||||||
|
const radioTrafficXp = Math.round(((current.xpEarned ?? 0) + runXp) * 10) / 10;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
radioTraffic: {
|
||||||
|
gamesPlayed,
|
||||||
|
totalMessages,
|
||||||
|
bestScore,
|
||||||
|
bestAccuracy,
|
||||||
|
bestSprintTimeMs,
|
||||||
|
xpEarned: radioTrafficXp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: runXp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
const modeLabel = input.mode === "sprint" ? "sprint" : "watch";
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameRadioTraffic,
|
||||||
|
message: `Completed a radio traffic ${modeLabel} run with ${input.correctMessages}/${input.messageCount} messages accurate (+${runXp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
mode: input.mode,
|
||||||
|
score,
|
||||||
|
messageCount: input.messageCount,
|
||||||
|
correctMessages: input.correctMessages,
|
||||||
|
accuracy: input.accuracy,
|
||||||
|
xpEarned: runXp,
|
||||||
|
bestScore,
|
||||||
|
bestSprintTimeMs,
|
||||||
|
totalMessages,
|
||||||
|
radioTrafficXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
totalMessages,
|
||||||
|
bestScore,
|
||||||
|
bestAccuracy,
|
||||||
|
bestSprintTimeMs,
|
||||||
|
radioTrafficXp,
|
||||||
|
xpEarned: runXp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/(frontend)/radio/page.tsx
Normal file
63
src/app/(frontend)/radio/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Radio } from "lucide-react";
|
||||||
|
import { RadioArena } from "@/components/frontend/radio/RadioArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Radio Traffic — Polaris Task Force",
|
||||||
|
description: "Intercept and type incoming radio traffic fast + accurately.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RadioPage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login?returnTo=${encodeURIComponent("/radio")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
minigames: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const radioTraffic = profileRes.docs[0]?.progression?.minigames?.radioTraffic ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: radioTraffic.gamesPlayed ?? 0,
|
||||||
|
totalMessages: radioTraffic.totalMessages ?? 0,
|
||||||
|
bestScore: radioTraffic.bestScore ?? 0,
|
||||||
|
bestAccuracy: radioTraffic.bestAccuracy ?? 0,
|
||||||
|
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
||||||
|
radioTrafficXp: radioTraffic.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radio className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Radio Traffic</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Intercept and type incoming radio traffic fast + accurately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RadioArena initialStats={stats} currentUserId={user.id as number} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,9 @@ export const metadata = {
|
||||||
export type RosterMember = {
|
export type RosterMember = {
|
||||||
profileId: number;
|
profileId: number;
|
||||||
user: Pick<User, "id" | "displayName" | "username">;
|
user: Pick<User, "id" | "displayName" | "username">;
|
||||||
rank: Pick<Rank, "id" | "name" | "abbreviation"> & {
|
enlistmentDate: string | null;
|
||||||
|
xp: number;
|
||||||
|
rank: Pick<Rank, "id" | "name" | "abbreviation" | "_order"> & {
|
||||||
insigniaUrl?: string | null;
|
insigniaUrl?: string | null;
|
||||||
};
|
};
|
||||||
assignmentName?: string;
|
assignmentName?: string;
|
||||||
|
|
@ -46,6 +48,8 @@ export default async function RosterPage() {
|
||||||
select: {
|
select: {
|
||||||
user: true,
|
user: true,
|
||||||
rank: true,
|
rank: true,
|
||||||
|
dossier: true,
|
||||||
|
progression: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
payload.find({
|
payload.find({
|
||||||
|
|
@ -97,7 +101,15 @@ export default async function RosterPage() {
|
||||||
return {
|
return {
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
user: { id: user.id, displayName: user.displayName, username: user.username },
|
user: { id: user.id, displayName: user.displayName, username: user.username },
|
||||||
rank: { id: rank.id, name: rank.name, abbreviation: rank.abbreviation, insigniaUrl },
|
enlistmentDate: profile.dossier?.enlistmentDate ?? null,
|
||||||
|
xp: profile.progression?.xp ?? 0,
|
||||||
|
rank: {
|
||||||
|
id: rank.id,
|
||||||
|
name: rank.name,
|
||||||
|
abbreviation: rank.abbreviation,
|
||||||
|
_order: rank._order,
|
||||||
|
insigniaUrl,
|
||||||
|
},
|
||||||
assignmentName,
|
assignmentName,
|
||||||
assignmentType,
|
assignmentType,
|
||||||
assignmentCallsign,
|
assignmentCallsign,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { CollectionConfig } from "payload";
|
||||||
import { Award } from "@/payload-types";
|
import { Award } from "@/payload-types";
|
||||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
import { notifyAwardGrants } from "@/lib/awards";
|
import { notifyAwardGrants } from "@/lib/awards";
|
||||||
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
export const Profiles: CollectionConfig = {
|
export const Profiles: CollectionConfig = {
|
||||||
slug: "profiles",
|
slug: "profiles",
|
||||||
|
|
@ -121,6 +121,7 @@ export const Profiles: CollectionConfig = {
|
||||||
minigames?: {
|
minigames?: {
|
||||||
flappyBird?: { xpEarned?: number | null };
|
flappyBird?: { xpEarned?: number | null };
|
||||||
eodMinesweeper?: { xpEarned?: number | null };
|
eodMinesweeper?: { xpEarned?: number | null };
|
||||||
|
radioTraffic?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const awardsList = progressionData?.awards || [];
|
const awardsList = progressionData?.awards || [];
|
||||||
|
|
@ -133,6 +134,7 @@ export const Profiles: CollectionConfig = {
|
||||||
const scoreboard = progressionData?.scoreboard || {};
|
const scoreboard = progressionData?.scoreboard || {};
|
||||||
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
|
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
|
||||||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||||
|
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
await req.payload
|
await req.payload
|
||||||
|
|
@ -172,6 +174,7 @@ export const Profiles: CollectionConfig = {
|
||||||
xpAcc += flappyXp;
|
xpAcc += flappyXp;
|
||||||
// EOD Minesweeper: XP from cleared minefields
|
// EOD Minesweeper: XP from cleared minefields
|
||||||
xpAcc += eodXp;
|
xpAcc += eodXp;
|
||||||
|
xpAcc += radioTrafficXp;
|
||||||
|
|
||||||
return xpAcc;
|
return xpAcc;
|
||||||
},
|
},
|
||||||
|
|
@ -371,6 +374,48 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "radioTraffic",
|
||||||
|
label: "Radio Traffic",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "totalMessages",
|
||||||
|
label: "Total Messages",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestAccuracy",
|
||||||
|
label: "Best Accuracy",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestSprintTimeMs",
|
||||||
|
label: "Best Sprint Time (ms)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BadgeCheck, Bird, Bomb, ChevronsUpDown, FileTextIcon, LogOut, Users2 } from "lucide-react";
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Bird,
|
||||||
|
Bomb,
|
||||||
|
ChevronsUpDown,
|
||||||
|
FileTextIcon,
|
||||||
|
LogOut,
|
||||||
|
Radio,
|
||||||
|
Users2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -139,6 +148,12 @@ export function NavUser({
|
||||||
Minefield Clearance
|
Minefield Clearance
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/radio">
|
||||||
|
<Radio />
|
||||||
|
Radio Traffic
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
|
|
||||||
55
src/components/frontend/radio/RadioArena.tsx
Normal file
55
src/components/frontend/radio/RadioArena.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { RadioStats } from "@/app/(frontend)/radio/actions";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { RadioLeaderboard } from "./RadioLeaderboard";
|
||||||
|
import { RadioTraffic } from "./RadioTraffic";
|
||||||
|
|
||||||
|
export function RadioArena({
|
||||||
|
initialStats,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialStats: RadioStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}) {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const activeOperators = useRealtimePresence("radio", playing, true);
|
||||||
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
|
setPlaying(phase === "playing");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<RadioTraffic
|
||||||
|
initialStats={initialStats}
|
||||||
|
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
||||||
|
onPhaseChangeAction={onPhaseChange}
|
||||||
|
/>
|
||||||
|
<div className="w-full max-w-[520px]">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">How scoring works</h2>
|
||||||
|
<ul className="mt-1.5 flex flex-col gap-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||||
|
<li>
|
||||||
|
Accuracy tracks every keystroke; each wrong character or backspace counts against it. Each message scores accuracy × speed multiplier, up to ~120 points for a fast perfect message.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-foreground/80">Sprint:</span> clear exactly 10 messages as fast as possible; maximum score is 1200.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-foreground/80">Watch:</span> relay as many messages as possible in 60 seconds; score is unbounded in practice.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-foreground/80">XP:</span> earn 0.1 per perfectly-typed message.
|
||||||
|
</li>
|
||||||
|
<li>The lifetime leaderboard ranks Top Score, Top Accuracy, Fastest Sprint, and Most Messages.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<RadioLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeOperators={activeOperators}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
src/components/frontend/radio/RadioLeaderboard.tsx
Normal file
265
src/components/frontend/radio/RadioLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
|
||||||
|
type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
|
||||||
|
|
||||||
|
interface ApiUser {
|
||||||
|
id: number;
|
||||||
|
username?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
payloadDisplayName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiProfile {
|
||||||
|
id: number;
|
||||||
|
profileTitle?: string | null;
|
||||||
|
user?: ApiUser | number | null;
|
||||||
|
progression?: {
|
||||||
|
minigames?: {
|
||||||
|
radioTraffic?: {
|
||||||
|
totalMessages?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
bestAccuracy?: number | null;
|
||||||
|
bestSprintTimeMs?: number | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
totalMessages: number;
|
||||||
|
bestScore: number;
|
||||||
|
bestAccuracy: number;
|
||||||
|
bestSprintTimeMs: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabConfig {
|
||||||
|
readonly label: string;
|
||||||
|
readonly sort: string;
|
||||||
|
readonly matches: (row: LeaderboardRow) => boolean;
|
||||||
|
readonly empty: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadioLeaderboardProps {
|
||||||
|
refreshKey?: number;
|
||||||
|
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>;
|
||||||
|
|
||||||
|
function rankClass(index: number) {
|
||||||
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
if (index === 2) return "font-semibold text-amber-700";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
activeOperators = [],
|
||||||
|
}: RadioLeaderboardProps) {
|
||||||
|
const [tab, setTab] = useState<LeaderboardTab>("score");
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const tabConfig = TAB_CONFIG[tab];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
setRows(null);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" });
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
return res.json() as Promise<{ docs?: ApiProfile[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
|
||||||
|
const radioTraffic = doc.progression?.minigames?.radioTraffic ?? {};
|
||||||
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
|
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,
|
||||||
|
bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const visible = mapped.filter(tabConfig.matches).slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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">
|
||||||
|
{activeOperators.length} {activeOperators.length === 1 ? "operator" : "operators"} on frequency
|
||||||
|
</span>
|
||||||
|
{activeOperators.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeOperators.map((operator) => operator.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="flex flex-col gap-2 px-4 py-4">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
row.isOutsideTopTen
|
||||||
|
? "Operator status: not currently ranked in the top 10"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>{row.isOutsideTopTen ? "—" : index + 1}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[180px] truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{activeOperators.some((operator) => operator.id === row.id) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Transmitting now"
|
||||||
|
aria-label="Transmitting now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{row.bestScore.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.totalMessages}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
513
src/components/frontend/radio/RadioTraffic.tsx
Normal file
513
src/components/frontend/radio/RadioTraffic.tsx
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { FormEvent, KeyboardEvent } from "react";
|
||||||
|
import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions";
|
||||||
|
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { createRadioRound, nextRadioMessage, SPRINT_MESSAGE_COUNT } from "./messageGenerator";
|
||||||
|
import { playCountdownEnd, playCountdownTick, playCorrect, playError, playKeystroke, playRoundComplete, playXp } from "./sounds";
|
||||||
|
|
||||||
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
type Mode = "sprint" | "watch";
|
||||||
|
type MessageResult = {
|
||||||
|
readonly target: string;
|
||||||
|
readonly score: number;
|
||||||
|
readonly correctKeys: number;
|
||||||
|
readonly incorrectKeys: number;
|
||||||
|
readonly accuracy: number;
|
||||||
|
readonly correct: boolean;
|
||||||
|
};
|
||||||
|
type Feedback = { readonly correct: boolean; readonly message: string };
|
||||||
|
type LastRun = {
|
||||||
|
readonly mode: Mode;
|
||||||
|
readonly score: number;
|
||||||
|
readonly accuracy: number;
|
||||||
|
readonly correctMessages: number;
|
||||||
|
readonly relayedMessages: number;
|
||||||
|
readonly xpEarned: number;
|
||||||
|
readonly best: number;
|
||||||
|
readonly isNewBest: boolean;
|
||||||
|
readonly error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timeMs: number): string {
|
||||||
|
return `${(timeMs / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccuracy(accuracy: number): string {
|
||||||
|
return `${Math.round(accuracy * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background p-3">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioTraffic({
|
||||||
|
initialStats: serverStats,
|
||||||
|
onGameRecordedAction,
|
||||||
|
onPhaseChangeAction,
|
||||||
|
}: {
|
||||||
|
initialStats: RadioStats;
|
||||||
|
onGameRecordedAction: () => void;
|
||||||
|
onPhaseChangeAction: (phase: Phase) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
const roundStartedAtRef = useRef(0);
|
||||||
|
const roundDeadlineRef = useRef(0);
|
||||||
|
const messageStartedAtRef = useRef(0);
|
||||||
|
const remainingMsRef = useRef(0);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const statsRef = useRef<RadioStats>(serverStats);
|
||||||
|
const previousRoundLastMessageRef = useRef<string | null>(null);
|
||||||
|
const watchUsedRef = useRef<Set<string>>(new Set());
|
||||||
|
const watchExpiryNotifiedRef = useRef(false);
|
||||||
|
const keystrokesRef = useRef({ correct: 0, incorrect: 0 });
|
||||||
|
const lastCountdownTickRef = useRef("");
|
||||||
|
const countdownEndPlayedRef = useRef(false);
|
||||||
|
const tickSoundRef = useRef(true);
|
||||||
|
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);
|
||||||
|
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
|
||||||
|
return initialRound;
|
||||||
|
});
|
||||||
|
const [messageIndex, setMessageIndex] = useState(0);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [results, setResults] = useState<readonly MessageResult[]>([]);
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
const [remainingMs, setRemainingMs] = useState(0);
|
||||||
|
const [stats, setStats] = useState<RadioStats>(serverStats);
|
||||||
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const setPhaseSafe = useCallback(
|
||||||
|
(next: Phase) => {
|
||||||
|
phaseRef.current = next;
|
||||||
|
setPhase(next);
|
||||||
|
onPhaseChangeAction(next);
|
||||||
|
},
|
||||||
|
[onPhaseChangeAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
window.localStorage.setItem(TICK_SOUND_STORAGE_KEY, String(enabled));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]);
|
||||||
|
|
||||||
|
const recordResult = useCallback(
|
||||||
|
async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
const score = finalResults.reduce((sum, result) => sum + result.score, 0);
|
||||||
|
const correctMessages = finalResults.filter((result) => result.correct).length;
|
||||||
|
const accuracy = roundAccuracy(finalResults);
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setLastRun({
|
||||||
|
mode: resultMode,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
correctMessages,
|
||||||
|
relayedMessages: finalResults.length,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
isNewBest: false,
|
||||||
|
error: "Your session expired. Result not recorded.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
const result = await submitRadioResult({
|
||||||
|
score,
|
||||||
|
messageCount: finalResults.length,
|
||||||
|
correctMessages,
|
||||||
|
accuracy,
|
||||||
|
mode: resultMode,
|
||||||
|
timeMs: elapsedMs,
|
||||||
|
});
|
||||||
|
submittingRef.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const previousBest = statsRef.current.bestScore;
|
||||||
|
statsRef.current = result.data;
|
||||||
|
setStats(result.data);
|
||||||
|
onGameRecordedAction();
|
||||||
|
if (result.data.xpEarned > 0) {
|
||||||
|
playXp();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: result.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
setLastRun({
|
||||||
|
mode: resultMode,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
correctMessages,
|
||||||
|
relayedMessages: finalResults.length,
|
||||||
|
xpEarned: result.data.xpEarned,
|
||||||
|
best: result.data.bestScore,
|
||||||
|
isNewBest: score > previousBest,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLastRun({
|
||||||
|
mode: resultMode,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
correctMessages,
|
||||||
|
relayedMessages: finalResults.length,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
isNewBest: false,
|
||||||
|
error: result.error ?? "Could not record radio traffic.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expiredRef, onGameRecordedAction, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishRound = useCallback(
|
||||||
|
(finalResults: readonly MessageResult[], resultMode: Mode) => {
|
||||||
|
if (phaseRef.current !== "playing") return;
|
||||||
|
const lastResult = finalResults[finalResults.length - 1];
|
||||||
|
if (lastResult) previousRoundLastMessageRef.current = lastResult.target;
|
||||||
|
setPhaseSafe("over");
|
||||||
|
playRoundComplete();
|
||||||
|
if (resultMode === "watch" && finalResults.length === 0) {
|
||||||
|
setLastRun({
|
||||||
|
mode: "watch",
|
||||||
|
score: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
correctMessages: 0,
|
||||||
|
relayedMessages: 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
isNewBest: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined;
|
||||||
|
void recordResult(finalResults, resultMode, elapsedMs);
|
||||||
|
},
|
||||||
|
[recordResult, setPhaseSafe],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "playing") return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (mode === "sprint") {
|
||||||
|
setElapsedMs(now - roundStartedAtRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextRemaining = Math.max(0, roundDeadlineRef.current - now);
|
||||||
|
remainingMsRef.current = nextRemaining;
|
||||||
|
setRemainingMs(nextRemaining);
|
||||||
|
const tickIntensity =
|
||||||
|
nextRemaining <= CRITICAL_TICK_THRESHOLD_MS
|
||||||
|
? "critical"
|
||||||
|
: nextRemaining <= FAST_TICK_THRESHOLD_MS
|
||||||
|
? "fast"
|
||||||
|
: "normal";
|
||||||
|
const tickSlotMs = tickIntensity === "critical" ? 250 : tickIntensity === "fast" ? 500 : 1000;
|
||||||
|
const tickKey = `${tickIntensity}:${Math.floor(nextRemaining / tickSlotMs)}`;
|
||||||
|
if (tickKey !== lastCountdownTickRef.current) {
|
||||||
|
lastCountdownTickRef.current = tickKey;
|
||||||
|
if (tickSoundRef.current) playCountdownTick(tickIntensity);
|
||||||
|
}
|
||||||
|
if (nextRemaining === 0) {
|
||||||
|
if (!countdownEndPlayedRef.current) {
|
||||||
|
countdownEndPlayedRef.current = true;
|
||||||
|
if (tickSoundRef.current) playCountdownEnd();
|
||||||
|
}
|
||||||
|
if (inputRef.current?.value && !watchExpiryNotifiedRef.current) {
|
||||||
|
watchExpiryNotifiedRef.current = true;
|
||||||
|
setFeedback({ correct: false, message: "Time expired. Press Enter to log the current transmission." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [mode, phase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "watch" || phase !== "playing" || remainingMs > 0 || input.length > 0) return;
|
||||||
|
finishRound(results, "watch");
|
||||||
|
}, [finishRound, input.length, mode, phase, remainingMs, results]);
|
||||||
|
|
||||||
|
const rejectInsertion = useCallback(() => {
|
||||||
|
setFeedback({ correct: false, message: "Typing only. Paste and drop are disabled." });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRound = useCallback(() => {
|
||||||
|
if (expiredRef.current || submittingRef.current) return;
|
||||||
|
const now = performance.now();
|
||||||
|
let nextMessages: readonly string[];
|
||||||
|
watchUsedRef.current = new Set();
|
||||||
|
watchExpiryNotifiedRef.current = false;
|
||||||
|
countdownEndPlayedRef.current = false;
|
||||||
|
if (mode === "sprint") {
|
||||||
|
nextMessages = createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
|
||||||
|
previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null;
|
||||||
|
} else {
|
||||||
|
const firstMessage = nextRadioMessage(watchUsedRef.current);
|
||||||
|
watchUsedRef.current.add(firstMessage);
|
||||||
|
nextMessages = [firstMessage];
|
||||||
|
}
|
||||||
|
setMessages(nextMessages);
|
||||||
|
setMessageIndex(0);
|
||||||
|
setInput("");
|
||||||
|
setResults([]);
|
||||||
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
setElapsedMs(0);
|
||||||
|
const isWatch = mode === "watch";
|
||||||
|
const nextRemaining = isWatch ? WATCH_DURATION_MS : 0;
|
||||||
|
roundStartedAtRef.current = now;
|
||||||
|
roundDeadlineRef.current = isWatch ? now + WATCH_DURATION_MS : 0;
|
||||||
|
remainingMsRef.current = nextRemaining;
|
||||||
|
setRemainingMs(nextRemaining);
|
||||||
|
setLastRun(null);
|
||||||
|
setFeedback(null);
|
||||||
|
messageStartedAtRef.current = now;
|
||||||
|
setPhaseSafe("playing");
|
||||||
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}, [expiredRef, mode, setPhaseSafe]);
|
||||||
|
|
||||||
|
const handleKeystroke = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (phaseRef.current !== "playing") return;
|
||||||
|
const currentInput = inputRef.current?.value ?? "";
|
||||||
|
const currentTarget = messages[messageIndex] ?? "";
|
||||||
|
if (event.key === "Backspace") {
|
||||||
|
if (currentInput.length > 0) keystrokesRef.current.incorrect += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
const expected = currentTarget[currentInput.length];
|
||||||
|
if (expected !== undefined && event.key.toUpperCase() === expected) {
|
||||||
|
keystrokesRef.current.correct += 1;
|
||||||
|
} else {
|
||||||
|
keystrokesRef.current.incorrect += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[messages, messageIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitCurrent = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (phaseRef.current !== "playing" || submittingRef.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;
|
||||||
|
const accuracy = keyTotal > 0 ? correctKeys / keyTotal : 0;
|
||||||
|
const result: MessageResult = {
|
||||||
|
target,
|
||||||
|
score: messageScore(accuracy, timeMs),
|
||||||
|
correctKeys,
|
||||||
|
incorrectKeys,
|
||||||
|
accuracy,
|
||||||
|
correct: input === target,
|
||||||
|
};
|
||||||
|
keystrokesRef.current = { correct: 0, incorrect: 0 };
|
||||||
|
lastCountdownTickRef.current = "";
|
||||||
|
const finalResults = [...results, result];
|
||||||
|
setResults(finalResults);
|
||||||
|
setInput("");
|
||||||
|
if (result.correct) {
|
||||||
|
playCorrect();
|
||||||
|
setFeedback({ correct: true, message: "Transmission accepted." });
|
||||||
|
} else {
|
||||||
|
playError();
|
||||||
|
setFeedback({ correct: false, message: "Signal mismatch. Next transmission incoming." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchExpired = mode === "watch" && now >= roundDeadlineRef.current;
|
||||||
|
const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES;
|
||||||
|
const shouldFinish =
|
||||||
|
mode === "sprint"
|
||||||
|
? messageIndex + 1 >= messages.length
|
||||||
|
: watchExpired || watchLimitReached;
|
||||||
|
if (shouldFinish) {
|
||||||
|
if (mode === "watch") {
|
||||||
|
remainingMsRef.current = Math.max(0, roundDeadlineRef.current - now);
|
||||||
|
setRemainingMs(remainingMsRef.current);
|
||||||
|
} else {
|
||||||
|
setElapsedMs(now - roundStartedAtRef.current);
|
||||||
|
}
|
||||||
|
finishRound(finalResults, mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "watch") {
|
||||||
|
watchUsedRef.current.add(target);
|
||||||
|
const nextTarget = nextRadioMessage(watchUsedRef.current);
|
||||||
|
watchUsedRef.current.add(nextTarget);
|
||||||
|
setMessages((current) => [...current, nextTarget]);
|
||||||
|
}
|
||||||
|
setMessageIndex((index) => index + 1);
|
||||||
|
messageStartedAtRef.current = now;
|
||||||
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
},
|
||||||
|
[finishRound, input, messageIndex, messages, mode, results],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
|
||||||
|
const target = messages[messageIndex] ?? "";
|
||||||
|
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 displayRelayed = lastRun?.relayedMessages ?? results.length;
|
||||||
|
const progress =
|
||||||
|
displayMode === "sprint"
|
||||||
|
? ((phase === "over" ? messages.length : messageIndex) / messages.length) * 100
|
||||||
|
: Math.min(100, Math.max(0, ((WATCH_DURATION_MS - remainingMs) / WATCH_DURATION_MS) * 100));
|
||||||
|
const headerLabel =
|
||||||
|
phase === "ready"
|
||||||
|
? "Frequency standby"
|
||||||
|
: displayMode === "watch"
|
||||||
|
? `Relayed ${displayRelayed}`
|
||||||
|
: `Message ${Math.min(messageIndex + 1, messages.length)} of ${messages.length}`;
|
||||||
|
const modeSelectorDisabled = phase === "playing";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-[620px] flex-col items-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Radio className="size-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold uppercase tracking-widest">Radio Traffic</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Intercept clean transmissions under pressure.</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full rounded-xl border border-border bg-card p-4 shadow-lg sm:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5"><Target className="size-3.5" />{headerLabel}</span>
|
||||||
|
<span className="flex items-center gap-1.5 font-mono tabular-nums"><Clock3 className="size-3.5" />{formatTime(displayMode === "watch" ? remainingMs : elapsedMs)}</span>
|
||||||
|
</div>
|
||||||
|
{displayMode === "sprint" && (
|
||||||
|
<Progress value={progress} aria-label={`Radio traffic progress: ${messageIndex} of ${messages.length} messages`} className="mt-3" />
|
||||||
|
)}
|
||||||
|
{displayMode === "watch" && (
|
||||||
|
<Progress value={progress} aria-label={`Watch time elapsed: ${formatTime(WATCH_DURATION_MS - remainingMs)}`} className="mt-3" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<label htmlFor="radio-tick-sound" title="Only plays during Watch mode countdowns" className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Volume2 className="size-3.5" /> Countdown tick sound
|
||||||
|
</label>
|
||||||
|
<Switch id="radio-tick-sound" checked={tickSoundEnabled} onCheckedChange={handleTickSoundChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(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")}>
|
||||||
|
Sprint · 10 messages
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant={mode === "watch" ? "secondary" : "outline"} disabled={modeSelectorDisabled} onClick={() => setMode("watch")}>
|
||||||
|
Watch · 60 seconds
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "ready" && <div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<div className="rounded-full border border-border bg-background p-4 text-emerald-400"><Radio className="size-8 animate-pulse" /></div>
|
||||||
|
<div className="flex flex-col gap-1"><p className="font-semibold uppercase tracking-wider">Incoming traffic queued</p><p className="text-sm text-muted-foreground">Type each transmission exactly, then press Enter to relay it.</p></div>
|
||||||
|
<Button type="button" onClick={startRound} disabled={expired}>Start intercept</Button>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
|
||||||
|
</form>
|
||||||
|
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
|
||||||
|
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
{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>}
|
||||||
|
{submitting && <p className="text-xs text-muted-foreground">Recording result…</p>}
|
||||||
|
<Button type="button" onClick={startRound} disabled={submitting || expired}>Play Again</Button>
|
||||||
|
</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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/frontend/radio/messageGenerator.ts
Normal file
55
src/components/frontend/radio/messageGenerator.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
type SlotName = "CALLSIGN" | "NUMBER" | "ACTION" | "LOCATION" | "STATUS" | "CLOSING";
|
||||||
|
type MessageSlot = { readonly name: SlotName; readonly separator: string; readonly options: readonly string[] };
|
||||||
|
|
||||||
|
export const SPRINT_MESSAGE_COUNT = 10;
|
||||||
|
const MESSAGE_SLOTS = [
|
||||||
|
{
|
||||||
|
name: "CALLSIGN",
|
||||||
|
separator: "",
|
||||||
|
options: ["ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHO", "FOXTROT", "VIKING", "SABER", "EAGLE", "RAVEN", "TIGER", "HAMMER", "WOLF", "FALCON", "ORION", "SENTINEL", "GHOST", "VIPER", "COBRA", "JAGUAR", "REAPER", "TITAN", "ATLAS", "COMET"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NUMBER",
|
||||||
|
separator: " ",
|
||||||
|
options: ["ACTUAL", "ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ACTION",
|
||||||
|
separator: ", ",
|
||||||
|
options: ["EN ROUTE TO", "HOLD AT", "APPROACHING", "REQUESTING EVAC FROM", "MOVING TO", "REPORTING FROM", "CONTACT AT", "SECURE", "SWEEP AND CLEAR", "MOVE TO DEFEND", "REGROUP AT", "DIVERT TO", "REQUESTING FIRE MISSION AT", "PICKING UP AT", "DROPPING OFF AT", "AWAITING ORDERS AT", "PROCEED TO", "FALL BACK TO", "CONVERGE ON", "SCREENING AT"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LOCATION",
|
||||||
|
separator: " ",
|
||||||
|
options: ["GRID 341-512", "GRID BRAVO ONE SIX", "SECTOR K-7", "CHECKPOINT ALPHA", "CHECKPOINT CHARLIE", "OVERWATCH POINT", "NORTH RIDGELINE", "OBJECTIVE DELTA", "SUPPLY ROAD", "EASTERN APPROACH", "SOUTH TRENCHLINE", "SITE 19", "FOB LIMA", "EGRESS", "WATCHPOST", "MAIN GATE", "HELI PAD", "BRIDGE CROSSING", "WESTERN VALLEY", "HILL 402", "RIVERBEND", "OLD MINE ROAD", "STAGING AREA", "PERIMETER LIMA"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "STATUS",
|
||||||
|
separator: ", ",
|
||||||
|
options: ["STATUS GREEN", "STATUS AMBER", "WEAPONS FREE", "UNDER CONTACT", "NEED MEDICAL", "ALL CLEAR", "FUEL STATE GREEN", "PACKAGE SECURE", "WE HAVE A MAN DOWN", "SITUATION CRITICAL", "LOST VISUAL", "CONTACT LOST", "RADAR CONTACTS DETECTED", "HOSTILES INBOUND", "AMMO STATE LOW", "RUNNING LOW ON FUEL", "NEED REINFORCEMENTS", "HOLDING POSITION", "ENEMY ACTIVITY INCREASED", "MEDICAL EXTRACTION READY", "PERIMETER SECURE", "NO MOVEMENT DETECTED"],
|
||||||
|
},
|
||||||
|
{ name: "CLOSING", separator: ", ", options: ["OVER", "DO YOU COPY? OVER", "HOW COPY? OVER", "REQUESTING READBACK, OVER", "OUT", "STAND BY, OVER", "AWAITING YOUR CALL, OVER", "READY WHEN YOU ARE, OVER", "SEND IT WHEN READY, OVER", "WILL WAIT FOR YOUR WORD, OVER", "STANDING BY FOR ORDERS, OVER"] },
|
||||||
|
] as const satisfies readonly MessageSlot[];
|
||||||
|
|
||||||
|
function composeMessage(): string {
|
||||||
|
return `${MESSAGE_SLOTS.map((slot) => `${slot.separator}${slot.options[Math.floor(Math.random() * slot.options.length)] ?? ""}`).join("")}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextRadioMessage(used: ReadonlySet<string>): string {
|
||||||
|
let message = composeMessage();
|
||||||
|
while (used.has(message)) message = composeMessage();
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRadioRound(
|
||||||
|
previousMessage: string | null = null,
|
||||||
|
count: number = SPRINT_MESSAGE_COUNT,
|
||||||
|
): readonly string[] {
|
||||||
|
const messages = new Set<string>();
|
||||||
|
while (messages.size < count) {
|
||||||
|
const message = nextRadioMessage(messages);
|
||||||
|
if (message === previousMessage && messages.size < count - 1) continue;
|
||||||
|
messages.add(message);
|
||||||
|
}
|
||||||
|
return [...messages];
|
||||||
|
}
|
||||||
122
src/components/frontend/radio/sounds.ts
Normal file
122
src/components/frontend/radio/sounds.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
function getCtx(): AudioContext | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const AudioCtor =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AudioCtor) return null;
|
||||||
|
if (!audioCtx) audioCtx = new AudioCtor();
|
||||||
|
if (audioCtx.state === "suspended") {
|
||||||
|
void audioCtx.resume();
|
||||||
|
}
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToneOptions = {
|
||||||
|
readonly frequency: number;
|
||||||
|
readonly endFrequency?: number;
|
||||||
|
readonly duration: number;
|
||||||
|
readonly type?: OscillatorType;
|
||||||
|
readonly volume?: number;
|
||||||
|
readonly delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function playTone(ctx: AudioContext, opts: ToneOptions) {
|
||||||
|
const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = opts;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
const t0 = ctx.currentTime + delay;
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.setValueAtTime(Math.max(frequency, 1), t0);
|
||||||
|
if (endFrequency && endFrequency !== frequency) {
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(Math.max(endFrequency, 1), t0 + duration);
|
||||||
|
}
|
||||||
|
gain.gain.setValueAtTime(0.0001, t0);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.012);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(t0);
|
||||||
|
osc.stop(t0 + duration + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNoise(
|
||||||
|
ctx: AudioContext,
|
||||||
|
opts: { readonly duration: number; readonly volume?: number; readonly delay?: number; readonly lowpass?: number },
|
||||||
|
) {
|
||||||
|
const { duration, volume = 0.2, delay = 0, lowpass = 800 } = opts;
|
||||||
|
const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration));
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i += 1) {
|
||||||
|
data[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = "lowpass";
|
||||||
|
filter.frequency.setValueAtTime(lowpass, ctx.currentTime + delay);
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
const t0 = ctx.currentTime + delay;
|
||||||
|
gain.gain.setValueAtTime(0.0001, t0);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.005);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
source.start(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playKeystroke() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 520, duration: 0.025, type: "square", volume: 0.025 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCorrect() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 620, endFrequency: 920, duration: 0.08, type: "sine", volume: 0.12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playError() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 150, endFrequency: 80, duration: 0.16, type: "square", volume: 0.14 });
|
||||||
|
playNoise(ctx, { duration: 0.1, volume: 0.08, lowpass: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playRoundComplete() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 523, duration: 0.1, type: "sine", volume: 0.16 });
|
||||||
|
playTone(ctx, { frequency: 659, duration: 0.1, type: "sine", volume: 0.16, delay: 0.1 });
|
||||||
|
playTone(ctx, { frequency: 784, duration: 0.18, type: "sine", volume: 0.18, delay: 0.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playXp() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 });
|
||||||
|
playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCountdownTick(intensity: "normal" | "fast" | "critical") {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
if (intensity === "normal") {
|
||||||
|
playTone(ctx, { frequency: 880, duration: 0.03, type: "square", volume: 0.05 });
|
||||||
|
} else if (intensity === "fast") {
|
||||||
|
playTone(ctx, { frequency: 1046, duration: 0.03, type: "square", volume: 0.07 });
|
||||||
|
} else {
|
||||||
|
playTone(ctx, { frequency: 1318, duration: 0.045, type: "square", volume: 0.11 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCountdownEnd() {
|
||||||
|
const ctx = getCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
playTone(ctx, { frequency: 1318, endFrequency: 240, duration: 0.75, type: "sine", volume: 0.16 });
|
||||||
|
playTone(ctx, { frequency: 659, endFrequency: 120, duration: 0.75, type: "triangle", volume: 0.1 });
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,14 @@ import Link from "next/link";
|
||||||
import { cn, pluralize } from "@/lib/utils";
|
import { cn, pluralize } from "@/lib/utils";
|
||||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
|
import {
|
||||||
|
Building2Icon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
CrownIcon,
|
||||||
|
ListIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||||||
|
|
||||||
|
|
@ -102,7 +109,7 @@ function OrgChartView({
|
||||||
const assignedIds = new Set(
|
const assignedIds = new Set(
|
||||||
assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean),
|
assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean),
|
||||||
);
|
);
|
||||||
const unassigned = members.filter((m) => !assignedIds.has(m.user.id));
|
const unassigned = members.filter((m) => !assignedIds.has(m.user.id)).sort(rosterMemberOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
@ -137,7 +144,9 @@ function OrgChartView({
|
||||||
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
||||||
const allMembers = collectAllMembers(node);
|
const allMembers = collectAllMembers(node);
|
||||||
const leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null;
|
const leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null;
|
||||||
const directMembers = node.members.filter((m) => m.user.id !== node.leaderId);
|
const directMembers = node.members
|
||||||
|
.filter((m) => m.user.id !== node.leaderId)
|
||||||
|
.sort(rosterMemberOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
|
@ -346,17 +355,134 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completedServiceYears(enlistmentDate: string | null): number | null {
|
||||||
|
if (!enlistmentDate) return null;
|
||||||
|
|
||||||
|
const enlistment = new Date(enlistmentDate);
|
||||||
|
if (Number.isNaN(enlistment.getTime())) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let years = now.getUTCFullYear() - enlistment.getUTCFullYear();
|
||||||
|
const anniversaryMonth = enlistment.getUTCMonth();
|
||||||
|
const anniversaryDay = enlistment.getUTCDate();
|
||||||
|
const anniversaryPassed =
|
||||||
|
now.getUTCMonth() > anniversaryMonth ||
|
||||||
|
(now.getUTCMonth() === anniversaryMonth && now.getUTCDate() >= anniversaryDay);
|
||||||
|
|
||||||
|
if (!anniversaryPassed) years -= 1;
|
||||||
|
return Math.max(0, years);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterMemberOrder(a: RosterMember, b: RosterMember): number {
|
||||||
|
const aRankOrder = a.rank._order ?? null;
|
||||||
|
const bRankOrder = b.rank._order ?? null;
|
||||||
|
const aMissingRank = aRankOrder === null;
|
||||||
|
const bMissingRank = bRankOrder === null;
|
||||||
|
|
||||||
|
if (aMissingRank && !bMissingRank) return 1;
|
||||||
|
if (!aMissingRank && bMissingRank) return -1;
|
||||||
|
if (aRankOrder !== null && bRankOrder !== null && aRankOrder !== bRankOrder) {
|
||||||
|
return bRankOrder.localeCompare(aRankOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aYears = completedServiceYears(a.enlistmentDate);
|
||||||
|
const bYears = completedServiceYears(b.enlistmentDate);
|
||||||
|
if (aYears === null && bYears !== null) return 1;
|
||||||
|
if (aYears !== null && bYears === null) return -1;
|
||||||
|
if (aYears !== null && bYears !== null && aYears !== bYears) return bYears - aYears;
|
||||||
|
|
||||||
|
if (a.xp !== b.xp) return b.xp - a.xp;
|
||||||
|
return a.user.displayName.localeCompare(b.user.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeniorityView({ members }: { members: RosterMember[] }) {
|
||||||
|
const sorted = [...members].sort((a, b) => {
|
||||||
|
const aTime = a.enlistmentDate ? new Date(a.enlistmentDate).getTime() : Number.NaN;
|
||||||
|
const bTime = b.enlistmentDate ? new Date(b.enlistmentDate).getTime() : Number.NaN;
|
||||||
|
const aMissing = Number.isNaN(aTime);
|
||||||
|
const bMissing = Number.isNaN(bTime);
|
||||||
|
|
||||||
|
if (aMissing && !bMissing) return 1;
|
||||||
|
if (!aMissing && bMissing) return -1;
|
||||||
|
if (!aMissing && !bMissing && aTime !== bTime) return aTime - bTime;
|
||||||
|
return a.user.displayName.localeCompare(b.user.displayName);
|
||||||
|
});
|
||||||
|
const groups = new Map<number | "unknown", RosterMember[]>();
|
||||||
|
for (const member of sorted) {
|
||||||
|
const years = completedServiceYears(member.enlistmentDate);
|
||||||
|
const group = years === null ? "unknown" : years;
|
||||||
|
groups.set(group, [...(groups.get(group) ?? []), member]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
||||||
|
Seniority by Years of Service
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Longest-serving members appear first.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.size > 0 ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{[...groups.entries()].map(([years, groupMembers]) => (
|
||||||
|
<section key={years} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-baseline gap-2 border-b border-border/60 pb-1">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||||
|
{years === 0
|
||||||
|
? "Less than a year of service"
|
||||||
|
: years === "unknown"
|
||||||
|
? "Service history unavailable"
|
||||||
|
: `${years} ${years === 1 ? "year" : "years"} of service`}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{groupMembers.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{groupMembers.map((member, index) => (
|
||||||
|
<div
|
||||||
|
key={member.profileId}
|
||||||
|
className={cn("rounded-md p-0.5", index % 2 === 0 && "bg-muted/20")}
|
||||||
|
>
|
||||||
|
<MemberCard member={member} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No roster members found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Member Card ────────────────────────────────────────────────────
|
// ─── Member Card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MemberCard({ member }: { member: RosterMember }) {
|
function MemberCard({ member }: { member: RosterMember }) {
|
||||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||||
|
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||||||
|
const serviceLabel =
|
||||||
|
serviceYears === null
|
||||||
|
? null
|
||||||
|
: serviceYears === 0
|
||||||
|
? "Less than a year"
|
||||||
|
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${member.user.username}`}
|
href={`/profile/${member.user.username}`}
|
||||||
className="flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
|
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
|
||||||
>
|
>
|
||||||
|
{serviceLabel && (
|
||||||
|
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||||||
|
{serviceLabel} of service
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{member.rank.insigniaUrl && (
|
{member.rank.insigniaUrl && (
|
||||||
<img
|
<img
|
||||||
src={member.rank.insigniaUrl}
|
src={member.rank.insigniaUrl}
|
||||||
|
|
@ -417,6 +543,10 @@ export function RosterView({
|
||||||
<ListIcon className="size-3" />
|
<ListIcon className="size-3" />
|
||||||
A–Z
|
A–Z
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="seniority" className="gap-1.5">
|
||||||
|
<CalendarDaysIcon className="size-3" />
|
||||||
|
Years of Service
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="org" className="overflow-x-scroll">
|
<TabsContent value="org" className="overflow-x-scroll">
|
||||||
|
|
@ -431,6 +561,9 @@ export function RosterView({
|
||||||
<TabsContent value="alpha">
|
<TabsContent value="alpha">
|
||||||
<AlphabeticalView members={members} />
|
<AlphabeticalView members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="seniority">
|
||||||
|
<SeniorityView members={members} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</RosterDisplayContext.Provider>
|
</RosterDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
20658
src/migrations/20260903_035544.json
Normal file
20658
src/migrations/20260903_035544.json
Normal file
File diff suppressed because it is too large
Load diff
17
src/migrations/20260903_035544.ts
Normal file
17
src/migrations/20260903_035544.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_games_played" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_total_messages" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_best_score" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_xp_earned" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_games_played";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_total_messages";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_best_score";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_xp_earned";`)
|
||||||
|
}
|
||||||
20665
src/migrations/20260903_044047.json
Normal file
20665
src/migrations/20260903_044047.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260903_044047.ts
Normal file
11
src/migrations/20260903_044047.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_best_accuracy" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_best_accuracy";`)
|
||||||
|
}
|
||||||
20672
src/migrations/20260903_053419.json
Normal file
20672
src/migrations/20260903_053419.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260903_053419.ts
Normal file
11
src/migrations/20260903_053419.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_radio_traffic_best_sprint_time_ms" numeric DEFAULT 0;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_radio_traffic_best_sprint_time_ms";`)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,9 @@ import * as migration_20260901_014006_add_assignment_transfer_event_target from
|
||||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
||||||
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
|
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
|
||||||
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
||||||
|
import * as migration_20260903_035544 from './20260903_035544';
|
||||||
|
import * as migration_20260903_044047 from './20260903_044047';
|
||||||
|
import * as migration_20260903_053419 from './20260903_053419';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -100,6 +103,21 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260902_195336_add_medal_backgrounds.up,
|
up: migration_20260902_195336_add_medal_backgrounds.up,
|
||||||
down: migration_20260902_195336_add_medal_backgrounds.down,
|
down: migration_20260902_195336_add_medal_backgrounds.down,
|
||||||
name: '20260902_195336_add_medal_backgrounds'
|
name: '20260902_195336_add_medal_backgrounds',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260903_035544.up,
|
||||||
|
down: migration_20260903_035544.down,
|
||||||
|
name: '20260903_035544',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260903_044047.up,
|
||||||
|
down: migration_20260903_044047.down,
|
||||||
|
name: '20260903_044047',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260903_053419.up,
|
||||||
|
down: migration_20260903_053419.down,
|
||||||
|
name: '20260903_053419'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1784,6 +1784,14 @@ export interface Profile {
|
||||||
bestPerimeterFlags?: number | null;
|
bestPerimeterFlags?: number | null;
|
||||||
bestSupplyRoadFlags?: number | null;
|
bestSupplyRoadFlags?: number | null;
|
||||||
};
|
};
|
||||||
|
radioTraffic?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
totalMessages?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
bestAccuracy?: number | null;
|
||||||
|
bestSprintTimeMs?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -3792,6 +3800,16 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
bestPerimeterFlags?: T;
|
bestPerimeterFlags?: T;
|
||||||
bestSupplyRoadFlags?: T;
|
bestSupplyRoadFlags?: T;
|
||||||
};
|
};
|
||||||
|
radioTraffic?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
totalMessages?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
bestAccuracy?: T;
|
||||||
|
bestSprintTimeMs?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export const EventTypes = {
|
||||||
// Minigames
|
// Minigames
|
||||||
MinigameFlappy: "minigame:flappy",
|
MinigameFlappy: "minigame:flappy",
|
||||||
MinigameEod: "minigame:eod",
|
MinigameEod: "minigame:eod",
|
||||||
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
|
|
||||||
// Market
|
// Market
|
||||||
MarketListingCreate: "market:listing-create",
|
MarketListingCreate: "market:listing-create",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue