1
0
Fork 0

Compare commits

...

23 commits

Author SHA1 Message Date
181f244f62 v0.2.25 - move minigames under /minigames, hide locked UGC entries, fix return url and unlock readout 2026-09-09 00:03:54 -04:00
1d4dee0433 chore: ignore playwright report artifacts
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:45 -04:00
167ac22990 fix(radio): floor unlock-progress readout so it never overstates the threshold
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:36 -04:00
911fc31354 feat(minigames): hide UGC entry buttons until custom content unlocks
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:29 -04:00
135a7fd5ae docs: update minigame paths in AGENTS.md
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:18 -04:00
14c9f0b4ec feat(minigames): redirect legacy minigame URLs to /minigames/*
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:18 -04:00
285de3ac14 fix(flappy): stop deriving returnTo from x-invoke-path
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:03:18 -04:00
25975db23f refactor(minigames): move routes, components, and libs under minigames parents
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 00:02:59 -04:00
76fbaba293 refactor(minigames): derive custom-page unlock state from shared lib predicates
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 22:38:38 -04:00
455d705eb9 feat(minigames): add UnlockNotice and wire into EOD and radio arenas
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 22:38:38 -04:00
f76966ebc3 v0.2.24 - add custom minefield XP payouts, exposure toggle, and loss fog 2026-09-08 15:57:13 -04:00
226cd9b2b8 test(eod): cover custom payout math, loss reveal, and exposure rule
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
bf491fc9cd feat(eod): add author exposure toggle and reduced-XP copy to custom play
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
6d4b5ad297 feat(eod): restrict custom loss reveal and add question-mark fog to hidden cells
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
efda519569 feat(eod): pay reduced XP with creator banking on custom minefield runs
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
243bb8ae0d feat(minigames): add creator bank and exposure toggle schema with migrations
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
987e78babd feat(minigames): add custom payout math and loss-reveal helpers to shared libs
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 15:57:01 -04:00
eeab85ad35 v0.2.23 - add custom EOD minefields and custom radio tests minigames 2026-09-08 14:45:29 -04:00
4fde0fe91c fix(ui): use h-dvh for sheet side variants so panels fill mobile viewports
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 14:45:29 -04:00
1cf11745fe 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>
2026-09-08 14:41:18 -04:00
52328da8f9 feat(eod): add custom minefield authoring, play, and leaderboard
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 14:41:18 -04:00
5b4842943e feat(minigames): extract shared custom-minigame validation and unlock libs
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 14:41:18 -04:00
1f99e34c0d feat(minigames): add custom minefield and radio test collections with permissions, events, and migration
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-08 14:41:18 -04:00
84 changed files with 79565 additions and 75 deletions

2
.gitignore vendored
View file

@ -55,6 +55,8 @@ next-env.d.ts
# Playwright
node_modules/
/test-results/
/playwright-report/
/.archived/test-results/
/.archived/playwright-report/
/blob-report/

View file

@ -298,11 +298,11 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
## Training Minigames
Four XP-earning minigames, each with a page + server actions + a pure client-safe game-rules lib, recording stats under Profiles `progression.minigames.*`:
Five XP-earning minigames under one parent: routes live at `/minigames/*` (moved from `/qualification`, `/radio`, `/flappy`, `/eod`, `/opord`; `next.config.mjs` redirects keep the old URLs working), components under `src/components/frontend/minigames/<game>/` (shared bits like `UnlockNotice.tsx` at the root), game-rule libs under `src/lib/minigames/` (custom-content libs in `src/lib/minigames/custom/`), stats under Profiles `progression.minigames.*`. The realtime presence channel strings (`"eod"`, `"radio"`, ...) are unchanged:
- **Qualification Course (aim trainer)** — `/qualification` (`src/app/(frontend)/qualification/`), game rules in `src/lib/aim-trainer.ts` (recruit/veteran/elite difficulties, civilian targets, scoring/accuracy/XP, injected rng). Components: `src/components/frontend/qualification/` (`AimTrainerArena`, `AimTrainerGame`, `AimTrainerCanvas`, `AimTrainerLeaderboard`). Action `submitAimTrainerResult` validates result bounds, updates profile stats, awards XP, emits `minigame:aim-trainer`.
- **Radio Traffic** — `/radio`, sprint + watch modes, procedural message generation (`src/components/frontend/radio/messageGenerator.ts`). Action `submitRadioResult` emits `minigame:radio-traffic`; stats under `progression.minigames.radioTraffic` (incl. `bestSprintTimeMs`).
- **Flight Simulator** — `/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)** — `/eod`.
- **Qualification Course (aim trainer)**: `/minigames/qualification` (`src/app/(frontend)/minigames/qualification/`), game rules in `src/lib/minigames/aim-trainer.ts` (recruit/veteran/elite difficulties, civilian targets, scoring/accuracy/XP, injected rng). Components: `src/components/frontend/minigames/qualification/` (`AimTrainerArena`, `AimTrainerGame`, `AimTrainerCanvas`, `AimTrainerLeaderboard`). Action `submitAimTrainerResult` validates result bounds, updates profile stats, awards XP, emits `minigame:aim-trainer`.
- **Radio Traffic**: `/minigames/radio`, sprint + watch modes, procedural message generation (`src/components/frontend/minigames/radio/messageGenerator.ts`). Action `submitRadioResult` emits `minigame:radio-traffic`; stats under `progression.minigames.radioTraffic` (incl. `bestSprintTimeMs`).
- **Flight Simulator**: `/minigames/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)**: `/minigames/eod`. **OPORD Recall**: `/minigames/opord` (`src/lib/minigames/opord-recall.ts`).
- Leaderboards match active players by user id (not display name) — see commit 72715c4 if leaderboard queries look wrong.
## Discord Bot

View file

@ -9,6 +9,23 @@ const nextConfig = {
env: {
version: packageJson.version,
},
// Minigame routes moved under /minigames/* (source + URLs). Keep old URLs
// working for bookmarks and stale links; temporary (307/308) so they stay
// easy to change during active development. Next 16 requires `permanent`
// to be set explicitly on every entry.
async redirects() {
return [
{ source: "/qualification", destination: "/minigames/qualification", permanent: false },
{ source: "/radio", destination: "/minigames/radio", permanent: false },
{ source: "/radio/custom", destination: "/minigames/radio/custom", permanent: false },
{ source: "/radio/custom/:id", destination: "/minigames/radio/custom/:id", permanent: false },
{ source: "/eod", destination: "/minigames/eod", permanent: false },
{ source: "/eod/custom", destination: "/minigames/eod/custom", permanent: false },
{ source: "/eod/custom/:id", destination: "/minigames/eod/custom/:id", permanent: false },
{ source: "/flappy", destination: "/minigames/flappy", permanent: false },
{ source: "/opord", destination: "/minigames/opord", permanent: false },
];
},
// Server Actions default to a 1 MB request body limit, which would reject
// most image uploads. Bump it so wiki image uploads (capped at 8 MB in the
// uploadWikiImage action) pass through with room for multipart overhead.

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.22",
"version": "0.2.25",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -0,0 +1,85 @@
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, Bomb } from "lucide-react";
import { CustomMinefieldArena } from "@/components/frontend/minigames/eod/CustomMinefieldArena";
export const metadata: Metadata = {
title: "Play Custom Minefield (Polaris Task Force)",
};
export default async function CustomMinefieldPlayPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const minefieldId = Number(id);
if (!Number.isInteger(minefieldId) || minefieldId <= 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 minefield = await payload
.findByID({
collection: "custom-minefields",
id: minefieldId,
depth: 1,
})
.catch(() => null);
if (!minefield || !minefield.published) {
notFound();
}
const mines = Array.isArray(minefield.mines)
? (minefield.mines as { row: number; col: number }[])
: [];
const authorId =
typeof minefield.author === "object" && minefield.author !== null
? minefield.author.id
: minefield.author;
const isAuthor = user !== null && String(authorId) === String(user.id);
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex items-center gap-2">
<Link
href="/minigames/eod/custom"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> All custom minefields
</Link>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Bomb className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{minefield.name}</h1>
</div>
<p className="text-sm text-muted-foreground">
Custom wins award reduced XP (10% of standard payouts); field authors bank 5% of each
payout.
</p>
</div>
<CustomMinefieldArena
minefield={{
id: minefield.id,
name: minefield.name,
rows: minefield.rows,
cols: minefield.cols,
mines,
exposeEdgeMines: minefield.exposeEdgeMines ?? true,
}}
currentUserId={user?.id ?? 0}
isAuthor={isAuthor}
/>
</div>
);
}

View file

@ -0,0 +1,442 @@
"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 { broadcastToUser } from "@/lib/realtime/bus";
import { creditEodXp } from "@/lib/minigames/custom/payouts";
import {
applyCreatorBank,
customPlayerXp,
isEodUnlocked,
minefieldFlagsError,
validateGridSize,
validateMinefieldName,
validateMinefieldScore,
validateMinePositions,
} from "@/lib/minigames/custom/eod";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
export interface CustomMinefieldSummary {
id: number;
name: string;
authorId: number | null;
authorName: string;
rows: number;
cols: number;
mineCount: number;
published: boolean;
playCount: number;
canEdit: boolean;
createdAt: string;
}
export interface EodUnlockStatus {
unlocked: boolean;
bestTrainingTime: number;
bestPerimeterTime: number;
bestSupplyRoadTime: 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 checkEodUnlock(): Promise<ActionResult<EodUnlockStatus>> {
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 eod =
profileRes.docs[0]?.progression?.minigames?.eodMinesweeper ?? {};
const bestTrainingTime = eod.bestTrainingTime ?? 0;
const bestPerimeterTime = eod.bestPerimeterTime ?? 0;
const bestSupplyRoadTime = eod.bestSupplyRoadTime ?? 0;
return {
success: true,
data: {
unlocked: isEodUnlocked({
bestTrainingTime,
bestPerimeterTime,
bestSupplyRoadTime,
}),
bestTrainingTime,
bestPerimeterTime,
bestSupplyRoadTime,
},
};
} 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 };
}
}
function authorName(author: unknown): { id: number | null; name: string } {
if (author && typeof author === "object") {
const doc = author as {
id?: number;
displayName?: string | null;
username?: string | null;
};
return {
id: doc.id ?? null,
name: doc.displayName || doc.username || `Operator #${doc.id ?? "?"}`,
};
}
if (typeof author === "number") {
return { id: author, name: `Operator #${author}` };
}
return { id: null, name: "Unknown" };
}
export async function createMinefield(input: {
name: string;
rows: number;
cols: number;
mines: unknown;
exposeEdgeMines?: boolean;
}): Promise<ActionResult<{ id: number }>> {
try {
const { payload, user } = await authenticate();
const nameCheck = validateMinefieldName(input.name);
if (nameCheck.error) {
return { success: false, error: nameCheck.error };
}
const name = nameCheck.name;
const gridCheck = validateGridSize(input.rows, input.cols);
if (gridCheck.error) {
return { success: false, error: gridCheck.error };
}
const mineCheck = validateMinePositions(input.mines, input.rows, input.cols);
if (mineCheck.error) {
return { success: false, error: mineCheck.error };
}
const created = await payload.create({
collection: "custom-minefields",
data: {
name,
author: user.id as number,
rows: input.rows,
cols: input.cols,
mines: mineCheck.positions,
mineCount: mineCheck.positions.length,
published: true,
exposeEdgeMines: input.exposeEdgeMines ?? true,
playCount: 0,
},
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.MinigameCustomEodCreate,
message: `Published custom minefield "${name}" (${input.rows}×${input.cols}, ${mineCheck.positions.length} mines)`,
actor: user.id as number,
targetCollection: "custom-minefields",
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 updateMinefield(input: {
id: number;
name?: string;
mines?: unknown;
published?: boolean;
exposeEdgeMines?: 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 minefield." };
}
const existing = await payload.findByID({
collection: "custom-minefields",
id: input.id as number,
depth: 0,
});
if (!existing) {
return { success: false, error: "Minefield not found." };
}
const isManager = await hasPermission(payload, user, "custom-minefields:update");
const isAuthor = existing.author === (user.id as number);
if (!isManager && !isAuthor) {
return { success: false, error: "You can only edit your own minefields." };
}
const data: Record<string, unknown> = {};
if (input.name !== undefined) {
const nameCheck = validateMinefieldName(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.exposeEdgeMines !== undefined) {
data.exposeEdgeMines = Boolean(input.exposeEdgeMines);
}
if (input.mines !== undefined) {
const mineCheck = validateMinePositions(input.mines, existing.rows, existing.cols);
if (mineCheck.error) {
return { success: false, error: mineCheck.error };
}
data.mines = mineCheck.positions;
data.mineCount = mineCheck.positions.length;
}
await payload.update({
collection: "custom-minefields",
id: existing.id,
data,
depth: 0,
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.MinigameCustomEodEdit,
message: `Updated custom minefield "${existing.name}"`,
actor: user.id as number,
targetCollection: "custom-minefields",
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: "Minefield not found." };
}
return { success: false, error: message };
}
}
export async function deleteMinefield(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 minefield." };
}
const existing = await payload.findByID({
collection: "custom-minefields",
id: input.id as number,
depth: 0,
});
if (!existing) {
return { success: false, error: "Minefield not found." };
}
await payload.delete({ collection: "custom-minefields", id: existing.id, depth: 0 });
await payload.delete({
collection: "custom-minefield-scores",
where: { minefield: { equals: existing.id } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.MinigameCustomEodDelete,
message: `Deleted custom minefield "${existing.name}"`,
actor: user.id as number,
targetCollection: "custom-minefields",
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 minefields." };
}
if (message === "Not Found" || message.includes("not found")) {
return { success: false, error: "Minefield not found." };
}
return { success: false, error: message };
}
}
export async function submitCustomMinefieldScore(input: {
minefieldId: number;
seconds: number;
flags: number;
won: boolean;
}): Promise<ActionResult<{ xpEarned: number }>> {
try {
const { payload, user } = await authenticate();
if (
!Number.isInteger(input.minefieldId) ||
(input.minefieldId as number) <= 0
) {
return { success: false, error: "Invalid minefield." };
}
const scoreCheck = validateMinefieldScore({
seconds: input.seconds,
flags: input.flags,
});
if (scoreCheck.error) {
return { success: false, error: scoreCheck.error };
}
const minefield = await payload.findByID({
collection: "custom-minefields",
id: input.minefieldId as number,
depth: 0,
});
if (!minefield || !minefield.published) {
return { success: false, error: "Minefield not found." };
}
const flagError = minefieldFlagsError(input.flags, minefield.mineCount);
if (flagError) {
return { success: false, error: flagError };
}
// Payouts: wins pay 10% of the equivalent standard run, the field's author
// banks 5% of each payout, and authors earn nothing on their own fields.
const isOwnPlay = minefield.author === (user.id as number);
let playerXp = 0;
let creatorXpCredited = 0;
let creatorXpBank = minefield.creatorXpBank ?? 0;
if (input.won && !isOwnPlay) {
playerXp = customPlayerXp({
mineCount: minefield.mineCount,
seconds: input.seconds,
won: true,
});
if (playerXp > 0) {
const credited = await creditEodXp(payload, user.id as number, playerXp);
if (credited) {
broadcastToUser("profile-update", Number(user.id), {
reason: "xp",
xpGained: playerXp,
profileId: credited.profileId,
});
}
const prevBank = creatorXpBank;
const applied = applyCreatorBank(prevBank, playerXp);
const authorId = typeof minefield.author === "number" ? minefield.author : null;
const authorCredit =
applied.wholeXpToCredit > 0 && authorId !== null
? await creditEodXp(payload, authorId, applied.wholeXpToCredit)
: null;
if (authorCredit) {
creatorXpCredited = applied.wholeXpToCredit;
creatorXpBank = applied.bank;
} else {
// No profile (or nothing whole due): keep the full banked fraction.
creatorXpBank = prevBank + applyCreatorBank(0, playerXp).bank;
}
}
}
await payload.create({
collection: "custom-minefield-scores",
data: {
minefield: minefield.id,
user: user.id as number,
seconds: input.seconds,
flags: input.flags,
won: Boolean(input.won),
},
depth: 0,
});
await payload.update({
collection: "custom-minefields",
id: minefield.id,
data: {
playCount: (minefield.playCount ?? 0) + 1,
...(playerXp > 0 ? { creatorXpBank } : {}),
},
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.MinigameCustomEodPlay,
message: input.won
? `Cleared custom minefield "${minefield.name}" in ${input.seconds}s${
playerXp > 0 ? ` (+${playerXp} XP)` : ""
}`
: `Died on custom minefield "${minefield.name}" after ${input.seconds}s`,
actor: user.id as number,
targetCollection: "custom-minefields",
targetId: minefield.id,
data: {
seconds: input.seconds,
flags: input.flags,
won: Boolean(input.won),
xpEarned: playerXp,
creatorXpCredited,
},
});
return { success: true, data: { xpEarned: playerXp } };
} 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: "Minefield not found." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,53 @@
import type { Metadata } from "next";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { Bomb } from "lucide-react";
import { CustomMinefieldBrowser } from "@/components/frontend/minigames/eod/CustomMinefieldBrowser";
import { isEodUnlocked } from "@/lib/minigames/custom/eod";
export const metadata: Metadata = {
title: "Custom Minefields (Polaris Task Force)",
description: "Browse and play community-authored minefields.",
};
export default async function CustomMinefieldsPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
const eod = 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?.eodMinesweeper ?? {}
: {};
const unlocked = isEodUnlocked({
bestTrainingTime: eod.bestTrainingTime ?? 0,
bestPerimeterTime: eod.bestPerimeterTime ?? 0,
bestSupplyRoadTime: eod.bestSupplyRoadTime ?? 0,
});
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Bomb className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Custom Minefields</h1>
</div>
<p className="text-sm text-muted-foreground">
Operator-authored minefields. Unranked, no XP.
</p>
</div>
<CustomMinefieldBrowser currentUserId={user?.id ?? 0} unlocked={unlocked} />
</div>
);
}

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Bomb } from "lucide-react";
import { EodArena } from "@/components/frontend/eod/EodArena";
import { EodArena } from "@/components/frontend/minigames/eod/EodArena";
export const metadata: Metadata = {
title: "Minefield Clearance (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function EodPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/eod")}`);
redirect(`/login?returnTo=${encodeURIComponent("/minigames/eod")}`);
}
const profileRes = await payload.find({

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Bird } from "lucide-react";
import { FlappyArena } from "@/components/frontend/flappy/FlappyArena";
import { FlappyArena } from "@/components/frontend/minigames/flappy/FlappyArena";
export const metadata: Metadata = {
title: "Flight Simulator: Polaris Task Force",
@ -18,8 +18,7 @@ export default async function FlappyPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
const pathname = headers.get("x-invoke-path") || "/flappy";
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
redirect(`/login?returnTo=${encodeURIComponent("/minigames/flappy")}`);
}
const profileRes = await payload.find({

View file

@ -12,7 +12,7 @@ import {
isOpordDifficulty,
minOpordRunTimeMs,
type OpordDifficulty,
} from "@/lib/opord-recall";
} from "@/lib/minigames/opord-recall";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus";

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { ScrollText } from "lucide-react";
import { OpordArena } from "@/components/frontend/opord/OpordArena";
import { OpordArena } from "@/components/frontend/minigames/opord/OpordArena";
export const metadata: Metadata = {
title: "OPORD Recall (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function OpordPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/opord")}`);
redirect(`/login?returnTo=${encodeURIComponent("/minigames/opord")}`);
}
const profileRes = await payload.find({

View file

@ -14,7 +14,7 @@ import {
MAX_HITS,
MAX_DESPAWNED_TARGETS,
MAX_CIVILIAN_HITS,
} from "@/lib/aim-trainer";
} from "@/lib/minigames/aim-trainer";
interface ActionResult<T = undefined> {
success: boolean;

View file

@ -5,8 +5,8 @@ import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Crosshair } from "lucide-react";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions";
import { AimTrainerArena } from "@/components/frontend/qualification/AimTrainerArena";
import type { AimTrainerStats } from "@/app/(frontend)/minigames/qualification/actions";
import { AimTrainerArena } from "@/components/frontend/minigames/qualification/AimTrainerArena";
export const metadata: Metadata = {
title: "Qualification Course: Polaris Task Force",
@ -20,7 +20,7 @@ export default async function QualificationPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/qualification")}`);
redirect(`/login?returnTo=${encodeURIComponent("/minigames/qualification")}`);
}
const profileRes = await payload.find({

View 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/minigames/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="/minigames/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>
);
}

View 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/minigames/custom/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 };
}
}

View file

@ -0,0 +1,52 @@
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/minigames/radio/CustomTestBrowser";
import { isRadioUnlocked } from "@/lib/minigames/custom/radio";
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 = isRadioUnlocked({
sprintBestAccuracy: radio.sprintBestAccuracy ?? 0,
watchBestAccuracy: radio.watchBestAccuracy ?? 0,
});
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Radio className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">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>
);
}

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Radio } from "lucide-react";
import { RadioArena } from "@/components/frontend/radio/RadioArena";
import { RadioArena } from "@/components/frontend/minigames/radio/RadioArena";
export const metadata: Metadata = {
title: "Radio Traffic (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function RadioPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/radio")}`);
redirect(`/login?returnTo=${encodeURIComponent("/minigames/radio")}`);
}
const profileRes = await payload.find({

View file

@ -36,6 +36,10 @@ const TARGET_COLLECTIONS = [
{ label: "Voice Submissions", value: "voice-submissions" },
{ label: "Story Beats", value: "story-beats" },
{ label: "Story Beat States", value: "story-beat-states" },
{ label: "Custom Minefields", value: "custom-minefields" },
{ label: "Custom Minefield Scores", value: "custom-minefield-scores" },
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -0,0 +1,48 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const CustomMinefieldScores: CollectionConfig = {
slug: "custom-minefield-scores",
admin: {
group: "Minigames",
defaultColumns: ["minefield", "user", "seconds", "won", "createdAt"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: () => false,
delete: requirePermission("custom-minefield-scores:delete"),
},
fields: [
{
name: "minefield",
type: "relationship",
relationTo: "custom-minefields",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
},
{
name: "seconds",
type: "number",
required: true,
},
{
name: "flags",
type: "number",
defaultValue: 0,
},
{
name: "won",
type: "checkbox",
required: true,
},
],
timestamps: true,
};

View file

@ -0,0 +1,107 @@
import { CollectionConfig } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const CustomMinefields: CollectionConfig = {
slug: "custom-minefields",
admin: {
group: "Minigames",
useAsTitle: "name",
defaultColumns: ["name", "author", "rows", "cols", "mineCount", "published", "playCount"],
listSearchableFields: ["name"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "custom-minefields:update")) return true;
return { author: { equals: req.user.id } };
},
delete: requirePermission("custom-minefields:delete"),
},
fields: [
{
name: "name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "Player who authored this minefield.",
},
},
{
name: "rows",
type: "number",
required: true,
min: 9,
max: 500,
},
{
name: "cols",
type: "number",
required: true,
min: 9,
max: 500,
},
{
name: "mines",
type: "json",
required: true,
admin: {
description: "Array of { row, col } mine positions.",
},
},
{
name: "mineCount",
type: "number",
required: true,
min: 1,
max: 10000,
},
{
name: "published",
type: "checkbox",
defaultValue: true,
},
{
name: "exposeEdgeMines",
type: "checkbox",
defaultValue: true,
admin: {
description:
"On a failed run, reveal mines bordering the swept area. When off, no remaining mines are revealed.",
},
},
{
name: "playCount",
type: "number",
defaultValue: 0,
admin: {
readOnly: true,
},
},
{
name: "creatorXpBank",
type: "number",
defaultValue: 0,
// Engine-managed payout ledger: only server actions may write it. Field
// access blocks REST/admin edits (overrideAccess in the actions bypasses
// this), so authors cannot credit their own bank.
access: {
update: () => false,
},
admin: {
readOnly: true,
description:
"Engine-managed: fractional XP bank for the author (5% of each player payout).",
},
},
],
timestamps: true,
};

View file

@ -0,0 +1,69 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const CustomRadioTestScores: CollectionConfig = {
slug: "custom-radio-test-scores",
admin: {
group: "Minigames",
defaultColumns: ["test", "user", "score", "accuracy", "mode", "createdAt"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: () => false,
delete: requirePermission("custom-radio-test-scores:delete"),
},
fields: [
{
name: "test",
type: "relationship",
relationTo: "custom-radio-tests",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
index: true,
},
{
name: "score",
type: "number",
required: true,
},
{
name: "accuracy",
type: "number",
required: true,
},
{
name: "correctMessages",
type: "number",
required: true,
},
{
name: "incorrectMessages",
type: "number",
required: true,
},
{
name: "mode",
type: "select",
required: true,
options: [
{ label: "Sprint", value: "sprint" },
{ label: "Watch", value: "watch" },
],
},
{
name: "timeMs",
type: "number",
admin: {
description: "Sprint mode only: elapsed time in milliseconds.",
},
},
],
timestamps: true,
};

View file

@ -0,0 +1,77 @@
import { CollectionConfig } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const CustomRadioTests: CollectionConfig = {
slug: "custom-radio-tests",
admin: {
group: "Minigames",
useAsTitle: "name",
defaultColumns: ["name", "author", "messageCount", "order", "published", "playCount"],
listSearchableFields: ["name"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "custom-radio-tests:update")) return true;
return { author: { equals: req.user.id } };
},
delete: requirePermission("custom-radio-tests:delete"),
},
fields: [
{
name: "name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "Player who authored this radio test.",
},
},
{
name: "messages",
type: "json",
required: true,
admin: {
description: "Array of message strings used as the typing pool.",
},
},
{
name: "messageCount",
type: "number",
required: true,
min: 1,
max: 100,
},
{
name: "order",
type: "select",
defaultValue: "fixed",
options: [
{ label: "Fixed Order", value: "fixed" },
{ label: "Shuffled", value: "shuffled" },
],
},
{
name: "published",
type: "checkbox",
defaultValue: true,
},
{
name: "playCount",
type: "number",
defaultValue: 0,
admin: {
readOnly: true,
},
},
],
timestamps: true,
};

View file

@ -224,35 +224,35 @@ export const navigationCommands: NavigationCommand[] = [
id: "nav:qualification-course",
label: "Qualification Course",
icon: Crosshair,
href: "/qualification",
href: "/minigames/qualification",
keywords: ["aim trainer", "minigame", "shooting", "practice"],
},
{
id: "nav:radio-traffic",
label: "Radio Traffic",
icon: Radio,
href: "/radio",
href: "/minigames/radio",
keywords: ["minigame", "comms", "messages", "training"],
},
{
id: "nav:flight-simulator",
label: "Flight Simulator",
icon: PlaneTakeoff,
href: "/flappy",
href: "/minigames/flappy",
keywords: ["minigame", "flappy", "fly", "training"],
},
{
id: "nav:minefield-clearance",
label: "Minefield Clearance",
icon: Bomb,
href: "/eod",
href: "/minigames/eod",
keywords: ["minigame", "eod", "mines", "training"],
},
{
id: "nav:opord-recall",
label: "OPORD Recall",
icon: ScrollText,
href: "/opord",
href: "/minigames/opord",
keywords: ["minigame", "briefing", "memory", "operations order", "training"],
},
{

View file

@ -144,31 +144,31 @@ export function NavUser({
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem asChild>
<Link href="/flappy">
<Link href="/minigames/flappy">
<Bird />
Flight Simulator
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/eod">
<Link href="/minigames/eod">
<Bomb />
Minefield Clearance
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/radio">
<Link href="/minigames/radio">
<Radio />
Radio Traffic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/qualification">
<Link href="/minigames/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/opord">
<Link href="/minigames/opord">
<FileTextIcon />
OPORD Recall
</Link>

View file

@ -0,0 +1,51 @@
import Link from "next/link";
import { ExternalLink, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
* Fully-featured nav button for a minigame's custom (UGC) area: an eyebrow
* status line, a title, and a pitch subtitle. Neutral outline styling, and the
* eyebrow flips between the unlocked celebration and the locked state.
*/
export function UgcLinkButton({
href,
title,
subtitle,
unlocked,
className,
}: {
href: string;
title: string;
subtitle: string;
unlocked: boolean;
className?: string;
}) {
return (
<Button
asChild
variant="outline"
className={cn("relative h-auto w-full gap-4 whitespace-normal px-4 py-4 text-left", className)}
>
<Link href={href}>
<span
aria-hidden
className="pointer-events-none absolute inset-0 animate-pulse rounded-md border-2 border-white/60"
/>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Wrench className="size-5" />
</span>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
{unlocked ? "You've unlocked" : "Locked"}
</span>
<span className="text-sm font-semibold text-foreground">{title}</span>
<span className="text-xs font-normal leading-relaxed text-muted-foreground">
{subtitle}
</span>
</span>
<ExternalLink className="ml-auto shrink-0 text-muted-foreground" />
</Link>
</Button>
);
}

