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