From 1cf11745fe489c8ab7b3fedf3164e5abd0b42413 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 14:38:58 -0400 Subject: [PATCH] feat(radio): add custom radio test authoring, play, and leaderboard Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/radio/custom/[id]/page.tsx | 70 ++ src/app/(frontend)/radio/custom/actions.ts | 342 +++++++ src/app/(frontend)/radio/custom/page.tsx | 49 + .../frontend/radio/CreateTestDialog.tsx | 164 ++++ .../frontend/radio/CustomRadioArena.tsx | 87 ++ .../frontend/radio/CustomTestBrowser.tsx | 136 +++ .../frontend/radio/CustomTestLeaderboard.tsx | 195 ++++ src/components/frontend/radio/RadioArena.tsx | 9 + .../frontend/radio/RadioTraffic.tsx | 93 +- tests/int/custom-radio-tests.int.spec.ts | 915 ++++++++++++++++++ 10 files changed, 2046 insertions(+), 14 deletions(-) create mode 100644 src/app/(frontend)/radio/custom/[id]/page.tsx create mode 100644 src/app/(frontend)/radio/custom/actions.ts create mode 100644 src/app/(frontend)/radio/custom/page.tsx create mode 100644 src/components/frontend/radio/CreateTestDialog.tsx create mode 100644 src/components/frontend/radio/CustomRadioArena.tsx create mode 100644 src/components/frontend/radio/CustomTestBrowser.tsx create mode 100644 src/components/frontend/radio/CustomTestLeaderboard.tsx create mode 100644 tests/int/custom-radio-tests.int.spec.ts 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 ( + { + setOpen(next); + if (!next) setError(null); + }} + > + + {children ?? ( + + )} + + + + Author a custom radio test + + Build a message pool, one transmission per line. Published tests are instantly + playable by every operator and scored like the official game. + + + +
+
+
+ + setName(event.target.value)} + placeholder="Night Watch Drill" + className="w-48" + /> +
+
+ + +
+ {pool.length} messages +
+ +
+ +