View file

@ -0,0 +1,26 @@
import { Lock } from "lucide-react";
/**
* Locked-state hint shown beside a minigame's custom-content link: tells the
* player exactly which milestones unlock authoring user-generated content and
* how close they are. Unlocked players see nothing (the link speaks for itself).
*/
export function UnlockNotice({
title,
requirement,
progress,
}: {
title: string;
requirement: string;
progress: string;
}) {
return (
<p className="flex max-w-xl items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-300">
<Lock className="mt-0.5 size-3.5 shrink-0" />
<span>
<span className="font-medium">Author your own {title}</span> once {requirement}{" "}
<span className="text-amber-200/60">({progress})</span>
</span>
</p>
);
}

View file

@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import { Bomb, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { createMinefield } from "@/app/(frontend)/minigames/eod/custom/actions";
const MIN_SIZE = 9;
const MAX_SIZE = 500;
const PAINTER_MAX_CELLS = 2500;
export function CreateMinefieldDialog({
unlocked,
children,
}: {
unlocked: boolean;
children?: React.ReactNode;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [rows, setRows] = useState(9);
const [cols, setCols] = useState(9);
const [mines, setMines] = useState<Set<number>>(new Set());
const [exposeEdgeMines, setExposeEdgeMines] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const paintable = rows * cols <= PAINTER_MAX_CELLS;
const mineCount = mines.size;
const clampSize = (value: number) =>
Math.max(MIN_SIZE, Math.min(MAX_SIZE, Number.isFinite(value) ? value : MIN_SIZE));
const resetPainting = (nextRows: number, nextCols: number) => {
const next = new Set<number>();
for (const key of mines) {
const row = Math.floor(key / cols);
const col = key % cols;
if (row < nextRows && col < nextCols) next.add(row * nextCols + col);
}
setMines(next);
};
const toggleCell = (index: number) => {
setMines((current) => {
const next = new Set(current);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
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 (mineCount < 1) {
setError("Paint at least one mine.");
return;
}
setSubmitting(true);
const positions = Array.from(mines).map((key) => ({
row: Math.floor(key / cols),
col: key % cols,
}));
const result = await createMinefield({
name: trimmed,
rows,
cols,
mines: positions,
exposeEdgeMines,
});
setSubmitting(false);
if (result.success) {
setOpen(false);
setName("");
setMines(new Set());
router.refresh();
} else {
setError(result.error ?? "Could not publish the minefield.");
}
};
if (!unlocked) return null;
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setError(null);
}}
>
<DialogTrigger asChild>
{children ?? (
<Button type="button">
<Bomb /> Author minefield
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[85dvh] overflow-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Author a custom minefield</DialogTitle>
<DialogDescription>
Pick a grid size, then paint charges. Published fields are instantly playable by every
operator. No safe first click is guaranteed.
</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="minefield-name" className="text-xs font-medium">
Name
</label>
<Input
id="minefield-name"
value={name}
maxLength={100}
onChange={(event) => setName(event.target.value)}
placeholder="Sector Ghost"
className="w-48"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="minefield-rows" className="text-xs font-medium">
Rows
</label>
<Input
id="minefield-rows"
type="number"
min={MIN_SIZE}
max={MAX_SIZE}
value={rows}
onChange={(event) => {
const next = clampSize(Number(event.target.value));
setRows(next);
resetPainting(next, cols);
}}
className="w-24"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="minefield-cols" className="text-xs font-medium">
Columns
</label>
<Input
id="minefield-cols"
type="number"
min={MIN_SIZE}
max={MAX_SIZE}
value={cols}
onChange={(event) => {
const next = clampSize(Number(event.target.value));
setCols(next);
resetPainting(rows, next);
}}
className="w-24"
/>
</div>
<span className="flex items-center gap-1.5 pb-2 text-sm font-medium tabular-nums">
<Bomb className="size-4 text-amber-400" />
{mineCount} mines
</span>
</div>
{paintable ? (
<div
className="grid max-h-[45dvh] min-w-max gap-0.5 overflow-auto rounded-lg border border-border bg-background p-2"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1.25rem))` }}
role="grid"
aria-label="Mine painter"
>
{Array.from({ length: rows * cols }, (_, index) => {
const painted = mines.has(index);
return (
<button
key={index}
type="button"
role="gridcell"
aria-label={`row ${Math.floor(index / cols) + 1} col ${(index % cols) + 1}${painted ? " mine" : ""}`}
className={cn(
"flex size-5 items-center justify-center rounded-sm border transition-colors",
painted
? "border-destructive bg-destructive text-destructive-foreground"
: "border-emerald-700/60 bg-emerald-900/40 hover:border-emerald-400 hover:bg-emerald-700",
)}
onClick={() => toggleCell(index)}
>
{painted ? <Bomb className="size-3" /> : null}
</button>
);
})}
</div>
) : (
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200">
Painting supports grids up to {Math.sqrt(PAINTER_MAX_CELLS)}×
{Math.sqrt(PAINTER_MAX_CELLS)} cells. Shrink the grid to author charges.
</p>
)}
<div className="flex items-start gap-2 rounded-lg border border-border p-3">
<Checkbox
id="expose-edge-mines"
checked={exposeEdgeMines}
onCheckedChange={(value) => setExposeEdgeMines(value === true)}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<label htmlFor="expose-edge-mines" className="text-sm font-medium leading-none">
Reveal bordering charges on failed runs
</label>
<p className="text-xs text-muted-foreground">
Failed runs show mines that touch the swept area. Turn off to reveal none of the
remaining charges.
</p>
</div>
</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 || !paintable} onClick={submit}>
{submitting ? <Loader2 className="animate-spin" /> : <Bomb />}
Publish minefield
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,54 @@
"use client";
import { useCallback, useState } from "react";
import { CustomMinesweeperBoard, type CustomMinefieldData } from "./CustomMinesweeperBoard";
import { CustomMinefieldLeaderboard } from "./CustomMinefieldLeaderboard";
import { ExposureRuleToggle } from "./ExposureRuleToggle";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { useEffect } from "react";
export function CustomMinefieldArena({
minefield,
currentUserId,
isAuthor,
}: {
minefield: CustomMinefieldData;
currentUserId: number | string;
isAuthor: boolean;
}) {
const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false);
const { isMobile, setOpenMobile } = useSidebar();
const activeTeams = useRealtimePresence("eod", playing, true);
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
setPlaying(phase === "playing");
}, []);
useEffect(() => {
if (playing && isMobile) setOpenMobile(false);
}, [isMobile, playing, setOpenMobile]);
return (
<div className="flex flex-col items-center gap-6">
<CustomMinesweeperBoard
minefield={minefield}
onGameRecorded={() => setRefreshKey((key) => key + 1)}
onPhaseChange={onPhaseChange}
/>
{isAuthor && (
<ExposureRuleToggle minefieldId={minefield.id} exposeEdgeMines={minefield.exposeEdgeMines} />
)}
<CustomMinefieldLeaderboard
minefieldId={minefield.id}
refreshKey={refreshKey}
currentUserId={currentUserId}
/>
{activeTeams.length > 0 && (
<p className="text-xs text-muted-foreground">
{activeTeams.length} operator{activeTeams.length === 1 ? "" : "s"} on site
</p>
)}
</div>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bomb, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CreateMinefieldDialog } from "./CreateMinefieldDialog";
interface MinefieldRow {
id: number;
name: string;
rows: number;
cols: number;
mineCount: number;
playCount: number;
authorName: string;
isOwn: boolean;
}
export function CustomMinefieldBrowser({
currentUserId,
unlocked,
}: {
currentUserId: number | string;
unlocked: boolean;
}) {
const [rows, setRows] = useState<MinefieldRow[] | 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-minefields?${params.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("minefield fetch failed");
return res.json() as Promise<{
docs?: {
id: number;
name: string;
rows: number;
cols: number;
mineCount: number;
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,
rows: doc.rows,
cols: doc.cols,
mineCount: doc.mineCount,
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 minefields. Wins award reduced XP (10% of standard payouts); field authors
bank 5% of each payout.
</p>
<CreateMinefieldDialog unlocked={unlocked} />
</div>
{failed ? (
<p className="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">
Could not load custom minefields.
</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 minefields published yet.
{unlocked ? " Be the first to author one!" : " Clear all three standard sectors 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">
<Bomb className="size-4 shrink-0 text-amber-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.rows}×{row.cols} · {row.mineCount} charges · by {row.authorName} · played{" "}
{row.playCount}×
</span>
</div>
<Button asChild size="sm">
<Link href={`/minigames/eod/custom/${row.id}`}>
<Play /> Play
</Link>
</Button>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,163 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
interface ApiScore {
id: number;
user?: number | { id: number; displayName?: string | null; username?: string | null } | null;
seconds: number;
flags: number;
won: boolean;
}
interface LeaderboardRow {
id: number;
name: string;
seconds: number;
flags: number;
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 CustomMinefieldLeaderboard({
minefieldId,
refreshKey = 0,
currentUserId,
}: {
minefieldId: number;
refreshKey?: number;
currentUserId?: number | string;
}) {
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setFailed(false);
const params = new URLSearchParams({
sort: "seconds,flags",
limit: "100",
depth: "1",
});
params.set("where[minefield][equals]", String(minefieldId));
params.set("where[won][equals]", "true");
fetch(`/api/custom-minefield-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.seconds < existing.seconds) 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 ?? "?"}`,
seconds: doc.seconds,
flags: doc.flags,
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
};
});
const sorted = mapped.sort((a, b) => a.seconds - b.seconds || a.flags - b.flags);
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;
};
}, [minefieldId, 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">Minefield Leaderboard</h2>
<span className="ml-auto text-xs text-muted-foreground">Top 10 · fastest clear</span>
</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 clears logged yet. Take the first run!
</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">Time</th>
<th className="px-4 py-2 text-right font-medium">Flags</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-[220px] 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.seconds}s
</td>
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
{row.flags}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,519 @@
"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Bomb, CircleHelp, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { submitCustomMinefieldScore } from "@/app/(frontend)/minigames/eod/custom/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { edgeMineIndices } from "@/lib/minigames/custom/eod";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal } from "./sounds";
type Phase = "ready" | "playing" | "over";
type Cell = {
readonly mine: boolean;
readonly adjacent: number;
readonly revealed: boolean;
readonly flagged: boolean;
readonly exploded: boolean;
readonly incorrectFlagged: boolean;
};
type Board = readonly Cell[];
const NUMBER_CLASSES: Readonly<Record<number, string>> = {
1: "text-blue-400",
2: "text-green-400",
3: "text-red-400",
4: "text-purple-400",
5: "text-rose-400",
6: "text-teal-400",
7: "text-slate-200",
8: "text-gray-400",
};
export type CustomMinefieldData = {
id: number;
name: string;
rows: number;
cols: number;
mines: { row: number; col: number }[];
exposeEdgeMines: boolean;
};
function emptyBoard(size: number): Board {
return Array.from({ length: size }, () => ({
mine: false,
adjacent: 0,
revealed: false,
flagged: false,
exploded: false,
incorrectFlagged: false,
}));
}
function neighbors(index: number, rows: number, cols: number): number[] {
const row = Math.floor(index / cols);
const col = index % cols;
const nearby: number[] = [];
for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
for (let colOffset = -1; colOffset <= 1; colOffset++) {
if (rowOffset === 0 && colOffset === 0) continue;
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
if (nextRow >= 0 && nextRow < rows && nextCol >= 0 && nextCol >= 0 && nextCol < cols) {
nearby.push(nextRow * cols + nextCol);
}
}
}
return nearby;
}
function createBoardFromMines(
rows: number,
cols: number,
mines: { row: number; col: number }[],
): Board {
const size = rows * cols;
const mineSet = new Set<number>();
for (const { row, col } of mines) {
if (row >= 0 && row < rows && col >= 0 && col < cols) {
mineSet.add(row * cols + col);
}
}
const mined = emptyBoard(size).map((cell, index) => ({ ...cell, mine: mineSet.has(index) }));
return mined.map((cell, index) => ({
...cell,
adjacent: neighbors(index, rows, cols).filter((nearby) => mined[nearby]?.mine).length,
}));
}
function revealFrom(start: number, board: Board, rows: number, cols: number): Board {
const next = [...board];
const queue = [start];
const visited = new Set<number>();
while (queue.length > 0) {
const index = queue.shift();
if (index === undefined || visited.has(index)) continue;
visited.add(index);
const cell = next[index];
if (!cell || cell.flagged || cell.revealed || cell.mine) continue;
next[index] = { ...cell, revealed: true };
if (cell.adjacent === 0) queue.push(...neighbors(index, rows, cols));
}
return next;
}
function cellLabel(cell: Cell, row: number, col: number): string {
const position = `row ${row + 1} col ${col + 1}`;
if (cell.incorrectFlagged) return `${position} incorrect flag`;
if (cell.flagged) return `${position} flagged`;
if (!cell.revealed) return `${position} hidden`;
if (cell.mine) return `${position} mine`;
if (cell.adjacent === 0) return `${position} revealed empty`;
return `${position} revealed number ${cell.adjacent}`;
}
type LastRun = {
readonly won: boolean;
readonly seconds: number;
readonly xp?: number;
readonly error?: string;
};
export function CustomMinesweeperBoard({
minefield,
onGameRecorded,
onPhaseChange,
}: {
minefield: CustomMinefieldData;
onGameRecorded: () => void;
onPhaseChange: (phase: Phase) => void;
}) {
const { expired, expiredRef } = useSessionExpired();
const [phase, setPhase] = useState<Phase>("ready");
const [board, setBoard] = useState<Board>(() =>
createBoardFromMines(minefield.rows, minefield.cols, minefield.mines),
);
const [seconds, setSeconds] = useState(0);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [lost, setLost] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submittingRef = useRef(false);
const setPhaseSafe = useCallback(
(nextPhase: Phase) => {
setPhase(nextPhase);
onPhaseChange(nextPhase);
},
[onPhaseChange],
);
useEffect(() => {
onPhaseChange("ready");
}, [onPhaseChange]);
useEffect(() => {
if (phase !== "playing") return;
const timer = window.setInterval(() => setSeconds((current) => current + 1), 1000);
return () => window.clearInterval(timer);
}, [phase]);
const reset = useCallback(() => {
if (submittingRef.current) return;
setBoard(createBoardFromMines(minefield.rows, minefield.cols, minefield.mines));
setSeconds(0);
setLastRun(null);
setLost(false);
setPhaseSafe("ready");
}, [minefield, setPhaseSafe]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() !== "r" ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
) {
return;
}
const target = event.target;
if (
target instanceof HTMLElement &&
(target.matches('input, textarea, select, [contenteditable="true"]') ||
target.closest('[role="combobox"]'))
) {
return;
}
event.preventDefault();
reset();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [reset]);
const recordResult = useCallback(
async (won: boolean, elapsedSeconds: number, flags: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
won,
seconds: elapsedSeconds,
error: "Your session expired. Result not recorded.",
});
return;
}
submittingRef.current = true;
setSubmitting(true);
const result = await submitCustomMinefieldScore({
minefieldId: minefield.id,
seconds: Math.max(1, elapsedSeconds),
flags,
won,
});
submittingRef.current = false;
setSubmitting(false);
if (result.success) {
onGameRecorded();
setLastRun({ won, seconds: elapsedSeconds, xp: result.data?.xpEarned });
} else {
setLastRun({
won,
seconds: elapsedSeconds,
error: result.error ?? "Could not record result.",
});
}
},
[expiredRef, minefield.id, onGameRecorded],
);
const finishGame = useCallback(
(won: boolean, finalBoard: Board) => {
if (submittingRef.current || phase === "over") return;
setPhaseSafe("over");
setLost(!won);
if (won) {
setBoard(finalBoard.map((cell) => (cell.mine ? { ...cell, flagged: true } : cell)));
} else {
// Reveal only the mines touching the exposed area (plus the one that
// went off) when the author kept the exposure rule on. The field is
// static, so a full reveal would leak the solution to retries and
// other players.
const revealed: number[] = [];
const mines: number[] = [];
finalBoard.forEach((cell, index) => {
if (cell.mine) mines.push(index);
else if (cell.revealed) revealed.push(index);
});
const edgeMines = minefield.exposeEdgeMines
? new Set(
edgeMineIndices({
rows: minefield.rows,
cols: minefield.cols,
mines,
revealed,
}),
)
: new Set<number>();
setBoard(
finalBoard.map((cell, index) =>
cell.mine
? edgeMines.has(index) || cell.exploded
? { ...cell, revealed: true }
: cell
: { ...cell, incorrectFlagged: cell.flagged },
),
);
}
if (won) playClear();
else playExplosion();
const flags = finalBoard.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
void recordResult(won, seconds, flags);
},
[minefield, phase, recordResult, seconds, setPhaseSafe],
);
const reveal = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.flagged || cell.revealed) return;
if (phase === "ready") setPhaseSafe("playing");
if (cell.mine) {
finishGame(
false,
board.map((item, cellIndex) =>
cellIndex === index ? { ...item, exploded: true } : item,
),
);
return;
}
playReveal();
const nextBoard = revealFrom(index, board, minefield.rows, minefield.cols);
setBoard(nextBoard);
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
},
[board, expiredRef, finishGame, minefield.cols, minefield.rows, phase, setPhaseSafe],
);
const toggleFlag = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.revealed) return;
playFlag();
setBoard((current) =>
current.map((item, cellIndex) =>
cellIndex === index ? { ...item, flagged: !item.flagged } : item,
),
);
},
[board, expiredRef, phase],
);
const longPressRef = useRef(false);
const longPressTimerRef = useRef<number | null>(null);
const clearLongPress = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
useEffect(() => {
return () => {
if (longPressTimerRef.current !== null) window.clearTimeout(longPressTimerRef.current);
};
}, []);
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
const incorrectFlagCount = board.reduce(
(count, cell) => count + (cell.incorrectFlagged ? 1 : 0),
0,
);
const statusClass =
phase === "over"
? lastRun?.won
? "border-emerald-500/40 bg-emerald-500/10"
: "border-destructive/40 bg-destructive/10"
: "border-border bg-card";
return (
<div
className="flex w-full max-w-5xl select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow"
onContextMenu={(event) => event.preventDefault()}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-emerald-500" />
{minefield.name}
</div>
<p className="text-xs text-muted-foreground">
{minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom wins
award reduced XP (10% of standard payouts)
</p>
</div>
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
<span
className="flex items-center gap-1.5"
aria-label={`${minefield.mines.length - flaggedCount} mines remaining`}
>
<Flag className="size-4 text-amber-400" />
{minefield.mines.length - flaggedCount}
</span>
<span className="flex items-center gap-1.5" aria-label={`${seconds} seconds elapsed`}>
<Timer className="size-4 text-muted-foreground" />
{seconds}s
</span>
</div>
</div>
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm",
statusClass,
)}
aria-live="polite"
>
<div className="flex flex-col gap-0.5">
{phase === "ready" && <span>Awaiting deployment. Any first click can be a mine.</span>}
{phase === "playing" && <span>Live sweep: mark every charge before you reveal.</span>}
{phase === "over" && lastRun?.won && (
<span>Sector clear. Defused in {lastRun.seconds}s.</span>
)}
{phase === "over" && !lastRun?.won && lastRun && (
<span>
The charge went off. MIA.
{incorrectFlagCount > 0 &&
` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`}{" "}
{minefield.exposeEdgeMines
? "Only charges bordering exposed ground are revealed; the rest of the layout stays hidden."
: "No remaining charges are revealed."}
</span>
)}
{phase === "over" && !lastRun && <span>Recording result…</span>}
{lastRun?.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
</div>
{phase === "over" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={submitting || expired}
onClick={reset}
>
<RotateCcw /> Retry sector
</Button>
)}
</div>
<div
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": minefield.cols,
"--cell-size": `min(1.75rem, calc((100cqw - ${(minefield.cols - 1) * 0.25}rem) / ${minefield.cols}))`,
} as CSSProperties
}
role="grid"
aria-label={`${minefield.name} minefield`}
>
{board.map((cell, index) => {
const row = Math.floor(index / minefield.cols);
const col = index % minefield.cols;
const revealedMine = cell.revealed && cell.mine;
return (
<button
key={index}
type="button"
role="gridcell"
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
!cell.mine &&
"border-yellow-900/40 bg-yellow-950/15 text-amber-50 shadow-inner",
!cell.revealed &&
"border-emerald-500 bg-emerald-800/90 text-emerald-50 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] hover:border-emerald-400 hover:bg-emerald-700",
lost &&
!cell.revealed &&
!cell.flagged &&
!cell.incorrectFlagged &&
"border-neutral-600 bg-neutral-800/80 text-neutral-400 hover:border-neutral-600 hover:bg-neutral-800/80",
cell.incorrectFlagged &&
"border-destructive bg-destructive/25 text-red-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] animate-pulse",
cell.flagged &&
!cell.revealed &&
!cell.incorrectFlagged &&
"border-amber-300 bg-amber-500/25 text-amber-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.14),0_1px_2px_rgb(0_0_0_/_0.35)]",
revealedMine && "border-destructive bg-destructive/30 text-red-300",
cell.exploded && "border-destructive bg-destructive text-destructive-foreground",
cell.revealed && cell.adjacent > 0 && NUMBER_CLASSES[cell.adjacent],
)}
onClick={() => {
if (longPressRef.current) {
longPressRef.current = false;
return;
}
reveal(index);
}}
onContextMenu={(event) => {
event.preventDefault();
toggleFlag(index);
}}
onKeyDown={(event) => {
if (event.key.toLowerCase() === "f") {
event.preventDefault();
toggleFlag(index);
}
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" || phase === "over") return;
longPressRef.current = false;
clearLongPress();
longPressTimerRef.current = window.setTimeout(() => {
longPressRef.current = true;
toggleFlag(index);
}, 350);
}}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
>
{cell.incorrectFlagged ? <X className="size-3.5" /> : null}
{cell.flagged && !cell.incorrectFlagged && !revealedMine ? (
<Flag className="size-3.5" />
) : null}
{revealedMine ? <Bomb className="size-3.5" /> : null}
{lost && !cell.revealed && !cell.flagged && !cell.incorrectFlagged ? (
<CircleHelp className="size-3.5" />
) : null}
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
</button>
);
})}
</div>
</div>
<p className="text-center text-xs text-muted-foreground">
Left click or tap to reveal · right click,{" "}
<kbd className="rounded border border-border px-1">F</kbd>, or long-press to flag · press{" "}
<kbd className="rounded border border-border px-1">R</kbd> to restart ·{" "}
{minefield.exposeEdgeMines
? "failed runs reveal only the charges bordering exposed ground"
: "failed runs reveal no remaining charges"}
</p>
</div>
);
}

View file

@ -1,9 +1,12 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
import type { EodDifficulty, EodStats } from "@/app/(frontend)/minigames/eod/actions";
import { isEodUnlocked } from "@/lib/minigames/custom/eod";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice";
import { UgcLinkButton } from "@/components/frontend/minigames/UgcLinkButton";
import { EodLeaderboard } from "./EodLeaderboard";
import { MinesweeperBoard } from "./MinesweeperBoard";
@ -23,12 +26,39 @@ export function EodArena({
setPlaying(phase === "playing");
}, []);
const customUnlocked = isEodUnlocked({
bestTrainingTime: initialStats.bestTrainingTime,
bestPerimeterTime: initialStats.bestPerimeterTime,
bestSupplyRoadTime: initialStats.bestSupplyRoadTime,
});
const clearedSectors = [
initialStats.bestTrainingTime,
initialStats.bestPerimeterTime,
initialStats.bestSupplyRoadTime,
].filter((time) => time > 0).length;
useEffect(() => {
if (playing && isMobile) setOpenMobile(false);
}, [isMobile, playing, setOpenMobile]);
return (
<div className="flex flex-col items-center gap-6">
{customUnlocked && (
<UgcLinkButton
href="/minigames/eod/custom"
title="UGC Minefields"
subtitle="Play, compete for, and create custom minefields you can share with other unit members! You can even earn XP for playing maps, and as an author, every payout on your map nets you some XP, too."
unlocked={customUnlocked}
className="max-w-5xl"
/>
)}
{!customUnlocked && (
<UnlockNotice
title="custom minefields"
requirement="you have cleared Training Pad, FOB Perimeter, and Main Supply Road"
progress={`${clearedSectors} of 3 sectors cleared`}
/>
)}
<MinesweeperBoard
initialStats={initialStats}
onGameRecorded={() => setRefreshKey((key) => key + 1)}

View file

@ -10,7 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { EodDifficulty } from "@/app/(frontend)/eod/actions";
import type { EodDifficulty } from "@/app/(frontend)/minigames/eod/actions";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
interface ApiUser {

View file

@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Switch } from "@/components/ui/switch";
import { updateMinefield } from "@/app/(frontend)/minigames/eod/custom/actions";
export function ExposureRuleToggle({
minefieldId,
exposeEdgeMines,
}: {
minefieldId: number;
exposeEdgeMines: boolean;
}) {
const router = useRouter();
const [checked, setChecked] = useState(exposeEdgeMines);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const onCheckedChange = async (next: boolean) => {
setError(null);
setPending(true);
const result = await updateMinefield({ id: minefieldId, exposeEdgeMines: next });
setPending(false);
if (result.success) {
setChecked(next);
router.refresh();
} else {
setError(result.error ?? "Could not update the exposure rule.");
}
};
return (
<div className="flex w-full max-w-5xl flex-col gap-1 rounded-lg border border-border bg-card p-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor="exposure-rule" className="text-sm font-medium">
Reveal bordering charges on failed runs
</label>
<Switch
id="exposure-rule"
checked={checked}
disabled={pending}
onCheckedChange={onCheckedChange}
/>
</div>
<p className="text-xs text-muted-foreground">
{checked
? "Failed runs show mines touching the swept area; everything else stays hidden."
: "Failed runs reveal no remaining charges; swept cells turn gray with a question mark."}
</p>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View file

@ -15,7 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/minigames/eod/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
@ -662,7 +662,7 @@ export function MinesweeperBoard({
phase === "playing" && "pointer-events-none opacity-40",
)}
>
<div className="grid gap-2 md:grid-cols-3">
<div className={cn("grid gap-2", customUnlocked ? "md:grid-cols-4" : "md:grid-cols-3")}>
{DIFFICULTY_KEYS.map((key) => {
const option = DIFFICULTIES[key];
return (
@ -684,8 +684,26 @@ export function MinesweeperBoard({
</Button>
);
})}
{customUnlocked && (
<Button
key="custom"
type="button"
variant={difficulty === "custom" ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting || expired}
aria-pressed={difficulty === "custom"}
onClick={() => reset("custom")}
>
<span className="flex flex-col items-start gap-0.5">
<span>Custom</span>
<span className="text-[10px] font-normal opacity-75">
{customConfig.rows}×{customConfig.cols} grid · {customConfig.mines} charges
</span>
</span>
</Button>
)}
</div>
{customUnlocked ? (
{customUnlocked && difficulty === "custom" ? (
<div className="rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
@ -792,11 +810,11 @@ export function MinesweeperBoard({
{CUSTOM_MAX_SIZE}×{CUSTOM_MAX_SIZE} field.
</p>
</div>
) : (
) : !customUnlocked ? (
<p className="text-xs text-muted-foreground">
Clear all three standard locations to unlock custom minefields.
</p>
)}
) : null}
</fieldset>
<div

View file

@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import type { FlappyStats } from "@/app/(frontend)/flappy/actions";
import type { FlappyStats } from "@/app/(frontend)/minigames/flappy/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { FlappyBird } from "./FlappyBird";
import { FlappyLeaderboard } from "./FlappyLeaderboard";

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/minigames/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";

View file

@ -2,8 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions";
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/minigames/opord-recall";
import type { OpordStats } from "@/app/(frontend)/minigames/opord/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

View file

@ -15,9 +15,9 @@ import {
type OpordDifficulty,
type OpordQuestion,
type OpordScenario,
} from "@/lib/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions";
import { submitOpordRecallResult } from "@/app/(frontend)/opord/actions";
} from "@/lib/minigames/opord-recall";
import type { OpordStats } from "@/app/(frontend)/minigames/opord/actions";
import { submitOpordRecallResult } from "@/app/(frontend)/minigames/opord/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { Button } from "@/components/ui/button";

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import type { OpordDifficulty } from "@/lib/opord-recall";
import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";

View file

@ -1,4 +1,4 @@
import type { OpordDifficulty } from "@/lib/opord-recall";
import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
export type OpordLeaderboardFields = {
readonly label: string;

View file

@ -6,8 +6,8 @@ import {
AIM_TRAINER_DIFFICULTIES,
isAimTrainerDifficulty,
type AimTrainerDifficulty,
} from "@/lib/aim-trainer";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions";
} from "@/lib/minigames/aim-trainer";
import type { AimTrainerStats } from "@/app/(frontend)/minigames/qualification/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

View file

@ -3,7 +3,7 @@ import {
calculateAimTrainerScore,
COURSE_DURATION_MS,
type AimTrainerTarget,
} from "@/lib/aim-trainer";
} from "@/lib/minigames/aim-trainer";
export const AIM_TRAINER_CANVAS_WIDTH = 720;
export const AIM_TRAINER_CANVAS_HEIGHT = 480;

View file

@ -19,12 +19,12 @@ import {
stepAimTrainerTargets,
TORSO_POINTS,
type AimTrainerDifficulty,
} from "@/lib/aim-trainer";
} from "@/lib/minigames/aim-trainer";
import {
type AimTrainerResultInput,
type AimTrainerStats,
submitAimTrainerResult,
} from "@/app/(frontend)/qualification/actions";
} from "@/app/(frontend)/minigames/qualification/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
import type { AimTrainerDifficulty } from "@/lib/minigames/aim-trainer";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";

View file

@ -1,4 +1,4 @@
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
import type { AimTrainerDifficulty } from "@/lib/minigames/aim-trainer";
export type AimTrainerLeaderboardFields = {
readonly label: string;

View 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)/minigames/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>
);
}

View 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>
);
}

View 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={`/minigames/radio/custom/${row.id}`}>
<Play /> Play
</Link>
</Button>
</div>
))}
</div>
)}
</div>
);
}

View 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>
);
}

View file

@ -1,8 +1,15 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { RadioStats } from "@/app/(frontend)/radio/actions";
import type { RadioStats } from "@/app/(frontend)/minigames/radio/actions";
import {
isRadioUnlocked,
SPRINT_ACCURACY_REQUIREMENT,
WATCH_ACCURACY_REQUIREMENT,
} from "@/lib/minigames/custom/radio";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice";
import { UgcLinkButton } from "@/components/frontend/minigames/UgcLinkButton";
import { RadioLeaderboard } from "./RadioLeaderboard";
import { RadioTraffic } from "./RadioTraffic";
@ -22,6 +29,14 @@ export function RadioArena({
setPlaying(phase === "playing");
}, []);
const customUnlocked = isRadioUnlocked({
sprintBestAccuracy: initialStats.sprintBestAccuracy,
watchBestAccuracy: initialStats.watchBestAccuracy,
});
// Floor, never round: rounding showed "90%" while locked at 89.75%.
const sprintPercent = Math.floor(initialStats.sprintBestAccuracy * 100);
const watchPercent = Math.floor(initialStats.watchBestAccuracy * 100);
useEffect(() => {
const savedMode = window.localStorage.getItem("radio-traffic-mode");
if (savedMode === "sprint" || savedMode === "watch") setMode(savedMode);
@ -34,6 +49,22 @@ export function RadioArena({
return (
<div className="flex flex-col items-center gap-6">
{customUnlocked && (
<UgcLinkButton
href="/minigames/radio/custom"
title="UGC Radio Tests"
subtitle="Play and create custom radio tests you can share with other unit members! Runs are unranked, so it is a great place to practice before an official transmission."
unlocked={customUnlocked}
className="max-w-[520px]"
/>
)}
{!customUnlocked && (
<UnlockNotice
title="custom radio tests"
requirement={`your best accuracy reaches ${Math.round(SPRINT_ACCURACY_REQUIREMENT * 100)}% in sprint and ${Math.round(WATCH_ACCURACY_REQUIREMENT * 100)}% in watch`}
progress={`Sprint ${sprintPercent}%, Watch ${watchPercent}%`}
/>
)}
<RadioTraffic
initialStats={initialStats}
mode={mode}

View file

@ -5,7 +5,8 @@ import type { FormEvent, KeyboardEvent } from "react";
import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions";
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/minigames/radio/actions";
import { submitCustomTestScore } from "@/app/(frontend)/minigames/radio/custom/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge";
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({
initialStats: serverStats,
mode,
onModeChangeAction,
onGameRecordedAction,
onPhaseChangeAction,
customTest,
}: {
initialStats: RadioStats;
mode: Mode;
onModeChangeAction: (mode: Mode) => void;
onGameRecordedAction: () => void;
onPhaseChangeAction: (phase: Phase) => void;
customTest?: CustomRadioTestData;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
@ -170,6 +202,7 @@ export function RadioTraffic({
const [phase, setPhase] = useState<Phase>("ready");
const [tickSoundEnabled, setTickSoundEnabled] = useState(true);
const [messages, setMessages] = useState<readonly string[]>(() => {
if (customTest) return buildCustomSprintMessages(customTest);
const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null;
return initialRound;
@ -247,6 +280,32 @@ export function RadioTraffic({
submittingRef.current = true;
setSubmitting(true);
if (customTest) {
await submitCustomTestScore({
testId: customTest.id,
score,
correctMessages,
incorrectMessages,
accuracy,
mode: resultMode,
timeMs: elapsedMs,
});
submittingRef.current = false;
setSubmitting(false);
onGameRecordedAction();
setLastRun({
mode: resultMode,
score,
accuracy,
correctMessages,
incorrectMessages,
relayedMessages: finalResults.length,
xpEarned: 0,
best: 0,
isNewBest: false,
});
return;
}
const result = await submitRadioResult({
score,
messageCount: finalResults.length,
@ -304,7 +363,7 @@ export function RadioTraffic({
});
}
},
[expiredRef, onGameRecordedAction, router],
[customTest, expiredRef, onGameRecordedAction, router],
);
const finishRound = useCallback(
@ -392,10 +451,14 @@ export function RadioTraffic({
watchExpiryNotifiedRef.current = false;
countdownEndPlayedRef.current = false;
if (mode === "sprint") {
nextMessages = createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
nextMessages = customTest
? buildCustomSprintMessages(customTest)
: createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT);
previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null;
} else {
const firstMessage = nextRadioMessage(watchUsedRef.current);
const firstMessage = customTest
? nextCustomWatchMessage(customTest, 0)
: nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(firstMessage);
nextMessages = [firstMessage];
}
@ -418,7 +481,7 @@ export function RadioTraffic({
messageStartedAtRef.current = now;
setPhaseSafe("playing");
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [expiredRef, mode, setPhaseSafe]);
}, [customTest, expiredRef, mode, setPhaseSafe]);
const handleKeystroke = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
@ -508,7 +571,9 @@ export function RadioTraffic({
if (mode === "watch") {
watchUsedRef.current.add(target);
const nextTarget = nextRadioMessage(watchUsedRef.current);
const nextTarget = customTest
? nextCustomWatchMessage(customTest, watchUsedRef.current.size)
: nextRadioMessage(watchUsedRef.current);
watchUsedRef.current.add(nextTarget);
setMessages((current) => [...current, nextTarget]);
}
@ -516,7 +581,7 @@ export function RadioTraffic({
messageStartedAtRef.current = now;
window.setTimeout(() => inputRef.current?.focus(), 0);
},
[finishRound, input, messageIndex, messages, mode, results],
[customTest, finishRound, input, messageIndex, messages, mode, results],
);
const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode;

View file

@ -38,9 +38,9 @@ const sheetVariants = cva(
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
left: "top-0 left-0 h-dvh w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
"top-0 right-0 h-dvh w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {

View file

@ -0,0 +1,253 @@
/**
* EOD custom minefield game rules.
*
* Deterministic, pure module for the custom-minefields minigame content:
* validation for grid/mine input, score submission bounds, and the
* custom-mode unlock predicate.
*
* Framework-free and client-safe: no React, Next.js, or Payload imports,
* no side effects.
*/
export const MIN_SIZE = 9;
export const MAX_SIZE = 500;
export const MAX_MINES = 10000;
export const MAX_SECONDS = 3600;
export const MAX_NAME_LENGTH = 100;
export type MinePosition = { row: number; col: number };
export interface EodUnlockStats {
bestTrainingTime: number;
bestPerimeterTime: number;
bestSupplyRoadTime: number;
}
/** A player unlocks the custom minefield editor once they cleared all three standard difficulties. */
export function isEodUnlocked(stats: EodUnlockStats): boolean {
return (
stats.bestTrainingTime > 0 &&
stats.bestPerimeterTime > 0 &&
stats.bestSupplyRoadTime > 0
);
}
export function validateMinefieldName(name: unknown): { error?: string; name: string } {
const trimmed = typeof name === "string" ? name.trim() : "";
if (trimmed.length < 1 || trimmed.length > MAX_NAME_LENGTH) {
return { error: "Name must be 1-100 characters.", name: "" };
}
return { name: trimmed };
}
export function validateGridSize(
rows: unknown,
cols: unknown,
): { error?: string; rows: number; cols: number } {
if (
!Number.isInteger(rows) ||
!Number.isInteger(cols) ||
(rows as number) < MIN_SIZE ||
(rows as number) > MAX_SIZE ||
(cols as number) < MIN_SIZE ||
(cols as number) > MAX_SIZE
) {
return { error: `Grid must be ${MIN_SIZE}×${MIN_SIZE} to ${MAX_SIZE}×${MAX_SIZE}.`, rows: 0, cols: 0 };
}
return { rows: rows as number, cols: cols as number };
}
export function validateMinePositions(
input: unknown,
rows: number,
cols: number,
): { error?: string; positions: MinePosition[] } {
if (!Array.isArray(input)) {
return { error: "Mines must be an array of positions.", positions: [] };
}
if (input.length < 1 || input.length > MAX_MINES) {
return { error: `Mine count must be between 1 and ${MAX_MINES}.`, positions: [] };
}
if (input.length > rows * cols) {
return {
error: `Mine count exceeds the ${rows}×${cols} grid.`,
positions: [],
};
}
const seen = new Set<number>();
const positions: MinePosition[] = [];
for (const entry of input) {
const candidate = entry as MinePosition | null | undefined;
const row = candidate?.row;
const col = candidate?.col;
if (
!Number.isInteger(row) ||
!Number.isInteger(col) ||
(row as number) < 0 ||
(row as number) >= rows ||
(col as number) < 0 ||
(col as number) >= cols
) {
return {
error: `Every mine needs a row (0-${rows - 1}) and column (0-${cols - 1}).`,
positions: [],
};
}
const key = (row as number) * cols + (col as number);
if (seen.has(key)) {
return { error: "Each cell can only hold one mine.", positions: [] };
}
seen.add(key);
positions.push({ row: row as number, col: col as number });
}
return { positions };
}
export interface MinefieldScoreInput {
seconds: number;
flags: number;
}
export function validateMinefieldScore(input: MinefieldScoreInput): {
error?: string;
seconds: number;
flags: number;
} {
if (
!Number.isFinite(input.seconds) ||
!Number.isInteger(input.seconds) ||
input.seconds <= 0 ||
input.seconds > MAX_SECONDS
) {
return { error: "Invalid result.", seconds: 0, flags: 0 };
}
if (!Number.isInteger(input.flags) || input.flags < 0) {
return { error: "Invalid result.", seconds: 0, flags: 0 };
}
return { seconds: input.seconds, flags: input.flags };
}
/** Flags above the declared mine count are impossible: catch them before recording. */
export function minefieldFlagsError(flags: number, mineCount: number): string | undefined {
if (flags > mineCount) {
return "Invalid result.";
}
return undefined;
}
// ---------------------------------------------------------------------------
// XP payouts
//
// Custom minefields pay a small share of the standard payout curve so community
// content stays far below official play: players earn PLAYER_XP_RATE of what
// the equivalent standard run would pay, and the field's author banks
// CREATOR_XP_RATE of each player payout. Authors earn nothing on their own
// fields. Numbers stay intentionally tiny.
// ---------------------------------------------------------------------------
export const PLAYER_XP_RATE = 0.1;
export const CREATOR_XP_RATE = 0.05;
export const XP_SCORE_DIVISOR = 50;
export const TRAINING_BRACKET_MAX_MINES = 15;
export const PERIMETER_BRACKET_MAX_MINES = 50;
export interface CustomXpBracket {
baseScore: number;
parSeconds: number;
}
/**
* Reference standard difficulty for a custom field, chosen by mine count.
* Brackets mirror the standard EOD difficulties (see DIFFICULTIES in the EOD
* actions): Training Pad 9x9/10, FOB Perimeter 16x16/40, Main Supply Road 16x30/99.
*/
export function customXpBracket(mineCount: number): CustomXpBracket {
if (mineCount <= TRAINING_BRACKET_MAX_MINES) {
return { baseScore: 100, parSeconds: 60 };
}
if (mineCount <= PERIMETER_BRACKET_MAX_MINES) {
return { baseScore: 300, parSeconds: 180 };
}
return { baseScore: 600, parSeconds: 420 };
}
/** Player XP for a custom run. Losses pay nothing; wins pay 1-2 points. */
export function customPlayerXp(input: {
mineCount: number;
seconds: number;
won: boolean;
}): number {
if (!input.won) {
return 0;
}
const bracket = customXpBracket(input.mineCount);
const score = bracket.baseScore + Math.max(0, bracket.parSeconds - input.seconds);
const regularXp = Math.max(1, Math.floor(score / XP_SCORE_DIVISOR));
return Math.max(1, Math.floor(PLAYER_XP_RATE * regularXp));
}
/** The author's fractional bank grows by this much per player payout. */
export function creatorBankIncrement(playerXp: number): number {
return CREATOR_XP_RATE * playerXp;
}
/**
* Adds a payout to the author's fractional bank and reports how many whole XP
* points are due. Whole points credit when the bank crosses 1, so a 5% share
* of 1-2 XP payouts still materializes over time.
*/
export function applyCreatorBank(
currentBank: number,
playerXp: number,
): { bank: number; wholeXpToCredit: number } {
const bank = currentBank + creatorBankIncrement(playerXp);
const wholeXpToCredit = Math.floor(bank);
return { bank: bank - wholeXpToCredit, wholeXpToCredit };
}
// ---------------------------------------------------------------------------
// Loss reveal
// ---------------------------------------------------------------------------
/**
* Mines that touch the exposed (revealed) area of a lost run, as linear
* indices. Losses reveal only these frontier mines (the caller keeps the
* exploded mine revealed too); the rest of the layout stays hidden because
* the field is static and a full reveal would leak the solution.
*/
export function edgeMineIndices(input: {
rows: number;
cols: number;
mines: number[];
revealed: number[];
}): number[] {
const revealedSet = new Set(input.revealed);
const frontier: number[] = [];
for (const mine of input.mines) {
const mineRow = Math.floor(mine / input.cols);
const mineCol = mine % input.cols;
let touchesExposedArea = false;
for (let rowOffset = -1; rowOffset <= 1 && !touchesExposedArea; rowOffset++) {
for (let colOffset = -1; colOffset <= 1 && !touchesExposedArea; colOffset++) {
if (rowOffset === 0 && colOffset === 0) continue;
const neighborRow = mineRow + rowOffset;
const neighborCol = mineCol + colOffset;
if (
neighborRow < 0 ||
neighborRow >= input.rows ||
neighborCol < 0 ||
neighborCol >= input.cols
) {
continue;
}
if (revealedSet.has(neighborRow * input.cols + neighborCol)) {
touchesExposedArea = true;
}
}
}
if (touchesExposedArea) {
frontier.push(mine);
}
}
return frontier;
}

View file

@ -0,0 +1,61 @@
import type { Payload } from "payload";
export interface CreditEodXpResult {
profileId: number;
xpEarned: number;
}
/**
* Credits EOD minigame XP to a user's profile, mirroring the write shape of
* the standard EOD action (including stripping the engine-managed
* `progression.xp` key). Returns null when the user has no profile.
*
* Server-side only (needs a live Payload instance); the payout math lives in
* the pure `custom-minigames/eod` lib.
*/
export async function creditEodXp(
payload: Payload,
userId: number,
amount: number,
): Promise<CreditEodXpResult | null> {
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
return null;
}
const progression = (profile.progression ?? {}) as {
xp?: unknown;
minigames?: Record<string, unknown>;
};
const { xp: _xp, ...progressionRest } = progression;
const minigames = progressionRest.minigames ?? {};
const eod = (minigames.eodMinesweeper ?? {}) as Record<string, unknown> & {
xpEarned?: number | null;
};
const xpEarned = (eod.xpEarned ?? 0) + amount;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progressionRest,
minigames: {
...minigames,
eodMinesweeper: { ...eod, xpEarned },
},
},
},
overrideAccess: true,
depth: 0,
});
return { profileId: profile.id, xpEarned };
}

View file

@ -0,0 +1,131 @@
/**
* Custom radio test game rules.
*
* Deterministic, pure module for the custom-radio-tests minigame content:
* validation for message pools / order / score submissions and the
* custom-mode unlock predicate.
*
* Framework-free and client-safe: no React, Next.js, or Payload imports,
* no side effects.
*/
export const MIN_MESSAGES = 1;
export const MAX_MESSAGES = 100;
export const MAX_MESSAGE_LENGTH = 500;
export const MAX_SCORE_PER_GAME = 5000;
export const MAX_SPRINT_TIME_MS = 600_000;
export const SPRINT_ACCURACY_REQUIREMENT = 0.9;
export const WATCH_ACCURACY_REQUIREMENT = 0.85;
export const MAX_NAME_LENGTH = 100;
export type RadioMode = "sprint" | "watch";
export function isRadioMode(value: string): value is RadioMode {
return value === "sprint" || value === "watch";
}
export type RadioOrder = "fixed" | "shuffled";
export function isValidRadioOrder(value: string): value is RadioOrder {
return value === "fixed" || value === "shuffled";
}
export interface RadioUnlockStats {
sprintBestAccuracy: number;
watchBestAccuracy: number;
}
/** A player unlocks the custom radio test editor once they hit both accuracy requirements. */
export function isRadioUnlocked(stats: RadioUnlockStats): boolean {
return (
stats.sprintBestAccuracy >= SPRINT_ACCURACY_REQUIREMENT &&
stats.watchBestAccuracy >= WATCH_ACCURACY_REQUIREMENT
);
}
export function validateTestName(name: unknown): { error?: string; name: string } {
const trimmed = typeof name === "string" ? name.trim() : "";
if (trimmed.length < 1 || trimmed.length > MAX_NAME_LENGTH) {
return { error: "Name must be 1-100 characters.", name: "" };
}
return { name: trimmed };
}
export function validateMessages(input: unknown): { error?: string; messages: string[] } {
if (!Array.isArray(input)) {
return { error: "Messages must be a list of strings.", messages: [] };
}
if (input.length < MIN_MESSAGES || input.length > MAX_MESSAGES) {
return {
error: `Message pool must contain ${MIN_MESSAGES}-${MAX_MESSAGES} messages.`,
messages: [],
};
}
const messages: string[] = [];
for (const entry of input) {
if (typeof entry !== "string") {
return { error: "Every message must be a string.", messages: [] };
}
const trimmed = entry.trim();
if (trimmed.length < 1 || trimmed.length > MAX_MESSAGE_LENGTH) {
return {
error: `Messages must be 1-${MAX_MESSAGE_LENGTH} characters.`,
messages: [],
};
}
messages.push(trimmed);
}
return { messages };
}
export interface RadioScoreInput {
score: number;
correctMessages: number;
incorrectMessages: number;
accuracy: number;
mode: string;
timeMs?: number;
}
export type RadioScoreValidation =
| { error: string; score: number; mode: null; timeMs: null }
| { error?: undefined; score: number; mode: RadioMode; timeMs: number | null };
export function validateRadioScore(input: RadioScoreInput): RadioScoreValidation {
const invalid: RadioScoreValidation = { error: "Invalid result.", score: 0, mode: null, timeMs: null };
if (!isRadioMode(input.mode)) {
return invalid;
}
const score = Math.floor(input.score);
if (!Number.isFinite(score) || score < 0 || score > MAX_SCORE_PER_GAME) {
return { error: "Invalid score.", score: 0, mode: null, timeMs: null };
}
if (
!Number.isInteger(input.correctMessages) ||
input.correctMessages < 0 ||
input.correctMessages > MAX_MESSAGES
) {
return invalid;
}
if (!Number.isInteger(input.incorrectMessages) || input.incorrectMessages < 0) {
return invalid;
}
if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) {
return invalid;
}
const sprintTime = input.timeMs;
if (
input.mode === "sprint" &&
(typeof sprintTime !== "number" ||
!Number.isFinite(sprintTime) ||
sprintTime <= 0 ||
sprintTime > MAX_SPRINT_TIME_MS)
) {
return invalid;
}
return {
score,
mode: input.mode,
timeMs: input.mode === "sprint" ? (sprintTime as number) : null,
};
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "custom_minefields" ADD COLUMN IF NOT EXISTS "creator_xp_bank" numeric DEFAULT 0;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "custom_minefields" DROP COLUMN IF EXISTS "creator_xp_bank";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "custom_minefields" ADD COLUMN IF NOT EXISTS "expose_edge_mines" boolean DEFAULT true;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "custom_minefields" DROP COLUMN IF EXISTS "expose_edge_mines";`)
}

View file

@ -45,6 +45,9 @@ import * as migration_20260907_090245_add_mission_ready_reminder_fields from './
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
export const migrations = [
{
@ -280,6 +283,21 @@ export const migrations = [
{
up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: '20260908_152021_add_opord_recall_minigame_stats'
name: '20260908_152021_add_opord_recall_minigame_stats',
},
{
up: migration_20260908_172241_add_custom_minigame_collections.up,
down: migration_20260908_172241_add_custom_minigame_collections.down,
name: '20260908_172241_add_custom_minigame_collections',
},
{
up: migration_20260908_193107_add_creator_xp_bank_field.up,
down: migration_20260908_193107_add_creator_xp_bank_field.down,
name: '20260908_193107_add_creator_xp_bank_field',
},
{
up: migration_20260908_194435_add_expose_edge_mines_field.up,
down: migration_20260908_194435_add_expose_edge_mines_field.down,
name: '20260908_194435_add_expose_edge_mines_field'
},
];

View file

@ -125,6 +125,10 @@ export interface Config {
'game-servers': GameServer;
'arma-sync-events': ArmaSyncEvent;
'arma-commands': ArmaCommand;
'custom-minefields': CustomMinefield;
'custom-minefield-scores': CustomMinefieldScore;
'custom-radio-tests': CustomRadioTest;
'custom-radio-test-scores': CustomRadioTestScore;
'plugin-ai-instructions': PluginAiInstruction;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
@ -198,6 +202,10 @@ export interface Config {
'game-servers': GameServersSelect<false> | GameServersSelect<true>;
'arma-sync-events': ArmaSyncEventsSelect<false> | ArmaSyncEventsSelect<true>;
'arma-commands': ArmaCommandsSelect<false> | ArmaCommandsSelect<true>;
'custom-minefields': CustomMinefieldsSelect<false> | CustomMinefieldsSelect<true>;
'custom-minefield-scores': CustomMinefieldScoresSelect<false> | CustomMinefieldScoresSelect<true>;
'custom-radio-tests': CustomRadioTestsSelect<false> | CustomRadioTestsSelect<true>;
'custom-radio-test-scores': CustomRadioTestScoresSelect<false> | CustomRadioTestScoresSelect<true>;
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
@ -1686,6 +1694,12 @@ export interface Role {
| 'tickets:staff'
| 'discord:staff'
| 'discord:announce'
| 'custom-minefields:update'
| 'custom-minefields:delete'
| 'custom-minefield-scores:delete'
| 'custom-radio-tests:update'
| 'custom-radio-tests:delete'
| 'custom-radio-test-scores:delete'
)[]
| null;
/**
@ -1888,6 +1902,10 @@ export interface GameEventLog {
| 'voice-submissions'
| 'story-beats'
| 'story-beat-states'
| 'custom-minefields'
| 'custom-minefield-scores'
| 'custom-radio-tests'
| 'custom-radio-test-scores'
)
| null;
targetId?: number | null;
@ -3692,6 +3710,109 @@ export interface ArmaCommand {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefields".
*/
export interface CustomMinefield {
id: number;
name: string;
/**
* Player who authored this minefield.
*/
author: number | User;
rows: number;
cols: number;
/**
* Array of { row, col } mine positions.
*/
mines:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
mineCount: number;
published?: boolean | null;
/**
* On a failed run, reveal mines bordering the swept area. When off, no remaining mines are revealed.
*/
exposeEdgeMines?: boolean | null;
playCount?: number | null;
/**
* Engine-managed: fractional XP bank for the author (5% of each player payout).
*/
creatorXpBank?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefield-scores".
*/
export interface CustomMinefieldScore {
id: number;
minefield: number | CustomMinefield;
user: number | User;
seconds: number;
flags?: number | null;
won: boolean;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-tests".
*/
export interface CustomRadioTest {
id: number;
name: string;
/**
* Player who authored this radio test.
*/
author: number | User;
/**
* Array of message strings used as the typing pool.
*/
messages:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
messageCount: number;
order?: ('fixed' | 'shuffled') | null;
published?: boolean | null;
playCount?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-test-scores".
*/
export interface CustomRadioTestScore {
id: number;
test: number | CustomRadioTest;
user: number | User;
score: number;
accuracy: number;
correctMessages: number;
incorrectMessages: number;
mode: 'sprint' | 'watch';
/**
* Sprint mode only: elapsed time in milliseconds.
*/
timeMs?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions".
@ -4012,6 +4133,22 @@ export interface PayloadLockedDocument {
relationTo: 'arma-commands';
value: number | ArmaCommand;
} | null)
| ({
relationTo: 'custom-minefields';
value: number | CustomMinefield;
} | null)
| ({
relationTo: 'custom-minefield-scores';
value: number | CustomMinefieldScore;
} | null)
| ({
relationTo: 'custom-radio-tests';
value: number | CustomRadioTest;
} | null)
| ({
relationTo: 'custom-radio-test-scores';
value: number | CustomRadioTestScore;
} | null)
| ({
relationTo: 'plugin-ai-instructions';
value: number | PluginAiInstruction;
@ -5651,6 +5788,68 @@ export interface ArmaCommandsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefields_select".
*/
export interface CustomMinefieldsSelect<T extends boolean = true> {
name?: T;
author?: T;
rows?: T;
cols?: T;
mines?: T;
mineCount?: T;
published?: T;
exposeEdgeMines?: T;
playCount?: T;
creatorXpBank?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-minefield-scores_select".
*/
export interface CustomMinefieldScoresSelect<T extends boolean = true> {
minefield?: T;
user?: T;
seconds?: T;
flags?: T;
won?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-tests_select".
*/
export interface CustomRadioTestsSelect<T extends boolean = true> {
name?: T;
author?: T;
messages?: T;
messageCount?: T;
order?: T;
published?: T;
playCount?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-radio-test-scores_select".
*/
export interface CustomRadioTestScoresSelect<T extends boolean = true> {
test?: T;
user?: T;
score?: T;
accuracy?: T;
correctMessages?: T;
incorrectMessages?: T;
mode?: T;
timeMs?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions_select".

View file

@ -68,6 +68,10 @@ import { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
import { Shims } from "@/collections/Shims";
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
import { mcpPlugin } from "@payloadcms/plugin-mcp";
@ -236,6 +240,12 @@ const collections = [
GameServers,
ArmaSyncEvents,
ArmaCommands,
// Minigames
CustomMinefields,
CustomMinefieldScores,
CustomRadioTests,
CustomRadioTestScores,
];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`

View file

@ -589,6 +589,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "discord:announce", label: "Post Announcements (/announce)" },
],
},
{
group: "Minigames",
permissions: [
{ value: "custom-minefields:update", label: "Edit Any Custom Minefield" },
{ value: "custom-minefields:delete", label: "Delete Custom Minefields" },
{ value: "custom-minefield-scores:delete", label: "Delete Custom Minefield Scores" },
{ value: "custom-radio-tests:update", label: "Edit Any Custom Radio Test" },
{ value: "custom-radio-tests:delete", label: "Delete Custom Radio Tests" },
{ value: "custom-radio-test-scores:delete", label: "Delete Custom Radio Test Scores" },
],
},
];
// ---------------------------------------------------------------------------
@ -855,6 +866,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"tickets:staff",
"discord:staff",
"discord:announce",
"custom-minefields:update",
"custom-minefields:delete",
"custom-minefield-scores:delete",
"custom-radio-tests:update",
"custom-radio-tests:delete",
"custom-radio-test-scores:delete",
];
/**

View file

@ -40,7 +40,11 @@ interface GameEventInput {
| "voice-scripts"
| "voice-submissions"
| "story-beats"
| "story-beat-states";
| "story-beat-states"
| "custom-minefields"
| "custom-minefield-scores"
| "custom-radio-tests"
| "custom-radio-test-scores";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -77,6 +77,14 @@ export const EventTypes = {
MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer",
MinigameOpordRecall: "minigame:opord-recall",
MinigameCustomEodCreate: "minigame:custom-eod-create",
MinigameCustomEodEdit: "minigame:custom-eod-edit",
MinigameCustomEodDelete: "minigame:custom-eod-delete",
MinigameCustomEodPlay: "minigame:custom-eod-play",
MinigameCustomRadioCreate: "minigame:custom-radio-create",
MinigameCustomRadioEdit: "minigame:custom-radio-edit",
MinigameCustomRadioDelete: "minigame:custom-radio-delete",
MinigameCustomRadioPlay: "minigame:custom-radio-play",
// Voiceover
VoiceoverSubmit: "voiceover:submit",

View file

@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { formatOpordGrid, generateOpordScenario } from "../../src/lib/opord-recall";
import { formatOpordGrid, generateOpordScenario } from "../../src/lib/minigames/opord-recall";
const FIXED_SEED = 424242;
const QUIZ_OPTION_BUTTON = "button.font-mono";
@ -14,7 +14,7 @@ async function login(page: Page): Promise<void> {
}
async function playOpordRound(page: Page): Promise<void> {
await page.goto(`/opord?seed=${FIXED_SEED}`);
await page.goto(`/minigames/opord?seed=${FIXED_SEED}`);
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
await page.getByRole("button", { name: "Receive briefing" }).click();

View file

@ -29,7 +29,7 @@ import {
resolveAimTrainerTargetHit,
stepAimTrainerTarget,
stepAimTrainerTargets,
} from "@/lib/aim-trainer";
} from "@/lib/minigames/aim-trainer";
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */
function seededRng(seed: number): () => number {

File diff suppressed because it is too large Load diff

View 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/minigames/custom/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);
});
});