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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
52328da8f9
commit
1cf11745fe
10 changed files with 2046 additions and 14 deletions
70
src/app/(frontend)/radio/custom/[id]/page.tsx
Normal file
70
src/app/(frontend)/radio/custom/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/radio/custom"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" /> All custom radio tests
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<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">{test.name}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Custom radio tests are unranked and do not affect official stats or XP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomRadioArena
|
||||||
|
test={{ id: test.id, name: test.name, messages, order }}
|
||||||
|
currentUserId={user?.id ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
src/app/(frontend)/radio/custom/actions.ts
Normal file
342
src/app/(frontend)/radio/custom/actions.ts
Normal file
|
|
@ -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<T = undefined> {
|
||||||
|
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<ActionResult<RadioUnlockStatus>> {
|
||||||
|
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<ActionResult<{ id: number }>> {
|
||||||
|
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<ActionResult<undefined>> {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<ActionResult<undefined>> {
|
||||||
|
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<ActionResult<undefined>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/(frontend)/radio/custom/page.tsx
Normal file
49
src/app/(frontend)/radio/custom/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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">Custom Radio Tests</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Operator-authored message pools. Unranked, no XP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomTestBrowser currentUserId={user?.id ?? 0} unlocked={unlocked} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/frontend/radio/CreateTestDialog.tsx
Normal file
164
src/components/frontend/radio/CreateTestDialog.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? (
|
||||||
|
<Button type="button">
|
||||||
|
<Radio /> Author radio test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85dvh] overflow-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Author a custom radio test</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Build a message pool, one transmission per line. Published tests are instantly
|
||||||
|
playable by every operator and scored like the official game.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="radio-test-name" className="text-xs font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="radio-test-name"
|
||||||
|
value={name}
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Night Watch Drill"
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="radio-test-order" className="text-xs font-medium">
|
||||||
|
Order
|
||||||
|
</label>
|
||||||
|
<Select value={order} onValueChange={(value) => setOrder(value as "fixed" | "shuffled")}>
|
||||||
|
<SelectTrigger id="radio-test-order" className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fixed">Fixed Order</SelectItem>
|
||||||
|
<SelectItem value="shuffled">Shuffled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="pb-2 text-xs text-muted-foreground">{pool.length} messages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="radio-test-pool" className="text-xs font-medium">
|
||||||
|
Message pool (one per line)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="radio-test-pool"
|
||||||
|
value={poolText}
|
||||||
|
onChange={(event) => setPoolText(event.target.value)}
|
||||||
|
rows={8}
|
||||||
|
placeholder={"VIKTOR ONE, EN ROUTE TO GRID 341-512, STATUS GREEN, OVER\nECHO TWO, CONTACT AT CHECKPOINT ALPHA, WEAPONS FREE, OUT"}
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={submitting} onClick={submit}>
|
||||||
|
{submitting ? <Loader2 className="animate-spin" /> : <Radio />}
|
||||||
|
Publish test
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/frontend/radio/CustomRadioArena.tsx
Normal file
87
src/components/frontend/radio/CustomRadioArena.tsx
Normal file
|
|
@ -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<Mode>("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 (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<RadioTraffic
|
||||||
|
initialStats={{
|
||||||
|
gamesPlayed: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
incorrectMessages: 0,
|
||||||
|
bestScore: 0,
|
||||||
|
bestAccuracy: 0,
|
||||||
|
bestSprintTimeMs: 0,
|
||||||
|
sprintGamesPlayed: 0,
|
||||||
|
sprintTotalMessages: 0,
|
||||||
|
sprintIncorrectMessages: 0,
|
||||||
|
sprintBestScore: 0,
|
||||||
|
sprintBestAccuracy: 0,
|
||||||
|
watchGamesPlayed: 0,
|
||||||
|
watchTotalMessages: 0,
|
||||||
|
watchIncorrectMessages: 0,
|
||||||
|
watchBestScore: 0,
|
||||||
|
watchBestAccuracy: 0,
|
||||||
|
radioTrafficXp: 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
}}
|
||||||
|
mode={mode}
|
||||||
|
onModeChangeAction={setMode}
|
||||||
|
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
||||||
|
onPhaseChangeAction={onPhaseChange}
|
||||||
|
customTest={test}
|
||||||
|
/>
|
||||||
|
<CustomTestLeaderboard
|
||||||
|
testId={test.id}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={setMode}
|
||||||
|
/>
|
||||||
|
{activeTeams.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{activeTeams.length} operator{activeTeams.length === 1 ? "" : "s"} on the net
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/components/frontend/radio/CustomTestBrowser.tsx
Normal file
136
src/components/frontend/radio/CustomTestBrowser.tsx
Normal file
|
|
@ -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<TestRow[] | null>(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 (
|
||||||
|
<div className="flex w-full max-w-5xl flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Community radio tests. Playing these does not affect your official stats or XP.
|
||||||
|
</p>
|
||||||
|
<CreateTestDialog unlocked={unlocked} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
Could not load custom radio tests.
|
||||||
|
</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-2 rounded-xl border border-border bg-card p-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="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
No custom radio tests published yet.
|
||||||
|
{unlocked ? " Be the first to author one!" : " Hit 90% sprint and 85% watch accuracy to author one."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radio className="size-4 shrink-0 text-emerald-400" />
|
||||||
|
<span className="truncate text-sm font-semibold">{row.name}</span>
|
||||||
|
{row.isOwn && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(yours)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.messageCount} messages · {row.order === "shuffled" ? "shuffled" : "fixed"} ·
|
||||||
|
by {row.authorName} · played {row.playCount}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href={`/radio/custom/${row.id}`}>
|
||||||
|
<Play /> Play
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/frontend/radio/CustomTestLeaderboard.tsx
Normal file
195
src/components/frontend/radio/CustomTestLeaderboard.tsx
Normal file
|
|
@ -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<LeaderboardRow[] | null>(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<number, ApiScore>();
|
||||||
|
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 (
|
||||||
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Trophy className="size-4 text-amber-400" />
|
||||||
|
<h2 className="text-sm font-semibold">Radio Test Leaderboard</h2>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select value={mode} onValueChange={(value) => onModeChange(value as ScoreMode)}>
|
||||||
|
<SelectTrigger aria-label="Leaderboard mode" className="h-8 w-[110px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sprint">Sprint</SelectItem>
|
||||||
|
<SelectItem value="watch">Watch</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · best score</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-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">
|
||||||
|
No runs logged yet. Take the first shift!
|
||||||
|
</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>
|
||||||
|
{mode === "sprint" && (
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Time</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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">{row.score}</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{Math.round(row.accuracy * 100)}%
|
||||||
|
</td>
|
||||||
|
{mode === "sprint" && (
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.timeMs !== null ? `${(row.timeMs / 1000).toFixed(1)}s` : "-"}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 type { RadioStats } from "@/app/(frontend)/radio/actions";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { RadioLeaderboard } from "./RadioLeaderboard";
|
import { RadioLeaderboard } from "./RadioLeaderboard";
|
||||||
|
|
@ -34,6 +36,13 @@ export function RadioArena({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<Link
|
||||||
|
href="/radio/custom"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1.5 text-sm text-emerald-300 transition-colors hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
<Radio className="size-4" />
|
||||||
|
Custom radio tests
|
||||||
|
</Link>
|
||||||
<RadioTraffic
|
<RadioTraffic
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions";
|
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions";
|
||||||
|
import { submitCustomTestScore } from "@/app/(frontend)/radio/custom/actions";
|
||||||
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -135,18 +136,49 @@ function Metric({ label, value }: { label: string; value: string | number }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CustomRadioTestData = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
messages: readonly string[];
|
||||||
|
order: "fixed" | "shuffled";
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCustomSprintMessages(test: CustomRadioTestData): readonly string[] {
|
||||||
|
const pool = [...test.messages];
|
||||||
|
if (test.order === "shuffled") {
|
||||||
|
for (let index = pool.length - 1; index > 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({
|
export function RadioTraffic({
|
||||||
initialStats: serverStats,
|
initialStats: serverStats,
|
||||||
mode,
|
mode,
|
||||||
onModeChangeAction,
|
onModeChangeAction,
|
||||||
onGameRecordedAction,
|
onGameRecordedAction,
|
||||||
onPhaseChangeAction,
|
onPhaseChangeAction,
|
||||||
|
customTest,
|
||||||
}: {
|
}: {
|
||||||
initialStats: RadioStats;
|
initialStats: RadioStats;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
onModeChangeAction: (mode: Mode) => void;
|
onModeChangeAction: (mode: Mode) => void;
|
||||||
onGameRecordedAction: () => void;
|
onGameRecordedAction: () => void;
|
||||||
onPhaseChangeAction: (phase: Phase) => void;
|
onPhaseChangeAction: (phase: Phase) => void;
|
||||||
|
customTest?: CustomRadioTestData;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { expired, expiredRef } = useSessionExpired();
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
|
@ -170,6 +202,7 @@ export function RadioTraffic({
|
||||||
const [phase, setPhase] = useState<Phase>("ready");
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
|
||||||
const [messages, setMessages] = useState<readonly string[]>(() => {
|
const [messages, setMessages] = useState<readonly string[]>(() => {
|
||||||
|
if (customTest) return buildCustomSprintMessages(customTest);
|
||||||
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
|
||||||
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
|
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
|
||||||
return initialRound;
|
return initialRound;
|
||||||
|
|
@ -247,15 +280,41 @@ export function RadioTraffic({
|
||||||
|
|
||||||
submittingRef.current = true;
|
submittingRef.current = true;
|
||||||
setSubmitting(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({
|
const result = await submitRadioResult({
|
||||||
score,
|
score,
|
||||||
messageCount: finalResults.length,
|
messageCount: finalResults.length,
|
||||||
correctMessages,
|
correctMessages,
|
||||||
incorrectMessages,
|
incorrectMessages,
|
||||||
accuracy,
|
accuracy,
|
||||||
mode: resultMode,
|
mode: resultMode,
|
||||||
timeMs: elapsedMs,
|
timeMs: elapsedMs,
|
||||||
});
|
});
|
||||||
submittingRef.current = false;
|
submittingRef.current = false;
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
|
|
@ -304,7 +363,7 @@ export function RadioTraffic({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[expiredRef, onGameRecordedAction, router],
|
[customTest, expiredRef, onGameRecordedAction, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishRound = useCallback(
|
const finishRound = useCallback(
|
||||||
|
|
@ -392,10 +451,14 @@ export function RadioTraffic({
|
||||||
watchExpiryNotifiedRef.current = false;
|
watchExpiryNotifiedRef.current = false;
|
||||||
countdownEndPlayedRef.current = false;
|
countdownEndPlayedRef.current = false;
|
||||||
if (mode === "sprint") {
|
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;
|
previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null;
|
||||||
} else {
|
} else {
|
||||||
const firstMessage = nextRadioMessage(watchUsedRef.current);
|
const firstMessage = customTest
|
||||||
|
? nextCustomWatchMessage(customTest, 0)
|
||||||
|
: nextRadioMessage(watchUsedRef.current);
|
||||||
watchUsedRef.current.add(firstMessage);
|
watchUsedRef.current.add(firstMessage);
|
||||||
nextMessages = [firstMessage];
|
nextMessages = [firstMessage];
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +481,7 @@ export function RadioTraffic({
|
||||||
messageStartedAtRef.current = now;
|
messageStartedAtRef.current = now;
|
||||||
setPhaseSafe("playing");
|
setPhaseSafe("playing");
|
||||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
}, [expiredRef, mode, setPhaseSafe]);
|
}, [customTest, expiredRef, mode, setPhaseSafe]);
|
||||||
|
|
||||||
const handleKeystroke = useCallback(
|
const handleKeystroke = useCallback(
|
||||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -508,7 +571,9 @@ export function RadioTraffic({
|
||||||
|
|
||||||
if (mode === "watch") {
|
if (mode === "watch") {
|
||||||
watchUsedRef.current.add(target);
|
watchUsedRef.current.add(target);
|
||||||
const nextTarget = nextRadioMessage(watchUsedRef.current);
|
const nextTarget = customTest
|
||||||
|
? nextCustomWatchMessage(customTest, watchUsedRef.current.size)
|
||||||
|
: nextRadioMessage(watchUsedRef.current);
|
||||||
watchUsedRef.current.add(nextTarget);
|
watchUsedRef.current.add(nextTarget);
|
||||||
setMessages((current) => [...current, nextTarget]);
|
setMessages((current) => [...current, nextTarget]);
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +581,7 @@ export function RadioTraffic({
|
||||||
messageStartedAtRef.current = now;
|
messageStartedAtRef.current = now;
|
||||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
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;
|
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;
|
||||||
|
|
|
||||||
915
tests/int/custom-radio-tests.int.spec.ts
Normal file
915
tests/int/custom-radio-tests.int.spec.ts
Normal file
|
|
@ -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<number> => {
|
||||||
|
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<number, number>();
|
||||||
|
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<User> => {
|
||||||
|
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<unknown> => {
|
||||||
|
return (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue