"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> = { 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(); for (const { row, col } of mines) { if (row >= 0 && row < rows && col >= 0 && col < cols) { mineSet.add(row * cols + col); } } const mined = emptyBoard(size).map((cell, index) => ({ ...cell, mine: mineSet.has(index) })); return mined.map((cell, index) => ({ ...cell, adjacent: neighbors(index, rows, cols).filter((nearby) => mined[nearby]?.mine).length, })); } function revealFrom(start: number, board: Board, rows: number, cols: number): Board { const next = [...board]; const queue = [start]; const visited = new Set(); while (queue.length > 0) { const index = queue.shift(); if (index === undefined || visited.has(index)) continue; visited.add(index); const cell = next[index]; if (!cell || cell.flagged || cell.revealed || cell.mine) continue; next[index] = { ...cell, revealed: true }; if (cell.adjacent === 0) queue.push(...neighbors(index, rows, cols)); } return next; } function cellLabel(cell: Cell, row: number, col: number): string { const position = `row ${row + 1} col ${col + 1}`; if (cell.incorrectFlagged) return `${position} incorrect flag`; if (cell.flagged) return `${position} flagged`; if (!cell.revealed) return `${position} hidden`; if (cell.mine) return `${position} mine`; if (cell.adjacent === 0) return `${position} revealed empty`; return `${position} revealed number ${cell.adjacent}`; } type LastRun = { readonly won: boolean; readonly seconds: number; readonly 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("ready"); const [board, setBoard] = useState(() => createBoardFromMines(minefield.rows, minefield.cols, minefield.mines), ); const [seconds, setSeconds] = useState(0); const [lastRun, setLastRun] = useState(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(); 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(null); const clearLongPress = useCallback(() => { if (longPressTimerRef.current !== null) { window.clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } }, []); useEffect(() => { return () => { if (longPressTimerRef.current !== null) window.clearTimeout(longPressTimerRef.current); }; }, []); const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0); const incorrectFlagCount = board.reduce( (count, cell) => count + (cell.incorrectFlagged ? 1 : 0), 0, ); const statusClass = phase === "over" ? lastRun?.won ? "border-emerald-500/40 bg-emerald-500/10" : "border-destructive/40 bg-destructive/10" : "border-border bg-card"; return (
event.preventDefault()} >
{minefield.name}

{minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom wins award reduced XP (10% of standard payouts)

{minefield.mines.length - flaggedCount} {seconds}s
{phase === "ready" && Awaiting deployment. Any first click can be a mine.} {phase === "playing" && Live sweep: mark every charge before you reveal.} {phase === "over" && lastRun?.won && ( Sector clear. Defused in {lastRun.seconds}s. )} {phase === "over" && !lastRun?.won && lastRun && ( The charge went off. MIA. {incorrectFlagCount > 0 && ` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`}{" "} {minefield.exposeEdgeMines ? "Only charges bordering exposed ground are revealed; the rest of the layout stays hidden." : "No remaining charges are revealed."} )} {phase === "over" && !lastRun && Recording result…} {lastRun?.error && {lastRun.error}}
{phase === "over" && ( )}
{board.map((cell, index) => { const row = Math.floor(index / minefield.cols); const col = index % minefield.cols; const revealedMine = cell.revealed && cell.mine; return ( ); })}

Left click or tap to reveal · right click,{" "} F, or long-press to flag · press{" "} R to restart ·{" "} {minefield.exposeEdgeMines ? "failed runs reveal only the charges bordering exposed ground" : "failed runs reveal no remaining charges"}

); }