diff --git a/src/app/(frontend)/radio/custom/[id]/page.tsx b/src/app/(frontend)/radio/custom/[id]/page.tsx
new file mode 100644
index 0000000..2f75189
--- /dev/null
+++ b/src/app/(frontend)/radio/custom/[id]/page.tsx
@@ -0,0 +1,70 @@
+import type { Metadata } from "next";
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { headers as nextHeaders } from "next/headers";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Radio } from "lucide-react";
+import { CustomRadioArena } from "@/components/frontend/radio/CustomRadioArena";
+
+export const metadata: Metadata = {
+ title: "Play Custom Radio Test (Polaris Task Force)",
+};
+
+export default async function CustomRadioTestPlayPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const testId = Number(id);
+ if (!Number.isInteger(testId) || testId <= 0) {
+ notFound();
+ }
+
+ const headers = await nextHeaders();
+ const payloadConfig = await config;
+ const payload = await getPayload({ config: payloadConfig });
+ const { user } = await payload.auth({ headers, canSetHeaders: false });
+
+ const test = await payload
+ .findByID({
+ collection: "custom-radio-tests",
+ id: testId,
+ depth: 1,
+ })
+ .catch(() => null);
+ if (!test || !test.published) {
+ notFound();
+ }
+
+ const messages = Array.isArray(test.messages) ? (test.messages as string[]) : [];
+ const order = test.order === "shuffled" ? "shuffled" : "fixed";
+
+ return (
+
+
+
+
All custom radio tests
+
+
+
+
+
+
{test.name}
+
+
+ Custom radio tests are unranked and do not affect official stats or XP.
+
+
+
+
+
+ );
+}
diff --git a/src/app/(frontend)/radio/custom/actions.ts b/src/app/(frontend)/radio/custom/actions.ts
new file mode 100644
index 0000000..2451d20
--- /dev/null
+++ b/src/app/(frontend)/radio/custom/actions.ts
@@ -0,0 +1,342 @@
+"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 { hasPermission } from "@/utils/access-control/hasPermission";
+import {
+ isRadioUnlocked,
+ isValidRadioOrder,
+ validateMessages,
+ validateRadioScore,
+ validateTestName,
+} from "@/lib/custom-minigames/radio";
+
+interface ActionResult {
+ success: boolean;
+ error?: string;
+ data?: T;
+}
+
+export interface RadioUnlockStatus {
+ unlocked: boolean;
+ sprintBestAccuracy: number;
+ watchBestAccuracy: number;
+}
+
+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 checkRadioUnlock(): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ const profileRes = await payload.find({
+ collection: "profiles",
+ where: { user: { equals: user.id as number } },
+ limit: 1,
+ depth: 0,
+ overrideAccess: true,
+ });
+ const radio =
+ profileRes.docs[0]?.progression?.minigames?.radioTraffic ?? {};
+ const sprintBestAccuracy = radio.sprintBestAccuracy ?? 0;
+ const watchBestAccuracy = radio.watchBestAccuracy ?? 0;
+
+ return {
+ success: true,
+ data: {
+ unlocked: isRadioUnlocked({ sprintBestAccuracy, watchBestAccuracy }),
+ sprintBestAccuracy,
+ watchBestAccuracy,
+ },
+ };
+ } 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 };
+ }
+}
+
+export async function createTest(input: {
+ name: string;
+ messages: unknown;
+ order: string;
+}): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ const nameCheck = validateTestName(input.name);
+ if (nameCheck.error) {
+ return { success: false, error: nameCheck.error };
+ }
+ const name = nameCheck.name;
+ const messageCheck = validateMessages(input.messages);
+ if (messageCheck.error) {
+ return { success: false, error: messageCheck.error };
+ }
+ if (!isValidRadioOrder(input.order)) {
+ return { success: false, error: "Invalid order." };
+ }
+
+ const created = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name,
+ author: user.id as number,
+ messages: messageCheck.messages,
+ messageCount: messageCheck.messages.length,
+ order: input.order,
+ published: true,
+ playCount: 0,
+ },
+ depth: 0,
+ });
+
+ await emitGameEvent(payload, {
+ type: EventTypes.MinigameCustomRadioCreate,
+ message: `Published custom radio test "${name}" (${messageCheck.messages.length} messages)`,
+ actor: user.id as number,
+ targetCollection: "custom-radio-tests",
+ targetId: created.id,
+ });
+
+ return { success: true, data: { id: created.id } };
+ } 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 };
+ }
+}
+
+export async function updateTest(input: {
+ id: number;
+ name?: string;
+ messages?: unknown;
+ published?: boolean;
+}): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ if (!Number.isInteger(input.id) || (input.id as number) <= 0) {
+ return { success: false, error: "Invalid test." };
+ }
+
+ const existing = await payload.findByID({
+ collection: "custom-radio-tests",
+ id: input.id as number,
+ depth: 0,
+ });
+ if (!existing) {
+ return { success: false, error: "Test not found." };
+ }
+
+ const isManager = await hasPermission(payload, user, "custom-radio-tests:update");
+ const isAuthor = existing.author === (user.id as number);
+ if (!isManager && !isAuthor) {
+ return { success: false, error: "You can only edit your own tests." };
+ }
+
+ const data: Record = {};
+
+ if (input.name !== undefined) {
+ const nameCheck = validateTestName(input.name);
+ if (nameCheck.error) {
+ return { success: false, error: nameCheck.error };
+ }
+ data.name = nameCheck.name;
+ }
+ if (input.published !== undefined) {
+ data.published = Boolean(input.published);
+ }
+ if (input.messages !== undefined) {
+ const messageCheck = validateMessages(input.messages);
+ if (messageCheck.error) {
+ return { success: false, error: messageCheck.error };
+ }
+ data.messages = messageCheck.messages;
+ data.messageCount = messageCheck.messages.length;
+ }
+
+ await payload.update({
+ collection: "custom-radio-tests",
+ id: existing.id,
+ data,
+ depth: 0,
+ overrideAccess: true,
+ });
+
+ await emitGameEvent(payload, {
+ type: EventTypes.MinigameCustomRadioEdit,
+ message: `Updated custom radio test "${existing.name}"`,
+ actor: user.id as number,
+ targetCollection: "custom-radio-tests",
+ targetId: existing.id,
+ });
+
+ return { success: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ if (message === "Not Found" || message.includes("not found")) {
+ return { success: false, error: "Test not found." };
+ }
+ return { success: false, error: message };
+ }
+}
+
+export async function deleteTest(input: { id: number }): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ if (!Number.isInteger(input.id) || (input.id as number) <= 0) {
+ return { success: false, error: "Invalid test." };
+ }
+
+ const existing = await payload.findByID({
+ collection: "custom-radio-tests",
+ id: input.id as number,
+ depth: 0,
+ });
+ if (!existing) {
+ return { success: false, error: "Test not found." };
+ }
+
+ await payload.delete({ collection: "custom-radio-tests", id: existing.id, depth: 0 });
+
+ await payload.delete({
+ collection: "custom-radio-test-scores",
+ where: { test: { equals: existing.id } },
+ overrideAccess: true,
+ });
+
+ await emitGameEvent(payload, {
+ type: EventTypes.MinigameCustomRadioDelete,
+ message: `Deleted custom radio test "${existing.name}"`,
+ actor: user.id as number,
+ targetCollection: "custom-radio-tests",
+ targetId: existing.id,
+ });
+
+ return { success: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ if (message === "Forbidden" || message.toLowerCase().includes("forbidden")) {
+ return { success: false, error: "You do not have permission to delete tests." };
+ }
+ if (message === "Not Found" || message.includes("not found")) {
+ return { success: false, error: "Test not found." };
+ }
+ return { success: false, error: message };
+ }
+}
+
+export async function submitCustomTestScore(input: {
+ testId: number;
+ score: number;
+ correctMessages: number;
+ incorrectMessages: number;
+ accuracy: number;
+ mode: string;
+ timeMs?: number;
+}): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ if (!Number.isInteger(input.testId) || (input.testId as number) <= 0) {
+ return { success: false, error: "Invalid test." };
+ }
+ const scoreCheck = validateRadioScore({
+ score: input.score,
+ correctMessages: input.correctMessages,
+ incorrectMessages: input.incorrectMessages,
+ accuracy: input.accuracy,
+ mode: input.mode,
+ timeMs: input.timeMs,
+ });
+ if (scoreCheck.error !== undefined) {
+ return { success: false, error: scoreCheck.error };
+ }
+ const score = scoreCheck.score;
+
+ const test = await payload.findByID({
+ collection: "custom-radio-tests",
+ id: input.testId as number,
+ depth: 0,
+ });
+ if (!test || !test.published) {
+ return { success: false, error: "Test not found." };
+ }
+
+ await payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: test.id,
+ user: user.id as number,
+ score,
+ accuracy: input.accuracy,
+ correctMessages: input.correctMessages,
+ incorrectMessages: input.incorrectMessages,
+ mode: scoreCheck.mode,
+ timeMs: scoreCheck.timeMs,
+ },
+ depth: 0,
+ });
+
+ await payload.update({
+ collection: "custom-radio-tests",
+ id: test.id,
+ data: { playCount: (test.playCount ?? 0) + 1 },
+ overrideAccess: true,
+ depth: 0,
+ });
+
+ await emitGameEvent(payload, {
+ type: EventTypes.MinigameCustomRadioPlay,
+ message: `Scored ${score} on custom radio test "${test.name}" (${input.mode})`,
+ actor: user.id as number,
+ targetCollection: "custom-radio-tests",
+ targetId: test.id,
+ data: {
+ score,
+ accuracy: input.accuracy,
+ mode: input.mode,
+ },
+ });
+
+ return { success: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ if (message === "Not Found" || message.includes("not found")) {
+ return { success: false, error: "Test not found." };
+ }
+ return { success: false, error: message };
+ }
+}
diff --git a/src/app/(frontend)/radio/custom/page.tsx b/src/app/(frontend)/radio/custom/page.tsx
new file mode 100644
index 0000000..e25fea4
--- /dev/null
+++ b/src/app/(frontend)/radio/custom/page.tsx
@@ -0,0 +1,49 @@
+import type { Metadata } from "next";
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { headers as nextHeaders } from "next/headers";
+import { Radio } from "lucide-react";
+import { CustomTestBrowser } from "@/components/frontend/radio/CustomTestBrowser";
+
+export const metadata: Metadata = {
+ title: "Custom Radio Tests (Polaris Task Force)",
+ description: "Browse and play community-authored radio traffic tests.",
+};
+
+export default async function CustomRadioTestsPage() {
+ const headers = await nextHeaders();
+ const payloadConfig = await config;
+ const payload = await getPayload({ config: payloadConfig });
+ const { user } = await payload.auth({ headers, canSetHeaders: false });
+
+ const radio = user
+ ? (
+ await payload.find({
+ collection: "profiles",
+ where: { user: { equals: user.id as number } },
+ limit: 1,
+ depth: 0,
+ overrideAccess: true,
+ select: { progression: { minigames: true } },
+ })
+ ).docs[0]?.progression?.minigames?.radioTraffic ?? {}
+ : {};
+ const unlocked =
+ (radio.sprintBestAccuracy ?? 0) >= 0.9 && (radio.watchBestAccuracy ?? 0) >= 0.85;
+
+ return (
+
+
+
+
+
Custom Radio Tests
+
+
+ Operator-authored message pools. Unranked, no XP.
+
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/radio/CreateTestDialog.tsx b/src/components/frontend/radio/CreateTestDialog.tsx
new file mode 100644
index 0000000..fd78b4d
--- /dev/null
+++ b/src/components/frontend/radio/CreateTestDialog.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2, Radio } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { createTest } from "@/app/(frontend)/radio/custom/actions";
+
+const MAX_MESSAGES = 100;
+
+export function CreateTestDialog({
+ unlocked,
+ children,
+}: {
+ unlocked: boolean;
+ children?: React.ReactNode;
+}) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [poolText, setPoolText] = useState("");
+ const [order, setOrder] = useState<"fixed" | "shuffled">("fixed");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const pool = poolText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+
+ const submit = async () => {
+ setError(null);
+ const trimmed = name.trim();
+ if (trimmed.length < 1 || trimmed.length > 100) {
+ setError("Name must be 1-100 characters.");
+ return;
+ }
+ if (pool.length < 1 || pool.length > MAX_MESSAGES) {
+ setError(`Message pool must contain 1-${MAX_MESSAGES} messages (one per line).`);
+ return;
+ }
+ if (pool.some((message) => message.length > 500)) {
+ setError("Each message must be 1-500 characters.");
+ return;
+ }
+ setSubmitting(true);
+ const result = await createTest({ name: trimmed, messages: pool, order });
+ setSubmitting(false);
+ if (result.success) {
+ setOpen(false);
+ setName("");
+ setPoolText("");
+ setOrder("fixed");
+ router.refresh();
+ } else {
+ setError(result.error ?? "Could not publish the test.");
+ }
+ };
+
+ if (!unlocked) return null;
+
+ return (
+
+ );
+}
diff --git a/src/components/frontend/radio/CustomRadioArena.tsx b/src/components/frontend/radio/CustomRadioArena.tsx
new file mode 100644
index 0000000..9357aff
--- /dev/null
+++ b/src/components/frontend/radio/CustomRadioArena.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useEffect } from "react";
+import { CustomTestLeaderboard } from "./CustomTestLeaderboard";
+import { RadioTraffic, type CustomRadioTestData } from "./RadioTraffic";
+import { useRealtimePresence } from "@/hooks/useRealtimePresence";
+import { useSidebar } from "@/components/ui/sidebar";
+
+type Mode = "sprint" | "watch";
+
+export function CustomRadioArena({
+ test,
+ currentUserId,
+}: {
+ test: CustomRadioTestData;
+ currentUserId: number | string;
+}) {
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const [mode, setMode] = useState("sprint");
+ const [modeHydrated, setModeHydrated] = useState(false);
+ const { isMobile, setOpenMobile } = useSidebar();
+ const activeTeams = useRealtimePresence("radio", playing, true);
+ const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
+ setPlaying(phase === "playing");
+ }, []);
+
+ useEffect(() => {
+ const saved = window.localStorage.getItem("radio-custom-mode");
+ if (saved === "watch" || saved === "sprint") setMode(saved);
+ setModeHydrated(true);
+ }, []);
+
+ useEffect(() => {
+ if (!modeHydrated) return;
+ window.localStorage.setItem("radio-custom-mode", mode);
+ }, [mode, modeHydrated]);
+
+ useEffect(() => {
+ if (playing && isMobile) setOpenMobile(false);
+ }, [isMobile, playing, setOpenMobile]);
+
+ return (
+
+
setRefreshKey((key) => key + 1)}
+ onPhaseChangeAction={onPhaseChange}
+ customTest={test}
+ />
+
+ {activeTeams.length > 0 && (
+
+ {activeTeams.length} operator{activeTeams.length === 1 ? "" : "s"} on the net
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/radio/CustomTestBrowser.tsx b/src/components/frontend/radio/CustomTestBrowser.tsx
new file mode 100644
index 0000000..8210281
--- /dev/null
+++ b/src/components/frontend/radio/CustomTestBrowser.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Play, Radio } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { CreateTestDialog } from "./CreateTestDialog";
+
+interface TestRow {
+ id: number;
+ name: string;
+ messageCount: number;
+ order: string;
+ playCount: number;
+ authorName: string;
+ isOwn: boolean;
+}
+
+export function CustomTestBrowser({
+ currentUserId,
+ unlocked,
+}: {
+ currentUserId: number | string;
+ unlocked: boolean;
+}) {
+ const [rows, setRows] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setFailed(false);
+
+ const params = new URLSearchParams({
+ sort: "-createdAt",
+ limit: "100",
+ depth: "1",
+ });
+ params.set("where[published][equals]", "true");
+
+ fetch(`/api/custom-radio-tests?${params.toString()}`)
+ .then((res) => {
+ if (!res.ok) throw new Error("test fetch failed");
+ return res.json() as Promise<{
+ docs?: {
+ id: number;
+ name: string;
+ messageCount: number;
+ order?: string | null;
+ playCount?: number | null;
+ author?: number | { id?: number; displayName?: string | null; username?: string | null } | null;
+ }[];
+ }>;
+ })
+ .then((data) => {
+ if (cancelled) return;
+ setRows(
+ (data.docs ?? []).map((doc) => {
+ const author = typeof doc.author === "object" && doc.author !== null ? doc.author : null;
+ return {
+ id: doc.id,
+ name: doc.name,
+ messageCount: doc.messageCount,
+ order: doc.order ?? "fixed",
+ playCount: doc.playCount ?? 0,
+ authorName:
+ author?.displayName || author?.username || `Operator #${author?.id ?? "?"}`,
+ isOwn: author ? String(author.id) === String(currentUserId) : false,
+ };
+ }),
+ );
+ })
+ .catch(() => {
+ if (!cancelled) setFailed(true);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [currentUserId]);
+
+ return (
+
+
+
+ Community radio tests. Playing these does not affect your official stats or XP.
+
+
+
+
+ {failed ? (
+
+ Could not load custom radio tests.
+
+ ) : rows === null ? (
+
+ ) : rows.length === 0 ? (
+
+ No custom radio tests published yet.
+ {unlocked ? " Be the first to author one!" : " Hit 90% sprint and 85% watch accuracy to author one."}
+
+ ) : (
+
+ {rows.map((row) => (
+
+
+
+
+ {row.name}
+ {row.isOwn && (
+ (yours)
+ )}
+
+
+ {row.messageCount} messages · {row.order === "shuffled" ? "shuffled" : "fixed"} ·
+ by {row.authorName} · played {row.playCount}×
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/radio/CustomTestLeaderboard.tsx b/src/components/frontend/radio/CustomTestLeaderboard.tsx
new file mode 100644
index 0000000..f35d9be
--- /dev/null
+++ b/src/components/frontend/radio/CustomTestLeaderboard.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Trophy } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+
+type ScoreMode = "sprint" | "watch";
+
+interface ApiScore {
+ id: number;
+ user?: number | { id: number; displayName?: string | null; username?: string | null } | null;
+ score: number;
+ accuracy: number;
+ timeMs?: number | null;
+}
+
+interface LeaderboardRow {
+ id: number;
+ name: string;
+ score: number;
+ accuracy: number;
+ timeMs: number | null;
+ isCurrentUser: boolean;
+ isOutsideTopTen?: boolean;
+}
+
+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 CustomTestLeaderboard({
+ testId,
+ refreshKey = 0,
+ currentUserId,
+ mode,
+ onModeChange,
+}: {
+ testId: number;
+ refreshKey?: number;
+ currentUserId?: number | string;
+ mode: ScoreMode;
+ onModeChange: (mode: ScoreMode) => void;
+}) {
+ const [rows, setRows] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setFailed(false);
+
+ const params = new URLSearchParams({
+ sort: "-score",
+ limit: "100",
+ depth: "1",
+ });
+ params.set("where[test][equals]", String(testId));
+ params.set("where[mode][equals]", mode);
+
+ fetch(`/api/custom-radio-test-scores?${params.toString()}`)
+ .then((res) => {
+ if (!res.ok) throw new Error("leaderboard fetch failed");
+ return res.json() as Promise<{ docs?: ApiScore[] }>;
+ })
+ .then((data) => {
+ if (cancelled) return;
+ const bestPerUser = new Map();
+ for (const doc of data.docs ?? []) {
+ const userId = typeof doc.user === "object" && doc.user !== null ? doc.user.id : null;
+ if (userId === null) continue;
+ const existing = bestPerUser.get(userId);
+ if (!existing || doc.score > existing.score) bestPerUser.set(userId, doc);
+ }
+ const mapped: LeaderboardRow[] = Array.from(bestPerUser.values()).map((doc) => {
+ const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
+ return {
+ id: doc.id,
+ name: user?.displayName || user?.username || `Operator #${user?.id ?? "?"}`,
+ score: doc.score,
+ accuracy: doc.accuracy,
+ timeMs: doc.timeMs ?? null,
+ isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
+ };
+ });
+ const sorted = mapped.sort((a, b) => b.score - a.score);
+ const visible = sorted.slice(0, 10);
+ const current = sorted.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;
+ };
+ }, [testId, mode, refreshKey, currentUserId]);
+
+ return (
+
+
+
+
Radio Test Leaderboard
+
+
+ Top 10 · best score
+
+
+
+ {failed ? (
+
Could not load the leaderboard.
+ ) : rows === null ? (
+
+ ) : rows.length === 0 ? (
+
+ No runs logged yet. Take the first shift!
+
+ ) : (
+
+
+
+
+ | # |
+ Operator |
+ Score |
+ Accuracy |
+ {mode === "sprint" && (
+ Time |
+ )}
+
+
+
+ {rows.map((row, index) => (
+
+ |
+
+ {row.isOutsideTopTen ? "-" : index + 1}
+
+ |
+
+
+ {row.name}
+ {row.isCurrentUser && (
+ (you)
+ )}
+
+ |
+ {row.score} |
+
+ {Math.round(row.accuracy * 100)}%
+ |
+ {mode === "sprint" && (
+
+ {row.timeMs !== null ? `${(row.timeMs / 1000).toFixed(1)}s` : "-"}
+ |
+ )}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/radio/RadioArena.tsx b/src/components/frontend/radio/RadioArena.tsx
index 5ab1392..fa8ca0e 100644
--- a/src/components/frontend/radio/RadioArena.tsx
+++ b/src/components/frontend/radio/RadioArena.tsx
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { Radio } from "lucide-react";
import type { RadioStats } from "@/app/(frontend)/radio/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { RadioLeaderboard } from "./RadioLeaderboard";
@@ -34,6 +36,13 @@ export function RadioArena({
return (
+
+
+ Custom radio tests
+
0; index -= 1) {
+ const swap = Math.floor(Math.random() * (index + 1));
+ [pool[index], pool[swap]] = [pool[swap] as string, pool[index] as string];
+ }
+ }
+ if (pool.length === 0) return [];
+ while (pool.length < SPRINT_MESSAGE_COUNT) {
+ pool.push(pool[pool.length % test.messages.length] as string);
+ }
+ return pool.slice(0, SPRINT_MESSAGE_COUNT);
+}
+
+function nextCustomWatchMessage(test: CustomRadioTestData, usedCount: number): string {
+ const pool = test.messages;
+ if (pool.length === 0) return "";
+ if (test.order === "fixed") return pool[usedCount % pool.length] as string;
+ return pool[Math.floor(Math.random() * pool.length)] as string;
+}
+
export function RadioTraffic({
initialStats: serverStats,
mode,
onModeChangeAction,
onGameRecordedAction,
onPhaseChangeAction,
+ customTest,
}: {
initialStats: RadioStats;
mode: Mode;
onModeChangeAction: (mode: Mode) => void;
onGameRecordedAction: () => void;
onPhaseChangeAction: (phase: Phase) => void;
+ customTest?: CustomRadioTestData;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
@@ -170,6 +202,7 @@ export function RadioTraffic({
const [phase, setPhase] = useState("ready");
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
const [messages, setMessages] = useState(() => {
+ if (customTest) return buildCustomSprintMessages(customTest);
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
return initialRound;
@@ -247,15 +280,41 @@ export function RadioTraffic({
submittingRef.current = true;
setSubmitting(true);
+ if (customTest) {
+ await submitCustomTestScore({
+ testId: customTest.id,
+ score,
+ correctMessages,
+ incorrectMessages,
+ accuracy,
+ mode: resultMode,
+ timeMs: elapsedMs,
+ });
+ submittingRef.current = false;
+ setSubmitting(false);
+ onGameRecordedAction();
+ setLastRun({
+ mode: resultMode,
+ score,
+ accuracy,
+ correctMessages,
+ incorrectMessages,
+ relayedMessages: finalResults.length,
+ xpEarned: 0,
+ best: 0,
+ isNewBest: false,
+ });
+ return;
+ }
const result = await submitRadioResult({
- score,
- messageCount: finalResults.length,
- correctMessages,
- incorrectMessages,
- accuracy,
- mode: resultMode,
- timeMs: elapsedMs,
- });
+ score,
+ messageCount: finalResults.length,
+ correctMessages,
+ incorrectMessages,
+ accuracy,
+ mode: resultMode,
+ timeMs: elapsedMs,
+ });
submittingRef.current = false;
setSubmitting(false);
@@ -304,7 +363,7 @@ export function RadioTraffic({
});
}
},
- [expiredRef, onGameRecordedAction, router],
+ [customTest, expiredRef, onGameRecordedAction, router],
);
const finishRound = useCallback(
@@ -392,10 +451,14 @@ export function RadioTraffic({
watchExpiryNotifiedRef.current = false;
countdownEndPlayedRef.current = false;
if (mode === "sprint") {
- nextMessages = createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
+ nextMessages = customTest
+ ? buildCustomSprintMessages(customTest)
+ : createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null;
} else {
- const firstMessage = nextRadioMessage(watchUsedRef.current);
+ const firstMessage = customTest
+ ? nextCustomWatchMessage(customTest, 0)
+ : nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(firstMessage);
nextMessages = [firstMessage];
}
@@ -418,7 +481,7 @@ export function RadioTraffic({
messageStartedAtRef.current = now;
setPhaseSafe("playing");
window.setTimeout(() => inputRef.current?.focus(), 0);
- }, [expiredRef, mode, setPhaseSafe]);
+ }, [customTest, expiredRef, mode, setPhaseSafe]);
const handleKeystroke = useCallback(
(event: KeyboardEvent) => {
@@ -508,7 +571,9 @@ export function RadioTraffic({
if (mode === "watch") {
watchUsedRef.current.add(target);
- const nextTarget = nextRadioMessage(watchUsedRef.current);
+ const nextTarget = customTest
+ ? nextCustomWatchMessage(customTest, watchUsedRef.current.size)
+ : nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(nextTarget);
setMessages((current) => [...current, nextTarget]);
}
@@ -516,7 +581,7 @@ export function RadioTraffic({
messageStartedAtRef.current = now;
window.setTimeout(() => inputRef.current?.focus(), 0);
},
- [finishRound, input, messageIndex, messages, mode, results],
+ [customTest, finishRound, input, messageIndex, messages, mode, results],
);
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
diff --git a/tests/int/custom-radio-tests.int.spec.ts b/tests/int/custom-radio-tests.int.spec.ts
new file mode 100644
index 0000000..b7291ec
--- /dev/null
+++ b/tests/int/custom-radio-tests.int.spec.ts
@@ -0,0 +1,915 @@
+import { getPayload, Payload } from "payload";
+import config from "@/payload.config";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import type { Role, User } from "@/payload-types";
+import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
+import { hasPermission } from "@/utils/access-control/hasPermission";
+import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
+import {
+ isRadioUnlocked,
+ isValidRadioOrder,
+ validateMessages,
+ validateRadioScore,
+ validateTestName,
+} from "@/lib/custom-minigames/radio";
+
+let payload: Payload;
+const RUN = `crt-${Date.now().toString(36)}`;
+const TIMEOUT = 30_000;
+
+const sampleMessages = [
+ "VIKTOR ONE, EN ROUTE TO GRID 341-512, STATUS GREEN, OVER",
+ "ECHO TWO, CONTACT AT CHECKPOINT ALPHA, WEAPONS FREE, OUT",
+ "FOXTROT THREE, REQUESTING FIRE SUPPORT ON BEARING 270, OVER",
+];
+
+describe("Custom radio tests", () => {
+ let author: User;
+ let authorId: number;
+ const testIds: number[] = [];
+ const scoreIds: number[] = [];
+
+ beforeAll(async () => {
+ const payloadConfig = await config;
+ payload = await getPayload({ config: payloadConfig });
+
+ author = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-author`,
+ discordUsername: `${RUN}-author`,
+ displayName: "RADIO AUTHOR",
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ roles: ["user"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ authorId = author.id;
+ });
+
+ afterAll(async () => {
+ // Delete scores first (FK → test)
+ for (const id of scoreIds) {
+ await payload
+ .delete({ collection: "custom-radio-test-scores", id, overrideAccess: true })
+ .catch(() => {});
+ }
+ // Delete tests
+ for (const id of testIds) {
+ await payload
+ .delete({ collection: "custom-radio-tests", id, overrideAccess: true })
+ .catch(() => {});
+ }
+ await payload
+ .delete({ collection: "users", id: authorId, overrideAccess: true })
+ .catch(() => {});
+ });
+
+ // ---------------------------------------------------------------------------
+ // Collection-level field constraints
+ // ---------------------------------------------------------------------------
+
+ describe("Collection field validation", () => {
+ it("creates a valid test with all required fields", async () => {
+ const doc = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Test Radio Drill",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ order: "fixed",
+ published: true,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testIds.push(doc.id);
+ expect(doc.name).toBe("Test Radio Drill");
+ expect(doc.messageCount).toBe(3);
+ expect(doc.order).toBe("fixed");
+ expect(doc.published).toBe(true);
+ expect(doc.playCount).toBe(0);
+ });
+
+ it("defaults order to fixed and published to true", async () => {
+ const doc = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Defaults Test",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testIds.push(doc.id);
+ expect(doc.order).toBe("fixed");
+ expect(doc.published).toBe(true);
+ expect(doc.playCount).toBe(0);
+ });
+
+ it("accepts shuffled order", async () => {
+ const doc = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Shuffled Test",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ order: "shuffled",
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testIds.push(doc.id);
+ expect(doc.order).toBe("shuffled");
+ });
+
+ it("rejects a test without a name", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a test without messages", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Empty",
+ author: authorId,
+ messageCount: 0,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a test without an author", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Orphan",
+ messages: sampleMessages,
+ messageCount: 3,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects messageCount below minimum (0 < 1)", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Zero Messages",
+ author: authorId,
+ messages: [],
+ messageCount: 0,
+ },
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Score collection
+ // ---------------------------------------------------------------------------
+
+ describe("Custom radio test scores", () => {
+ let testId: number;
+
+ beforeAll(async () => {
+ const doc = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Score Test Radio",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testId = doc.id;
+ testIds.push(testId);
+ });
+
+ it("creates a sprint score with valid fields", async () => {
+ const score = await payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: testId,
+ user: authorId,
+ score: 850,
+ accuracy: 0.95,
+ correctMessages: 9,
+ incorrectMessages: 1,
+ mode: "sprint",
+ timeMs: 45000,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ expect(score.test).toBe(testId);
+ expect(score.user).toBe(authorId);
+ expect(score.score).toBe(850);
+ expect(score.accuracy).toBe(0.95);
+ expect(score.mode).toBe("sprint");
+ expect(score.timeMs).toBe(45000);
+ });
+
+ it("creates a watch score without timeMs", async () => {
+ const score = await payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: testId,
+ user: authorId,
+ score: 320,
+ accuracy: 0.88,
+ correctMessages: 8,
+ incorrectMessages: 2,
+ mode: "watch",
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ expect(score.mode).toBe("watch");
+ expect(score.timeMs === null || score.timeMs === undefined).toBe(true);
+ });
+
+ it("rejects a score without a test reference", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ user: authorId,
+ score: 100,
+ accuracy: 0.5,
+ correctMessages: 5,
+ incorrectMessages: 5,
+ mode: "sprint",
+ timeMs: 30000,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a score without a user reference", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: testId,
+ score: 100,
+ accuracy: 0.5,
+ correctMessages: 5,
+ incorrectMessages: 5,
+ mode: "sprint",
+ timeMs: 30000,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a score without a mode", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: testId,
+ user: authorId,
+ score: 100,
+ accuracy: 0.5,
+ correctMessages: 5,
+ incorrectMessages: 5,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects score updates (update: () => false)", async () => {
+ const score = await payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: testId,
+ user: authorId,
+ score: 100,
+ accuracy: 0.5,
+ correctMessages: 5,
+ incorrectMessages: 5,
+ mode: "watch",
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+
+ await expect(
+ payload.update({
+ collection: "custom-radio-test-scores",
+ id: score.id,
+ data: { score: 999 },
+ overrideAccess: false,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Access control
+ // ---------------------------------------------------------------------------
+
+ describe("Access control", () => {
+ let viewer: User;
+ let viewerId: number;
+
+ beforeAll(async () => {
+ viewer = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-viewer`,
+ discordUsername: `${RUN}-viewer`,
+ displayName: "VIEWER",
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ roles: ["user"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ viewerId = viewer.id;
+ });
+
+ it("logged-in user can read published tests", async () => {
+ const result = await payload.find({
+ collection: "custom-radio-tests",
+ where: { published: { equals: true } },
+ limit: 1,
+ overrideAccess: true,
+ depth: 0,
+ });
+ expect(result.docs.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("logged-in user can create a test", async () => {
+ const doc = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Can Create",
+ author: viewerId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testIds.push(doc.id);
+ expect(doc.name).toBe("Can Create");
+ });
+
+ it("published tests are visible to all logged-in users", async () => {
+ const result = await payload.find({
+ collection: "custom-radio-tests",
+ where: { published: { equals: true } },
+ limit: 20,
+ overrideAccess: true,
+ depth: 0,
+ });
+ expect(result.docs.some((doc) => doc.name === "Can Create")).toBe(true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Cascade delete
+ // ---------------------------------------------------------------------------
+
+ describe("Cascade delete", () => {
+ it("deleting a test removes associated scores", async () => {
+ const test = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Cascade Test Radio",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+
+ const score = await payload.create({
+ collection: "custom-radio-test-scores",
+ data: {
+ test: test.id,
+ user: authorId,
+ score: 200,
+ accuracy: 0.7,
+ correctMessages: 4,
+ incorrectMessages: 6,
+ mode: "sprint",
+ timeMs: 60000,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+
+ await payload.delete({
+ collection: "custom-radio-test-scores",
+ id: score.id,
+ overrideAccess: true,
+ });
+
+ await payload.delete({
+ collection: "custom-radio-tests",
+ id: test.id,
+ overrideAccess: true,
+ });
+
+ const remaining = await payload.findByID({
+ collection: "custom-radio-test-scores",
+ id: score.id,
+ overrideAccess: true,
+ depth: 0,
+ }).catch(() => null);
+ expect(remaining).toBeNull();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Unlock gating (pure lib: mirrors checkRadioUnlock)
+ // ---------------------------------------------------------------------------
+
+ describe("Unlock gating", () => {
+ it("locks custom radio tests until both accuracy requirements are met", () => {
+ expect(isRadioUnlocked({ sprintBestAccuracy: 0, watchBestAccuracy: 0 })).toBe(false);
+ expect(isRadioUnlocked({ sprintBestAccuracy: 0.9, watchBestAccuracy: 0.5 })).toBe(false);
+ expect(isRadioUnlocked({ sprintBestAccuracy: 0.5, watchBestAccuracy: 0.85 })).toBe(false);
+ });
+
+ it("unlocks at exactly the sprint (0.9) and watch (0.85) thresholds", () => {
+ expect(isRadioUnlocked({ sprintBestAccuracy: 0.9, watchBestAccuracy: 0.85 })).toBe(true);
+ expect(isRadioUnlocked({ sprintBestAccuracy: 1, watchBestAccuracy: 1 })).toBe(true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Action-level validation (pure lib: mirrors createTest / updateTest /
+ // submitCustomTestScore)
+ // ---------------------------------------------------------------------------
+
+ describe("Action-level validation", () => {
+ it("rejects empty, oversized, and non-string names; trims valid ones", () => {
+ expect(validateTestName("").error).toBe("Name must be 1-100 characters.");
+ expect(validateTestName(" ").error).toBe("Name must be 1-100 characters.");
+ expect(validateTestName("x".repeat(101)).error).toBe("Name must be 1-100 characters.");
+ expect(validateTestName(123).error).toBe("Name must be 1-100 characters.");
+ expect(validateTestName(" Sweep Drill ")).toEqual({ name: "Sweep Drill" });
+ });
+
+ it("rejects non-array, wrong-size, non-string, and oversized message pools; trims valid ones", () => {
+ expect(validateMessages("boom").error).toBe("Messages must be a list of strings.");
+ expect(validateMessages([]).error).toBe("Message pool must contain 1-100 messages.");
+ expect(
+ validateMessages(Array.from({ length: 101 }, (_, i) => `m${i}`)).error,
+ ).toBe("Message pool must contain 1-100 messages.");
+ expect(validateMessages([42]).error).toBe("Every message must be a string.");
+ expect(validateMessages([" "]).error).toBe("Messages must be 1-500 characters.");
+ expect(validateMessages(["x".repeat(501)]).error).toBe("Messages must be 1-500 characters.");
+ expect(validateMessages([" Alpha ", "Bravo"])).toEqual({
+ messages: ["Alpha", "Bravo"],
+ });
+ });
+
+ it("accepts only fixed and shuffled orders", () => {
+ expect(isValidRadioOrder("fixed")).toBe(true);
+ expect(isValidRadioOrder("shuffled")).toBe(true);
+ expect(isValidRadioOrder("random")).toBe(false);
+ expect(isValidRadioOrder("")).toBe(false);
+ });
+
+ it("validates score submissions per mode", () => {
+ const sprintResult = {
+ score: 120,
+ correctMessages: 4,
+ incorrectMessages: 1,
+ accuracy: 0.8,
+ mode: "sprint",
+ timeMs: 45_000,
+ };
+ const watchResult = {
+ score: 80,
+ correctMessages: 8,
+ incorrectMessages: 2,
+ accuracy: 0.8,
+ mode: "watch",
+ };
+
+ expect(validateRadioScore(sprintResult)).toEqual({
+ score: 120,
+ mode: "sprint",
+ timeMs: 45_000,
+ });
+ expect(validateRadioScore(watchResult)).toEqual({ score: 80, mode: "watch", timeMs: null });
+ expect(validateRadioScore({ ...watchResult, timeMs: 5_000 })).toEqual({
+ score: 80,
+ mode: "watch",
+ timeMs: null,
+ });
+ expect(validateRadioScore({ ...sprintResult, score: 100.9 })).toEqual({
+ score: 100,
+ mode: "sprint",
+ timeMs: 45_000,
+ });
+
+ expect(validateRadioScore({ ...sprintResult, mode: "nope" }).error).toBe("Invalid result.");
+ expect(validateRadioScore({ ...sprintResult, score: -1 }).error).toBe("Invalid score.");
+ expect(validateRadioScore({ ...sprintResult, score: Number.NaN }).error).toBe(
+ "Invalid score.",
+ );
+ expect(validateRadioScore({ ...sprintResult, score: 5001 }).error).toBe("Invalid score.");
+ expect(validateRadioScore({ ...sprintResult, correctMessages: -1 }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateRadioScore({ ...sprintResult, correctMessages: 101 }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateRadioScore({ ...sprintResult, incorrectMessages: -1 }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateRadioScore({ ...sprintResult, accuracy: 1.5 }).error).toBe("Invalid result.");
+ expect(validateRadioScore({ ...sprintResult, accuracy: -0.1 }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateRadioScore({ ...sprintResult, timeMs: undefined }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateRadioScore({ ...sprintResult, timeMs: 0 }).error).toBe("Invalid result.");
+ expect(validateRadioScore({ ...sprintResult, timeMs: 600_001 }).error).toBe(
+ "Invalid result.",
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Leaderboard ordering (DB: replicates CustomTestLeaderboard's query: where
+ // test + mode, sort -score, best score per player)
+ // ---------------------------------------------------------------------------
+
+ describe("Leaderboard ordering", () => {
+ let testA: number;
+ let testB: number;
+ const tempUserIds: number[] = [];
+
+ const makePlayer = async (label: string): Promise => {
+ const user = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-lb-${label}`,
+ discordUsername: `${RUN}-lb-${label}`,
+ displayName: `LBOARD ${label.toUpperCase()}`,
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ roles: ["user"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ tempUserIds.push(user.id);
+ return user.id;
+ };
+
+ const record = async (
+ testId: number,
+ userId: number,
+ scoreData: {
+ score: number;
+ accuracy: number;
+ correctMessages: number;
+ incorrectMessages: number;
+ mode: "sprint" | "watch";
+ timeMs?: number;
+ },
+ ) => {
+ const score = await payload.create({
+ collection: "custom-radio-test-scores",
+ data: { test: testId, user: userId, ...scoreData },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ return score.id;
+ };
+
+ beforeAll(async () => {
+ const a = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "LBoard Alpha",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ const b = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "LBoard Bravo",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ testA = a.id;
+ testB = b.id;
+ testIds.push(testA, testB);
+ }, TIMEOUT);
+
+ afterAll(async () => {
+ if (!payload) return;
+ // Runs before the parent afterAll: remove each temp user's scores first
+ // (FK), then the users. Leftover ids in scoreIds are cleaned up (and
+ // tolerated) by the parent afterAll.
+ for (const uid of tempUserIds) {
+ await payload
+ .delete({
+ collection: "custom-radio-test-scores",
+ where: { user: { equals: uid } },
+ overrideAccess: true,
+ })
+ .catch(() => {});
+ await payload
+ .delete({ collection: "users", id: uid, overrideAccess: true })
+ .catch(() => {});
+ }
+ });
+
+ it("keeps each player's best score and isolates leaderboards per test and mode", async () => {
+ const u1 = await makePlayer("one");
+ const u2 = await makePlayer("two");
+
+ await record(testA, u1, {
+ score: 300,
+ accuracy: 0.8,
+ correctMessages: 4,
+ incorrectMessages: 1,
+ mode: "sprint",
+ timeMs: 45_000,
+ });
+ await record(testA, u1, {
+ score: 450,
+ accuracy: 0.9,
+ correctMessages: 5,
+ incorrectMessages: 0,
+ mode: "sprint",
+ timeMs: 40_000,
+ });
+ await record(testA, u2, {
+ score: 500,
+ accuracy: 0.95,
+ correctMessages: 6,
+ incorrectMessages: 0,
+ mode: "sprint",
+ timeMs: 38_000,
+ });
+ await record(testA, u1, {
+ score: 200,
+ accuracy: 0.7,
+ correctMessages: 7,
+ incorrectMessages: 3,
+ mode: "watch",
+ });
+ await record(testB, u1, {
+ score: 150,
+ accuracy: 0.6,
+ correctMessages: 5,
+ incorrectMessages: 3,
+ mode: "watch",
+ });
+
+ const leaderboardFor = async (testId: number, mode: "sprint" | "watch") => {
+ const res = await payload.find({
+ collection: "custom-radio-test-scores",
+ where: { test: { equals: testId }, mode: { equals: mode } },
+ sort: "-score",
+ limit: 100,
+ depth: 1,
+ overrideAccess: true,
+ });
+ const best = new Map();
+ for (const doc of res.docs) {
+ const ref = doc.user as unknown as { id: number } | number | null;
+ if (ref === null) continue;
+ const uid = typeof ref === "object" ? ref.id : ref;
+ if (typeof uid !== "number") continue;
+ const prev = best.get(uid);
+ if (prev === undefined || doc.score > prev) best.set(uid, doc.score);
+ }
+ return Array.from(best.entries())
+ .map(([uid, score]) => ({ uid, score }))
+ .sort((x, y) => y.score - x.score);
+ };
+
+ const sprintOrder = await leaderboardFor(testA, "sprint");
+ expect(sprintOrder).toHaveLength(2);
+ expect(sprintOrder[0]).toEqual({ uid: u2, score: 500 });
+ expect(sprintOrder[1]).toEqual({ uid: u1, score: 450 });
+
+ const watchOrder = await leaderboardFor(testA, "watch");
+ expect(watchOrder).toHaveLength(1);
+ expect(watchOrder[0]).toEqual({ uid: u1, score: 200 });
+
+ const testBOrder = await leaderboardFor(testB, "watch");
+ expect(testBOrder).toHaveLength(1);
+ expect(testBOrder[0]).toEqual({ uid: u1, score: 150 });
+ }, TIMEOUT);
+ });
+
+ // ---------------------------------------------------------------------------
+ // Edit and delete permissions (action gate + collection access)
+ // ---------------------------------------------------------------------------
+
+ describe("Edit and delete permissions", () => {
+ const roleIds: number[] = [];
+ const permUserIds: number[] = [];
+ let otherPlayer: User;
+ let manager: User;
+ let devUser: User;
+ let gateTestId: number;
+
+ const makeUserWithRole = async (label: string, roleId: number): Promise => {
+ const user = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-perm-${label}`,
+ discordUsername: `${RUN}-perm-${label}`,
+ displayName: `PERM ${label.toUpperCase()}`,
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ // Permission resolution reads roleDocs (dynamic RBAC), not the legacy
+ // `roles` enum: assign a real role doc to grant permissions.
+ roleDocs: [roleId],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ permUserIds.push(user.id);
+ return user;
+ };
+
+ const invokeAccess = (fn: unknown, user: User | null): Promise => {
+ return (fn as (args: { req: unknown }) => Promise)({
+ req: { user, payload },
+ });
+ };
+
+ beforeAll(async () => {
+ const plainRole = (await payload.create({
+ collection: "roles",
+ data: { name: `${RUN}-plain`, slug: `${RUN}-plain`, permissions: [] },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as Role;
+ const managerRole = (await payload.create({
+ collection: "roles",
+ data: {
+ name: `${RUN}-rt-manager`,
+ slug: `${RUN}-rt-manager`,
+ permissions: ["custom-radio-tests:update"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as Role;
+ const devRole = (await payload.create({
+ collection: "roles",
+ data: { name: `${RUN}-rt-dev`, slug: `${RUN}-rt-dev`, isSuperuser: true },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as Role;
+ roleIds.push(plainRole.id, managerRole.id, devRole.id);
+ invalidatePermissionCache();
+
+ otherPlayer = await makeUserWithRole("other", plainRole.id);
+ manager = await makeUserWithRole("manager", managerRole.id);
+ devUser = await makeUserWithRole("dev", devRole.id);
+
+ const gateTest = await payload.create({
+ collection: "custom-radio-tests",
+ data: {
+ name: "Gate Test Radio",
+ author: authorId,
+ messages: sampleMessages,
+ messageCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ gateTestId = gateTest.id;
+ testIds.push(gateTestId);
+ }, TIMEOUT);
+
+ afterAll(async () => {
+ if (!payload) return;
+ // Runs before the parent afterAll: profiles first (FK), then users, then roles.
+ for (const uid of permUserIds) {
+ const profiles = await payload
+ .find({
+ collection: "profiles",
+ where: { user: { equals: uid } },
+ limit: 5,
+ depth: 0,
+ overrideAccess: true,
+ })
+ .catch(() => null);
+ for (const p of profiles?.docs ?? []) {
+ await payload
+ .delete({ collection: "profiles", id: p.id, overrideAccess: true })
+ .catch(() => {});
+ }
+ await payload
+ .delete({ collection: "users", id: uid, overrideAccess: true })
+ .catch(() => {});
+ }
+ for (const id of roleIds) {
+ await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
+ }
+ });
+
+ it("lets only the author (or a permissioned manager) pass the action's edit gate", async () => {
+ // Mirrors updateTest: isManager || isAuthor, evaluated against the gate
+ // test authored by `author`.
+ const gate = async (user: User) => {
+ const isManager = await hasPermission(payload, user, "custom-radio-tests:update");
+ return isManager || user.id === authorId;
+ };
+ expect(await gate(author)).toBe(true);
+ expect(await gate(otherPlayer)).toBe(false);
+ expect(await gate(manager)).toBe(true);
+ expect(await gate(devUser)).toBe(true);
+ }, TIMEOUT);
+
+ it("scopes collection update access to the author for non-managers", async () => {
+ const fn = CustomRadioTests.access?.update;
+ expect(typeof fn).toBe("function");
+ expect(await invokeAccess(fn, author)).toMatchObject({ author: { equals: authorId } });
+ expect(await invokeAccess(fn, otherPlayer)).toMatchObject({
+ author: { equals: otherPlayer.id },
+ });
+ expect(await invokeAccess(fn, manager)).toBe(true);
+ expect(await invokeAccess(fn, null)).toBe(false);
+ }, TIMEOUT);
+
+ it("gates deletes behind custom-radio-tests:delete", async () => {
+ const fn = CustomRadioTests.access?.delete;
+ expect(typeof fn).toBe("function");
+ expect(await invokeAccess(fn, null)).toBe(false);
+ expect(await invokeAccess(fn, author)).toBe(false);
+ expect(await invokeAccess(fn, otherPlayer)).toBe(false);
+ expect(await invokeAccess(fn, devUser)).toBe(true);
+ }, TIMEOUT);
+
+ it("gates publish (create) and read behind login", async () => {
+ const createFn = CustomRadioTests.access?.create;
+ const readFn = CustomRadioTests.access?.read;
+ expect(typeof createFn).toBe("function");
+ expect(typeof readFn).toBe("function");
+ expect(await invokeAccess(createFn, null)).toBe(false);
+ expect(await invokeAccess(createFn, otherPlayer)).toBe(true);
+ expect(await invokeAccess(readFn, null)).toBe(false);
+ expect(await invokeAccess(readFn, otherPlayer)).toBe(true);
+ }, TIMEOUT);
+ });
+});