(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 (
+ event.preventDefault()}
+ >
+
+
+
+
+ {minefield.name}
+
+
+ {minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom
+ minefields do not award official XP
+
+
+
+
+
+ {minefield.mines.length - flaggedCount}
+
+
+
+ {seconds}s
+
+
+
+
+
+
+ {phase === "ready" && Awaiting deployment. Any first click can be a mine.}
+ {phase === "playing" && Live sweep: mark every charge before you reveal.}
+ {phase === "over" && lastRun?.won && (
+ Sector clear. Defused in {lastRun.seconds}s.
+ )}
+ {phase === "over" && !lastRun?.won && lastRun && (
+
+ The charge went off. MIA.
+ {incorrectFlagCount > 0 &&
+ ` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`}
+
+ )}
+ {phase === "over" && !lastRun && Recording result…}
+ {lastRun?.error && {lastRun.error}}
+
+ {phase === "over" && (
+
+ )}
+
+
+
+
+ {board.map((cell, index) => {
+ const row = Math.floor(index / minefield.cols);
+ const col = index % minefield.cols;
+ const revealedMine = cell.revealed && cell.mine;
+ return (
+
+ );
+ })}
+
+
+
+
+ Left click or tap to reveal · right click,{" "}
+ F, or long-press to flag · press{" "}
+ R to restart
+
+
+ );
+}
diff --git a/src/components/frontend/eod/EodArena.tsx b/src/components/frontend/eod/EodArena.tsx
index 0c386aa..25a93e6 100644
--- a/src/components/frontend/eod/EodArena.tsx
+++ b/src/components/frontend/eod/EodArena.tsx
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { Bomb } from "lucide-react";
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
@@ -29,6 +31,13 @@ export function EodArena({
return (
+
+
+ Custom minefields
+
setRefreshKey((key) => key + 1)}
diff --git a/tests/int/custom-minefields.int.spec.ts b/tests/int/custom-minefields.int.spec.ts
new file mode 100644
index 0000000..9f1aaf8
--- /dev/null
+++ b/tests/int/custom-minefields.int.spec.ts
@@ -0,0 +1,874 @@
+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 { CustomMinefields } from "@/collections/minigames/CustomMinefields";
+import { hasPermission } from "@/utils/access-control/hasPermission";
+import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
+import {
+ isEodUnlocked,
+ minefieldFlagsError,
+ validateGridSize,
+ validateMinefieldName,
+ validateMinefieldScore,
+ validateMinePositions,
+} from "@/lib/custom-minigames/eod";
+
+let payload: Payload;
+const RUN = `cmf-${Date.now().toString(36)}`;
+const TIMEOUT = 30_000;
+
+/** Minimal valid mine positions for a 9x9 grid. */
+const validMines = [
+ { row: 0, col: 0 },
+ { row: 2, col: 2 },
+ { row: 8, col: 8 },
+];
+
+describe("Custom minefields", () => {
+ let author: User;
+ let authorId: number;
+ const minefieldIds: 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: "MINEFIELD 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 → minefield)
+ for (const id of scoreIds) {
+ await payload
+ .delete({ collection: "custom-minefield-scores", id, overrideAccess: true })
+ .catch(() => {});
+ }
+ // Delete minefields
+ for (const id of minefieldIds) {
+ await payload
+ .delete({ collection: "custom-minefields", id, overrideAccess: true })
+ .catch(() => {});
+ }
+ // Delete user (and rely on afterChange hook cleanup for profile/bank)
+ await payload
+ .delete({ collection: "users", id: authorId, overrideAccess: true })
+ .catch(() => {});
+ });
+
+ // ---------------------------------------------------------------------------
+ // Collection-level field constraints
+ // ---------------------------------------------------------------------------
+
+ describe("Collection field validation", () => {
+ it("creates a valid minefield with all required fields", async () => {
+ const doc = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Test Minefield",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ published: true,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ minefieldIds.push(doc.id);
+ expect(doc.name).toBe("Test Minefield");
+ expect(doc.rows).toBe(9);
+ expect(doc.cols).toBe(9);
+ expect(doc.mineCount).toBe(3);
+ expect(doc.published).toBe(true);
+ expect(doc.playCount).toBe(0);
+ });
+
+ it("rejects rows below minimum (8 < 9)", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Tiny",
+ author: authorId,
+ rows: 8,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects cols above maximum (501 > 500)", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Wide",
+ author: authorId,
+ rows: 9,
+ cols: 501,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects mineCount of 0", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "No Mines",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: [],
+ mineCount: 0,
+ },
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("defaults published to true and playCount to 0", async () => {
+ const doc = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Defaults Test",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ minefieldIds.push(doc.id);
+ expect(doc.published).toBe(true);
+ expect(doc.playCount).toBe(0);
+ });
+
+ it("rejects a minefield without a name", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefields",
+ data: {
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a minefield without an author", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Orphan",
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Score collection
+ // ---------------------------------------------------------------------------
+
+ describe("Custom minefield scores", () => {
+ let minefieldId: number;
+
+ beforeAll(async () => {
+ const doc = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Score Test Field",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ minefieldId = doc.id;
+ minefieldIds.push(minefieldId);
+ });
+
+ it("creates a score record with valid fields", async () => {
+ const score = await payload.create({
+ collection: "custom-minefield-scores",
+ data: {
+ minefield: minefieldId,
+ user: authorId,
+ seconds: 45,
+ flags: 3,
+ won: true,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ expect(score.minefield).toBe(minefieldId);
+ expect(score.user).toBe(authorId);
+ expect(score.seconds).toBe(45);
+ expect(score.flags).toBe(3);
+ expect(score.won).toBe(true);
+ });
+
+ it("rejects a score without a minefield reference", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefield-scores",
+ data: {
+ user: authorId,
+ seconds: 10,
+ flags: 0,
+ won: false,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("rejects a score without a user reference", async () => {
+ await expect(
+ payload.create({
+ collection: "custom-minefield-scores",
+ data: {
+ minefield: minefieldId,
+ seconds: 10,
+ flags: 0,
+ won: false,
+ } as any,
+ overrideAccess: true,
+ depth: 0,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("creates a losing score with flags=0", async () => {
+ const score = await payload.create({
+ collection: "custom-minefield-scores",
+ data: {
+ minefield: minefieldId,
+ user: authorId,
+ seconds: 120,
+ flags: 0,
+ won: false,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ expect(score.won).toBe(false);
+ expect(score.flags).toBe(0);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Access control
+ // ---------------------------------------------------------------------------
+
+ describe("Access control", () => {
+ let viewer: User;
+ let viewerId: number;
+ let viewerMinefieldId: 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;
+
+ const doc = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Viewer Minefield",
+ author: viewerId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ viewerMinefieldId = doc.id;
+ minefieldIds.push(viewerMinefieldId);
+ });
+
+ it("logged-in user can read published minefields", async () => {
+ const result = await payload.find({
+ collection: "custom-minefields",
+ where: { published: { equals: true } },
+ limit: 1,
+ overrideAccess: true,
+ depth: 0,
+ });
+ expect(result.docs.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("logged-in user can create a minefield", async () => {
+ const doc = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Can Create",
+ author: viewerId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ minefieldIds.push(doc.id);
+ expect(doc.name).toBe("Can Create");
+ });
+
+ it("author can update their own minefield name via overrideAccess", async () => {
+ const updated = await payload.update({
+ collection: "custom-minefields",
+ id: viewerMinefieldId,
+ data: { name: "Updated Name" },
+ overrideAccess: true,
+ depth: 0,
+ });
+ expect(updated.name).toBe("Updated Name");
+ });
+
+ it("published minefields are visible to all logged-in users", async () => {
+ const result = await payload.find({
+ collection: "custom-minefields",
+ where: { published: { equals: true } },
+ limit: 20,
+ overrideAccess: true,
+ depth: 0,
+ });
+ expect(result.docs.some((doc) => doc.name === "Updated Name")).toBe(true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Delete flow
+ // ---------------------------------------------------------------------------
+
+ describe("Delete flow", () => {
+ it("deleting a minefield's scores then the minefield cleans up cleanly", async () => {
+ const mf = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Delete Test",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+
+ const score = await payload.create({
+ collection: "custom-minefield-scores",
+ data: {
+ minefield: mf.id,
+ user: authorId,
+ seconds: 25,
+ flags: 1,
+ won: true,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+
+ await payload.delete({
+ collection: "custom-minefield-scores",
+ id: score.id,
+ overrideAccess: true,
+ });
+
+ await payload.delete({
+ collection: "custom-minefields",
+ id: mf.id,
+ overrideAccess: true,
+ });
+
+ const remaining = await payload.findByID({
+ collection: "custom-minefield-scores",
+ id: score.id,
+ overrideAccess: true,
+ depth: 0,
+ }).catch(() => null);
+ expect(remaining).toBeNull();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Unlock gating (pure lib: mirrors checkEodUnlock)
+ // ---------------------------------------------------------------------------
+
+ describe("Unlock gating", () => {
+ it("locks custom minefields until a win is recorded on all three difficulties", () => {
+ expect(
+ isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
+ ).toBe(false);
+ expect(
+ isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }),
+ ).toBe(false);
+ expect(
+ isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }),
+ ).toBe(false);
+ });
+
+ it("unlocks once every difficulty has a best time above zero", () => {
+ expect(
+ isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 1 }),
+ ).toBe(true);
+ expect(
+ isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 120 }),
+ ).toBe(true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Action-level validation (pure lib: mirrors createMinefield / updateMinefield
+ // / submitCustomMinefieldScore)
+ // ---------------------------------------------------------------------------
+
+ describe("Action-level validation", () => {
+ it("rejects empty, oversized, and non-string names; trims valid ones", () => {
+ expect(validateMinefieldName("").error).toBe("Name must be 1-100 characters.");
+ expect(validateMinefieldName(" ").error).toBe("Name must be 1-100 characters.");
+ expect(validateMinefieldName("x".repeat(101)).error).toBe("Name must be 1-100 characters.");
+ expect(validateMinefieldName(123).error).toBe("Name must be 1-100 characters.");
+ expect(validateMinefieldName(" Clear Path ")).toEqual({ name: "Clear Path" });
+ });
+
+ it("rejects grid sizes outside 9-500 on either axis and non-integers", () => {
+ expect(validateGridSize(8, 9).error).toBe("Grid must be 9×9 to 500×500.");
+ expect(validateGridSize(9, 501).error).toBe("Grid must be 9×9 to 500×500.");
+ expect(validateGridSize(9.5, 9).error).toBe("Grid must be 9×9 to 500×500.");
+ expect(validateGridSize("9", 9).error).toBe("Grid must be 9×9 to 500×500.");
+ expect(validateGridSize(9, 9)).toEqual({ rows: 9, cols: 9 });
+ expect(validateGridSize(500, 500)).toEqual({ rows: 500, cols: 500 });
+ });
+
+ it("rejects non-array, empty, oversized, out-of-range, and duplicate mine lists", () => {
+ expect(validateMinePositions("boom", 9, 9).error).toBe("Mines must be an array of positions.");
+ expect(validateMinePositions([], 9, 9).error).toBe("Mine count must be between 1 and 10000.");
+ const tooMany = Array.from({ length: 10001 }, (_, i) => ({ row: 0, col: i % 9 }));
+ expect(validateMinePositions(tooMany, 9, 9).error).toBe(
+ "Mine count must be between 1 and 10000.",
+ );
+ const overflow = Array.from({ length: 82 }, (_, i) => ({
+ row: Math.floor(i / 9),
+ col: i % 9,
+ }));
+ expect(validateMinePositions(overflow, 9, 9).error).toBe("Mine count exceeds the 9×9 grid.");
+ expect(validateMinePositions([{ row: 9, col: 0 }], 9, 9).error).toBe(
+ "Every mine needs a row (0-8) and column (0-8).",
+ );
+ expect(validateMinePositions([{ row: 0, col: -1 }], 9, 9).error).toBe(
+ "Every mine needs a row (0-8) and column (0-8).",
+ );
+ expect(validateMinePositions([{ row: 0.5, col: 0 }], 9, 9).error).toBe(
+ "Every mine needs a row (0-8) and column (0-8).",
+ );
+ expect(
+ validateMinePositions(
+ [
+ { row: 0, col: 0 },
+ { row: 0, col: 0 },
+ ],
+ 9,
+ 9,
+ ).error,
+ ).toBe("Each cell can only hold one mine.");
+ });
+
+ it("normalizes valid mine positions", () => {
+ const result = validateMinePositions(validMines, 9, 9);
+ expect(result.error).toBeUndefined();
+ expect(result.positions).toEqual(validMines);
+ });
+
+ it("rejects impossible score results", () => {
+ expect(validateMinefieldScore({ seconds: 0, flags: 0 }).error).toBe("Invalid result.");
+ expect(validateMinefieldScore({ seconds: -5, flags: 0 }).error).toBe("Invalid result.");
+ expect(validateMinefieldScore({ seconds: 2.5, flags: 0 }).error).toBe("Invalid result.");
+ expect(validateMinefieldScore({ seconds: 3601, flags: 0 }).error).toBe("Invalid result.");
+ expect(validateMinefieldScore({ seconds: 30, flags: -1 }).error).toBe("Invalid result.");
+ expect(validateMinefieldScore({ seconds: Number.NaN, flags: 0 }).error).toBe(
+ "Invalid result.",
+ );
+ expect(validateMinefieldScore({ seconds: 30, flags: 2 })).toEqual({ seconds: 30, flags: 2 });
+ expect(validateMinefieldScore({ seconds: 3600, flags: 0 })).toEqual({
+ seconds: 3600,
+ flags: 0,
+ });
+ });
+
+ it("rejects flag counts above the minefield's declared mine count", () => {
+ expect(minefieldFlagsError(4, 3)).toBe("Invalid result.");
+ expect(minefieldFlagsError(3, 3)).toBeUndefined();
+ expect(minefieldFlagsError(0, 3)).toBeUndefined();
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Leaderboard ordering (DB: replicates CustomMinefieldLeaderboard's query:
+ // where minefield + won=true, sort seconds,flags, best won time per player)
+ // ---------------------------------------------------------------------------
+
+ describe("Leaderboard ordering", () => {
+ let fieldA: number;
+ let fieldB: number;
+ const tempUserIds: number[] = [];
+
+ const makePlayer = async (label: string): Promise => {
+ const user = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-lb-${label}`,
+ discordUsername: `${RUN}-lb-${label}`,
+ displayName: `LBOARD ${label.toUpperCase()}`,
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ roles: ["user"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ tempUserIds.push(user.id);
+ return user.id;
+ };
+
+ const record = async (
+ minefieldId: number,
+ userId: number,
+ seconds: number,
+ flags: number,
+ won: boolean,
+ ) => {
+ const score = await payload.create({
+ collection: "custom-minefield-scores",
+ data: { minefield: minefieldId, user: userId, seconds, flags, won },
+ overrideAccess: true,
+ depth: 0,
+ });
+ scoreIds.push(score.id);
+ return score.id;
+ };
+
+ beforeAll(async () => {
+ const a = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "LBoard Alpha",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ const b = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "LBoard Bravo",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ fieldA = a.id;
+ fieldB = b.id;
+ minefieldIds.push(fieldA, fieldB);
+ }, 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-minefield-scores",
+ where: { user: { equals: uid } },
+ overrideAccess: true,
+ })
+ .catch(() => {});
+ await payload
+ .delete({ collection: "users", id: uid, overrideAccess: true })
+ .catch(() => {});
+ }
+ });
+
+ it("keeps each player's best won time and isolates leaderboards per minefield", async () => {
+ const u1 = await makePlayer("one");
+ const u2 = await makePlayer("two");
+
+ // Field A: u1 40s then improved 35s, u2 30s, plus a faster-but-lost run.
+ await record(fieldA, u1, 40, 2, true);
+ await record(fieldA, u1, 35, 2, true);
+ await record(fieldA, u2, 30, 1, true);
+ await record(fieldA, u1, 5, 0, false);
+ // Field B: only u1 plays (50s).
+ await record(fieldB, u1, 50, 1, true);
+
+ const leaderboardFor = async (minefieldId: number) => {
+ const res = await payload.find({
+ collection: "custom-minefield-scores",
+ where: { minefield: { equals: minefieldId }, won: { equals: true } },
+ sort: "seconds,flags",
+ limit: 100,
+ depth: 1,
+ overrideAccess: true,
+ });
+ const best = new Map();
+ for (const doc of res.docs) {
+ const ref = doc.user as unknown as { id: number } | number | null;
+ if (ref === null) continue;
+ const uid = typeof ref === "object" ? ref.id : ref;
+ if (typeof uid !== "number") continue;
+ const prev = best.get(uid);
+ if (!prev || doc.seconds < prev.seconds) {
+ best.set(uid, { seconds: doc.seconds, flags: doc.flags ?? 0 });
+ }
+ }
+ return Array.from(best.entries())
+ .map(([uid, v]) => ({ uid, ...v }))
+ .sort((x, y) => x.seconds - y.seconds || x.flags - y.flags);
+ };
+
+ const ordered = await leaderboardFor(fieldA);
+ expect(ordered).toHaveLength(2);
+ // u2's 30s beats u1's best won 35s; the 5s loss never counts.
+ expect(ordered[0].uid).toBe(u2);
+ expect(ordered[0].seconds).toBe(30);
+ expect(ordered[1].uid).toBe(u1);
+ expect(ordered[1].seconds).toBe(35);
+ expect(ordered[1].flags).toBe(2);
+
+ const orderedB = await leaderboardFor(fieldB);
+ expect(orderedB).toHaveLength(1);
+ expect(orderedB[0].uid).toBe(u1);
+ expect(orderedB[0].seconds).toBe(50);
+ }, 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 gateFieldId: number;
+
+ const makeUserWithRole = async (label: string, roleId: number): Promise => {
+ const user = (await payload.create({
+ collection: "users",
+ data: {
+ username: `${RUN}-perm-${label}`,
+ discordUsername: `${RUN}-perm-${label}`,
+ displayName: `PERM ${label.toUpperCase()}`,
+ steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
+ password: "Test123",
+ // Permission resolution reads roleDocs (dynamic RBAC), not the legacy
+ // `roles` enum: assign a real role doc to grant permissions.
+ roleDocs: [roleId],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as User;
+ permUserIds.push(user.id);
+ return user;
+ };
+
+ const invokeAccess = (fn: unknown, user: User | null): Promise => {
+ return (fn as (args: { req: unknown }) => Promise)({
+ req: { user, payload },
+ });
+ };
+
+ beforeAll(async () => {
+ const plainRole = (await payload.create({
+ collection: "roles",
+ data: { name: `${RUN}-plain`, slug: `${RUN}-plain`, permissions: [] },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as Role;
+ const managerRole = (await payload.create({
+ collection: "roles",
+ data: {
+ name: `${RUN}-mf-manager`,
+ slug: `${RUN}-mf-manager`,
+ permissions: ["custom-minefields:update"],
+ },
+ overrideAccess: true,
+ depth: 0,
+ })) as unknown as Role;
+ const devRole = (await payload.create({
+ collection: "roles",
+ data: { name: `${RUN}-mf-dev`, slug: `${RUN}-mf-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 gateField = await payload.create({
+ collection: "custom-minefields",
+ data: {
+ name: "Gate Field",
+ author: authorId,
+ rows: 9,
+ cols: 9,
+ mines: validMines,
+ mineCount: 3,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ gateFieldId = gateField.id;
+ minefieldIds.push(gateFieldId);
+ }, 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 updateMinefield: isManager || isAuthor, evaluated against the
+ // gate field authored by `author`.
+ const gate = async (user: User) => {
+ const isManager = await hasPermission(payload, user, "custom-minefields: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 = CustomMinefields.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-minefields:delete", async () => {
+ const fn = CustomMinefields.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 = CustomMinefields.access?.create;
+ const readFn = CustomMinefields.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);
+ });
+});