feat(eod): add custom minefield authoring, play, and leaderboard
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
5b4842943e
commit
52328da8f9
10 changed files with 2450 additions and 0 deletions
77
src/app/(frontend)/eod/custom/[id]/page.tsx
Normal file
77
src/app/(frontend)/eod/custom/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/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 minefields are unranked and do not affect official stats or XP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomMinefieldArena
|
||||||
|
minefield={{
|
||||||
|
id: minefield.id,
|
||||||
|
name: minefield.name,
|
||||||
|
rows: minefield.rows,
|
||||||
|
cols: minefield.cols,
|
||||||
|
mines,
|
||||||
|
}}
|
||||||
|
currentUserId={user?.id ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
src/app/(frontend)/eod/custom/actions.ts
Normal file
386
src/app/(frontend)/eod/custom/actions.ts
Normal file
|
|
@ -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<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;
|
||||||
|
}): 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,
|
||||||
|
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<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.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<undefined>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/(frontend)/eod/custom/page.tsx
Normal file
51
src/app/(frontend)/eod/custom/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
src/components/frontend/eod/CreateMinefieldDialog.tsx
Normal file
226
src/components/frontend/eod/CreateMinefieldDialog.tsx
Normal file
|
|
@ -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<Set<number>>(new Set());
|
||||||
|
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 });
|
||||||
|
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(
|
||||||
|
"size-5 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/frontend/eod/CustomMinefieldArena.tsx
Normal file
48
src/components/frontend/eod/CustomMinefieldArena.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<CustomMinesweeperBoard
|
||||||
|
minefield={minefield}
|
||||||
|
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
||||||
|
onPhaseChange={onPhaseChange}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/frontend/eod/CustomMinefieldBrowser.tsx
Normal file
139
src/components/frontend/eod/CustomMinefieldBrowser.tsx
Normal file
|
|
@ -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<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. Playing these does not affect your official stats or XP.
|
||||||
|
</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={`/eod/custom/${row.id}`}>
|
||||||
|
<Play /> Play
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/components/frontend/eod/CustomMinefieldLeaderboard.tsx
Normal file
163
src/components/frontend/eod/CustomMinefieldLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiScore {
|
||||||
|
id: number;
|
||||||
|
user?: number | { id: number; displayName?: string | null; username?: string | null } | null;
|
||||||
|
seconds: number;
|
||||||
|
flags: number;
|
||||||
|
won: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
seconds: number;
|
||||||
|
flags: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankClass(index: number) {
|
||||||
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
if (index === 2) return "font-semibold text-amber-700";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomMinefieldLeaderboard({
|
||||||
|
minefieldId,
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
minefieldId: number;
|
||||||
|
refreshKey?: number;
|
||||||
|
currentUserId?: number | string;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: "seconds,flags",
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
params.set("where[minefield][equals]", String(minefieldId));
|
||||||
|
params.set("where[won][equals]", "true");
|
||||||
|
|
||||||
|
fetch(`/api/custom-minefield-scores?${params.toString()}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
return res.json() as Promise<{ docs?: ApiScore[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const bestPerUser = new Map<number, ApiScore>();
|
||||||
|
for (const doc of data.docs ?? []) {
|
||||||
|
const userId = typeof doc.user === "object" && doc.user !== null ? doc.user.id : null;
|
||||||
|
if (userId === null) continue;
|
||||||
|
const existing = bestPerUser.get(userId);
|
||||||
|
if (!existing || doc.seconds < existing.seconds) bestPerUser.set(userId, doc);
|
||||||
|
}
|
||||||
|
const mapped: LeaderboardRow[] = Array.from(bestPerUser.values()).map((doc) => {
|
||||||
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: user?.displayName || user?.username || `Operator #${user?.id ?? "?"}`,
|
||||||
|
seconds: doc.seconds,
|
||||||
|
flags: doc.flags,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const sorted = mapped.sort((a, b) => a.seconds - b.seconds || a.flags - b.flags);
|
||||||
|
const visible = sorted.slice(0, 10);
|
||||||
|
const current = sorted.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [minefieldId, refreshKey, currentUserId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Trophy className="size-4 text-amber-400" />
|
||||||
|
<h2 className="text-sm font-semibold">Minefield Leaderboard</h2>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">Top 10 · fastest clear</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-2 px-4 py-4">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No clears logged yet. Take the first run!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Time</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>
|
||||||
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[220px] truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{row.seconds}s
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.flags}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
477
src/components/frontend/eod/CustomMinesweeperBoard.tsx
Normal file
477
src/components/frontend/eod/CustomMinesweeperBoard.tsx
Normal file
|
|
@ -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<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 }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 [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<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
|
||||||
|
minefields do not award official XP
|
||||||
|
</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.`}
|
||||||
|
</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",
|
||||||
|
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}
|
||||||
|
{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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Bomb } from "lucide-react";
|
||||||
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
|
@ -29,6 +31,13 @@ export function EodArena({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<Link
|
||||||
|
href="/eod/custom"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-sm text-amber-300 transition-colors hover:bg-amber-500/20"
|
||||||
|
>
|
||||||
|
<Bomb className="size-4" />
|
||||||
|
Custom minefields
|
||||||
|
</Link>
|
||||||
<MinesweeperBoard
|
<MinesweeperBoard
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
||||||
|
|
|
||||||
874
tests/int/custom-minefields.int.spec.ts
Normal file
874
tests/int/custom-minefields.int.spec.ts
Normal file
|
|
@ -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<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 (
|
||||||
|
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<number, { seconds: number; flags: 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 || 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<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}-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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue