Compare commits
23 commits
0143c235bc
...
181f244f62
| Author | SHA1 | Date | |
|---|---|---|---|
| 181f244f62 | |||
| 1d4dee0433 | |||
| 167ac22990 | |||
| 911fc31354 | |||
| 135a7fd5ae | |||
| 14c9f0b4ec | |||
| 285de3ac14 | |||
| 25975db23f | |||
| 76fbaba293 | |||
| 455d705eb9 | |||
| f76966ebc3 | |||
| 226cd9b2b8 | |||
| bf491fc9cd | |||
| 6d4b5ad297 | |||
| efda519569 | |||
| 243bb8ae0d | |||
| 987e78babd | |||
| eeab85ad35 | |||
| 4fde0fe91c | |||
| 1cf11745fe | |||
| 52328da8f9 | |||
| 5b4842943e | |||
| 1f99e34c0d |
84 changed files with 79565 additions and 75 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -55,6 +55,8 @@ next-env.d.ts
|
|||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/.archived/test-results/
|
||||
/.archived/playwright-report/
|
||||
/blob-report/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
85
src/app/(frontend)/minigames/eod/custom/[id]/page.tsx
Normal file
85
src/app/(frontend)/minigames/eod/custom/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
442
src/app/(frontend)/minigames/eod/custom/actions.ts
Normal file
442
src/app/(frontend)/minigames/eod/custom/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
53
src/app/(frontend)/minigames/eod/custom/page.tsx
Normal file
53
src/app/(frontend)/minigames/eod/custom/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -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({
|
||||
|
|
@ -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";
|
||||
|
|
@ -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({
|
||||
|
|
@ -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;
|
||||
|
|
@ -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({
|
||||
70
src/app/(frontend)/minigames/radio/custom/[id]/page.tsx
Normal file
70
src/app/(frontend)/minigames/radio/custom/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Radio } from "lucide-react";
|
||||
import { CustomRadioArena } from "@/components/frontend/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>
|
||||
);
|
||||
}
|
||||
342
src/app/(frontend)/minigames/radio/custom/actions.ts
Normal file
342
src/app/(frontend)/minigames/radio/custom/actions.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
isRadioUnlocked,
|
||||
isValidRadioOrder,
|
||||
validateMessages,
|
||||
validateRadioScore,
|
||||
validateTestName,
|
||||
} from "@/lib/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 };
|
||||
}
|
||||
}
|
||||
52
src/app/(frontend)/minigames/radio/custom/page.tsx
Normal file
52
src/app/(frontend)/minigames/radio/custom/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
48
src/collections/minigames/CustomMinefieldScores.ts
Normal file
48
src/collections/minigames/CustomMinefieldScores.ts
Normal 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,
|
||||
};
|
||||
107
src/collections/minigames/CustomMinefields.ts
Normal file
107
src/collections/minigames/CustomMinefields.ts
Normal 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,
|
||||
};
|
||||
69
src/collections/minigames/CustomRadioTestScores.ts
Normal file
69
src/collections/minigames/CustomRadioTestScores.ts
Normal 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,
|
||||
};
|
||||
77
src/collections/minigames/CustomRadioTests.ts
Normal file
77
src/collections/minigames/CustomRadioTests.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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"],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
src/components/frontend/minigames/UgcLinkButton.tsx
Normal file
51
src/components/frontend/minigames/UgcLinkButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/frontend/minigames/UnlockNotice.tsx
Normal file
26
src/components/frontend/minigames/UnlockNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/frontend/minigames/eod/CreateMinefieldDialog.tsx
Normal file
252
src/components/frontend/minigames/eod/CreateMinefieldDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
140
src/components/frontend/minigames/eod/CustomMinefieldBrowser.tsx
Normal file
140
src/components/frontend/minigames/eod/CustomMinefieldBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
519
src/components/frontend/minigames/eod/CustomMinesweeperBoard.tsx
Normal file
519
src/components/frontend/minigames/eod/CustomMinesweeperBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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 {
|
||||
54
src/components/frontend/minigames/eod/ExposureRuleToggle.tsx
Normal file
54
src/components/frontend/minigames/eod/ExposureRuleToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
164
src/components/frontend/minigames/radio/CreateTestDialog.tsx
Normal file
164
src/components/frontend/minigames/radio/CreateTestDialog.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Radio } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createTest } from "@/app/(frontend)/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>
|
||||
);
|
||||
}
|
||||
87
src/components/frontend/minigames/radio/CustomRadioArena.tsx
Normal file
87
src/components/frontend/minigames/radio/CustomRadioArena.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { CustomTestLeaderboard } from "./CustomTestLeaderboard";
|
||||
import { RadioTraffic, type CustomRadioTestData } from "./RadioTraffic";
|
||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
type Mode = "sprint" | "watch";
|
||||
|
||||
export function CustomRadioArena({
|
||||
test,
|
||||
currentUserId,
|
||||
}: {
|
||||
test: CustomRadioTestData;
|
||||
currentUserId: number | string;
|
||||
}) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>("sprint");
|
||||
const [modeHydrated, setModeHydrated] = useState(false);
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const activeTeams = useRealtimePresence("radio", playing, true);
|
||||
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||
setPlaying(phase === "playing");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem("radio-custom-mode");
|
||||
if (saved === "watch" || saved === "sprint") setMode(saved);
|
||||
setModeHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modeHydrated) return;
|
||||
window.localStorage.setItem("radio-custom-mode", mode);
|
||||
}, [mode, modeHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing && isMobile) setOpenMobile(false);
|
||||
}, [isMobile, playing, setOpenMobile]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<RadioTraffic
|
||||
initialStats={{
|
||||
gamesPlayed: 0,
|
||||
totalMessages: 0,
|
||||
incorrectMessages: 0,
|
||||
bestScore: 0,
|
||||
bestAccuracy: 0,
|
||||
bestSprintTimeMs: 0,
|
||||
sprintGamesPlayed: 0,
|
||||
sprintTotalMessages: 0,
|
||||
sprintIncorrectMessages: 0,
|
||||
sprintBestScore: 0,
|
||||
sprintBestAccuracy: 0,
|
||||
watchGamesPlayed: 0,
|
||||
watchTotalMessages: 0,
|
||||
watchIncorrectMessages: 0,
|
||||
watchBestScore: 0,
|
||||
watchBestAccuracy: 0,
|
||||
radioTrafficXp: 0,
|
||||
xpEarned: 0,
|
||||
}}
|
||||
mode={mode}
|
||||
onModeChangeAction={setMode}
|
||||
onGameRecordedAction={() => setRefreshKey((key) => key + 1)}
|
||||
onPhaseChangeAction={onPhaseChange}
|
||||
customTest={test}
|
||||
/>
|
||||
<CustomTestLeaderboard
|
||||
testId={test.id}
|
||||
refreshKey={refreshKey}
|
||||
currentUserId={currentUserId}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
{activeTeams.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeTeams.length} operator{activeTeams.length === 1 ? "" : "s"} on the net
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/frontend/minigames/radio/CustomTestBrowser.tsx
Normal file
136
src/components/frontend/minigames/radio/CustomTestBrowser.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Radio } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateTestDialog } from "./CreateTestDialog";
|
||||
|
||||
interface TestRow {
|
||||
id: number;
|
||||
name: string;
|
||||
messageCount: number;
|
||||
order: string;
|
||||
playCount: number;
|
||||
authorName: string;
|
||||
isOwn: boolean;
|
||||
}
|
||||
|
||||
export function CustomTestBrowser({
|
||||
currentUserId,
|
||||
unlocked,
|
||||
}: {
|
||||
currentUserId: number | string;
|
||||
unlocked: boolean;
|
||||
}) {
|
||||
const [rows, setRows] = useState<TestRow[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFailed(false);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
sort: "-createdAt",
|
||||
limit: "100",
|
||||
depth: "1",
|
||||
});
|
||||
params.set("where[published][equals]", "true");
|
||||
|
||||
fetch(`/api/custom-radio-tests?${params.toString()}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("test fetch failed");
|
||||
return res.json() as Promise<{
|
||||
docs?: {
|
||||
id: number;
|
||||
name: string;
|
||||
messageCount: number;
|
||||
order?: string | null;
|
||||
playCount?: number | null;
|
||||
author?: number | { id?: number; displayName?: string | null; username?: string | null } | null;
|
||||
}[];
|
||||
}>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setRows(
|
||||
(data.docs ?? []).map((doc) => {
|
||||
const author = typeof doc.author === "object" && doc.author !== null ? doc.author : null;
|
||||
return {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
messageCount: doc.messageCount,
|
||||
order: doc.order ?? "fixed",
|
||||
playCount: doc.playCount ?? 0,
|
||||
authorName:
|
||||
author?.displayName || author?.username || `Operator #${author?.id ?? "?"}`,
|
||||
isOwn: author ? String(author.id) === String(currentUserId) : false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUserId]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-5xl flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Community radio tests. Playing these does not affect your official stats or XP.
|
||||
</p>
|
||||
<CreateTestDialog unlocked={unlocked} />
|
||||
</div>
|
||||
|
||||
{failed ? (
|
||||
<p className="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
Could not load custom radio tests.
|
||||
</p>
|
||||
) : rows === null ? (
|
||||
<div className="space-y-2 rounded-xl border border-border bg-card p-4">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No custom radio tests published yet.
|
||||
{unlocked ? " Be the first to author one!" : " Hit 90% sprint and 85% watch accuracy to author one."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="size-4 shrink-0 text-emerald-400" />
|
||||
<span className="truncate text-sm font-semibold">{row.name}</span>
|
||||
{row.isOwn && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">(yours)</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.messageCount} messages · {row.order === "shuffled" ? "shuffled" : "fixed"} ·
|
||||
by {row.authorName} · played {row.playCount}×
|
||||
</span>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/minigames/radio/custom/${row.id}`}>
|
||||
<Play /> Play
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trophy } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ScoreMode = "sprint" | "watch";
|
||||
|
||||
interface ApiScore {
|
||||
id: number;
|
||||
user?: number | { id: number; displayName?: string | null; username?: string | null } | null;
|
||||
score: number;
|
||||
accuracy: number;
|
||||
timeMs?: number | null;
|
||||
}
|
||||
|
||||
interface LeaderboardRow {
|
||||
id: number;
|
||||
name: string;
|
||||
score: number;
|
||||
accuracy: number;
|
||||
timeMs: number | null;
|
||||
isCurrentUser: boolean;
|
||||
isOutsideTopTen?: boolean;
|
||||
}
|
||||
|
||||
function rankClass(index: number) {
|
||||
if (index === 0) return "font-bold text-amber-400";
|
||||
if (index === 1) return "font-semibold text-slate-300";
|
||||
if (index === 2) return "font-semibold text-amber-700";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function CustomTestLeaderboard({
|
||||
testId,
|
||||
refreshKey = 0,
|
||||
currentUserId,
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
testId: number;
|
||||
refreshKey?: number;
|
||||
currentUserId?: number | string;
|
||||
mode: ScoreMode;
|
||||
onModeChange: (mode: ScoreMode) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFailed(false);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
sort: "-score",
|
||||
limit: "100",
|
||||
depth: "1",
|
||||
});
|
||||
params.set("where[test][equals]", String(testId));
|
||||
params.set("where[mode][equals]", mode);
|
||||
|
||||
fetch(`/api/custom-radio-test-scores?${params.toString()}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||
return res.json() as Promise<{ docs?: ApiScore[] }>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const bestPerUser = new Map<number, ApiScore>();
|
||||
for (const doc of data.docs ?? []) {
|
||||
const userId = typeof doc.user === "object" && doc.user !== null ? doc.user.id : null;
|
||||
if (userId === null) continue;
|
||||
const existing = bestPerUser.get(userId);
|
||||
if (!existing || doc.score > existing.score) bestPerUser.set(userId, doc);
|
||||
}
|
||||
const mapped: LeaderboardRow[] = Array.from(bestPerUser.values()).map((doc) => {
|
||||
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||
return {
|
||||
id: doc.id,
|
||||
name: user?.displayName || user?.username || `Operator #${user?.id ?? "?"}`,
|
||||
score: doc.score,
|
||||
accuracy: doc.accuracy,
|
||||
timeMs: doc.timeMs ?? null,
|
||||
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||
};
|
||||
});
|
||||
const sorted = mapped.sort((a, b) => b.score - a.score);
|
||||
const visible = sorted.slice(0, 10);
|
||||
const current = sorted.find((row) => row.isCurrentUser);
|
||||
if (current && !visible.some((row) => row.id === current.id)) {
|
||||
visible.push({ ...current, isOutsideTopTen: true });
|
||||
}
|
||||
setRows(visible);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [testId, mode, refreshKey, currentUserId]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Trophy className="size-4 text-amber-400" />
|
||||
<h2 className="text-sm font-semibold">Radio Test Leaderboard</h2>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Select value={mode} onValueChange={(value) => onModeChange(value as ScoreMode)}>
|
||||
<SelectTrigger aria-label="Leaderboard mode" className="h-8 w-[110px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sprint">Sprint</SelectItem>
|
||||
<SelectItem value="watch">Watch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>Top 10 · best score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{failed ? (
|
||||
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||
) : rows === null ? (
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||
No runs logged yet. Take the first shift!
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-2 font-medium">#</th>
|
||||
<th className="px-2 py-2 font-medium">Operator</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Score</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
|
||||
{mode === "sprint" && (
|
||||
<th className="px-4 py-2 text-right font-medium">Time</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row, index) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
row.isCurrentUser && "bg-accent/40",
|
||||
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={rankClass(index)}>
|
||||
{row.isOutsideTopTen ? "-" : index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="max-w-[180px] truncate">{row.name}</span>
|
||||
{row.isCurrentUser && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-semibold tabular-nums">{row.score}</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||
{Math.round(row.accuracy * 100)}%
|
||||
</td>
|
||||
{mode === "sprint" && (
|
||||
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||
{row.timeMs !== null ? `${(row.timeMs / 1000).toFixed(1)}s` : "-"}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,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}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
253
src/lib/minigames/custom/eod.ts
Normal file
253
src/lib/minigames/custom/eod.ts
Normal 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;
|
||||
}
|
||||
61
src/lib/minigames/custom/payouts.ts
Normal file
61
src/lib/minigames/custom/payouts.ts
Normal 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 };
|
||||
}
|
||||
131
src/lib/minigames/custom/radio.ts
Normal file
131
src/lib/minigames/custom/radio.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24406
src/migrations/20260908_172241_add_custom_minigame_collections.json
Normal file
24406
src/migrations/20260908_172241_add_custom_minigame_collections.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
24462
src/migrations/20260908_193107_add_creator_xp_bank_field.json
Normal file
24462
src/migrations/20260908_193107_add_creator_xp_bank_field.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260908_193107_add_creator_xp_bank_field.ts
Normal file
11
src/migrations/20260908_193107_add_creator_xp_bank_field.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "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";`)
|
||||
}
|
||||
24462
src/migrations/20260908_194435_add_expose_edge_mines_field.json
Normal file
24462
src/migrations/20260908_194435_add_expose_edge_mines_field.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1031
tests/int/custom-minefields.int.spec.ts
Normal file
1031
tests/int/custom-minefields.int.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
915
tests/int/custom-radio-tests.int.spec.ts
Normal file
915
tests/int/custom-radio-tests.int.spec.ts
Normal file
|
|
@ -0,0 +1,915 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Role, User } from "@/payload-types";
|
||||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
import {
|
||||
isRadioUnlocked,
|
||||
isValidRadioOrder,
|
||||
validateMessages,
|
||||
validateRadioScore,
|
||||
validateTestName,
|
||||
} from "@/lib/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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue