diff --git a/src/app/(frontend)/eod/custom/[id]/page.tsx b/src/app/(frontend)/eod/custom/[id]/page.tsx new file mode 100644 index 0000000..a15caa7 --- /dev/null +++ b/src/app/(frontend)/eod/custom/[id]/page.tsx @@ -0,0 +1,77 @@ +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/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 }[]) + : []; + + return ( +
+
+ + All custom minefields + +
+
+
+ +

{minefield.name}

+
+

+ Custom minefields are unranked and do not affect official stats or XP. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/eod/custom/actions.ts b/src/app/(frontend)/eod/custom/actions.ts new file mode 100644 index 0000000..9ae3630 --- /dev/null +++ b/src/app/(frontend)/eod/custom/actions.ts @@ -0,0 +1,386 @@ +"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 { + isEodUnlocked, + minefieldFlagsError, + validateGridSize, + validateMinefieldName, + validateMinefieldScore, + validateMinePositions, +} from "@/lib/custom-minigames/eod"; + +interface ActionResult { + 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> { + 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; +}): Promise> { + 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, + 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; +}): Promise> { + 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 = {}; + + 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.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> { + 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> { + 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 }; + } + + 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 }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.MinigameCustomEodPlay, + message: input.won + ? `Cleared custom minefield "${minefield.name}" in ${input.seconds}s` + : `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), + }, + }); + + 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 }; + } +} diff --git a/src/app/(frontend)/eod/custom/page.tsx b/src/app/(frontend)/eod/custom/page.tsx new file mode 100644 index 0000000..b7a03d2 --- /dev/null +++ b/src/app/(frontend)/eod/custom/page.tsx @@ -0,0 +1,51 @@ +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/eod/CustomMinefieldBrowser"; + +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 = + (eod.bestTrainingTime ?? 0) > 0 && + (eod.bestPerimeterTime ?? 0) > 0 && + (eod.bestSupplyRoadTime ?? 0) > 0; + + return ( +
+
+
+ +

Custom Minefields

+
+

+ Operator-authored minefields. Unranked, no XP. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/eod/CreateMinefieldDialog.tsx b/src/components/frontend/eod/CreateMinefieldDialog.tsx new file mode 100644 index 0000000..33c59c3 --- /dev/null +++ b/src/components/frontend/eod/CreateMinefieldDialog.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState } from "react"; +import { Bomb, Loader2 } 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 { cn } from "@/lib/utils"; +import { createMinefield } from "@/app/(frontend)/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>(new Set()); + const [error, setError] = useState(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(); + 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 }); + 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 ( + { + setOpen(next); + if (!next) setError(null); + }} + > + + {children ?? ( + + )} + + + + Author a custom minefield + + Pick a grid size, then paint charges. Published fields are instantly playable by every + operator. No safe first click is guaranteed. + + + +
+
+
+ + setName(event.target.value)} + placeholder="Sector Ghost" + className="w-48" + /> +
+
+ + { + const next = clampSize(Number(event.target.value)); + setRows(next); + resetPainting(next, cols); + }} + className="w-24" + /> +
+
+ + { + const next = clampSize(Number(event.target.value)); + setCols(next); + resetPainting(rows, next); + }} + className="w-24" + /> +
+ + + {mineCount} mines + +
+ + {paintable ? ( +
+ {Array.from({ length: rows * cols }, (_, index) => { + const painted = mines.has(index); + return ( + + ); + })} +
+ ) : ( +

+ Painting supports grids up to {Math.sqrt(PAINTER_MAX_CELLS)}× + {Math.sqrt(PAINTER_MAX_CELLS)} cells. Shrink the grid to author charges. +

+ )} + + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/src/components/frontend/eod/CustomMinefieldArena.tsx b/src/components/frontend/eod/CustomMinefieldArena.tsx new file mode 100644 index 0000000..65c2ad7 --- /dev/null +++ b/src/components/frontend/eod/CustomMinefieldArena.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { CustomMinesweeperBoard, type CustomMinefieldData } from "./CustomMinesweeperBoard"; +import { CustomMinefieldLeaderboard } from "./CustomMinefieldLeaderboard"; +import { useRealtimePresence } from "@/hooks/useRealtimePresence"; +import { useSidebar } from "@/components/ui/sidebar"; +import { useEffect } from "react"; + +export function CustomMinefieldArena({ + minefield, + currentUserId, +}: { + minefield: CustomMinefieldData; + currentUserId: number | string; +}) { + 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 ( +
+ setRefreshKey((key) => key + 1)} + onPhaseChange={onPhaseChange} + /> + + {activeTeams.length > 0 && ( +

+ {activeTeams.length} operator{activeTeams.length === 1 ? "" : "s"} on site +

+ )} +
+ ); +} diff --git a/src/components/frontend/eod/CustomMinefieldBrowser.tsx b/src/components/frontend/eod/CustomMinefieldBrowser.tsx new file mode 100644 index 0000000..ccd66bd --- /dev/null +++ b/src/components/frontend/eod/CustomMinefieldBrowser.tsx @@ -0,0 +1,139 @@ +"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(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 ( +
+
+

+ Community minefields. Playing these does not affect your official stats or XP. +

+ +
+ + {failed ? ( +

+ Could not load custom minefields. +

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

+ No custom minefields published yet. + {unlocked ? " Be the first to author one!" : " Clear all three standard sectors to author one."} +

+ ) : ( +
+ {rows.map((row) => ( +
+
+
+ + {row.name} + {row.isOwn && ( + (yours) + )} +
+ + {row.rows}×{row.cols} · {row.mineCount} charges · by {row.authorName} · played{" "} + {row.playCount}× + +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/frontend/eod/CustomMinefieldLeaderboard.tsx b/src/components/frontend/eod/CustomMinefieldLeaderboard.tsx new file mode 100644 index 0000000..b818d82 --- /dev/null +++ b/src/components/frontend/eod/CustomMinefieldLeaderboard.tsx @@ -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(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(); + 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 ( +
+
+ +

Minefield Leaderboard

+ Top 10 · fastest clear +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

+ No clears logged yet. Take the first run! +

+ ) : ( +
+ + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + ))} + +
#OperatorTimeFlags
+ + {row.isOutsideTopTen ? "-" : index + 1} + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + + + {row.seconds}s + + {row.flags} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/eod/CustomMinesweeperBoard.tsx b/src/components/frontend/eod/CustomMinesweeperBoard.tsx new file mode 100644 index 0000000..551efbd --- /dev/null +++ b/src/components/frontend/eod/CustomMinesweeperBoard.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"; +import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { submitCustomMinefieldScore } from "@/app/(frontend)/eod/custom/actions"; +import { useSessionExpired } from "@/hooks/useSessionExpired"; +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> = { + 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 }[]; +}; + +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(); + 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(); + 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 error?: string; +}; + +export function CustomMinesweeperBoard({ + minefield, + onGameRecorded, + onPhaseChange, +}: { + minefield: CustomMinefieldData; + onGameRecorded: () => void; + onPhaseChange: (phase: Phase) => void; +}) { + const { expired, expiredRef } = useSessionExpired(); + const [phase, setPhase] = useState("ready"); + const [board, setBoard] = useState(() => + createBoardFromMines(minefield.rows, minefield.cols, minefield.mines), + ); + const [seconds, setSeconds] = useState(0); + const [lastRun, setLastRun] = useState(null); + 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); + 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 }); + } 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"); + setBoard( + finalBoard.map((cell) => + won + ? cell.mine + ? { ...cell, flagged: true } + : cell + : cell.mine + ? { ...cell, revealed: true } + : { ...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); + }, + [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(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); + }); +});