From 6d4b5ad297c6f2a75352cfda2a46c23e352c18a3 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 15:57:01 -0400 Subject: [PATCH] feat(eod): restrict custom loss reveal and add question-mark fog to hidden cells Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../frontend/eod/CustomMinesweeperBoard.tsx | 76 ++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/src/components/frontend/eod/CustomMinesweeperBoard.tsx b/src/components/frontend/eod/CustomMinesweeperBoard.tsx index 551efbd..305513a 100644 --- a/src/components/frontend/eod/CustomMinesweeperBoard.tsx +++ b/src/components/frontend/eod/CustomMinesweeperBoard.tsx @@ -1,10 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"; -import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-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"; @@ -38,6 +39,7 @@ export type CustomMinefieldData = { rows: number; cols: number; mines: { row: number; col: number }[]; + exposeEdgeMines: boolean; }; function emptyBoard(size: number): Board { @@ -116,6 +118,7 @@ function cellLabel(cell: Cell, row: number, col: number): string { type LastRun = { readonly won: boolean; readonly seconds: number; + readonly xp?: number; readonly error?: string; }; @@ -135,6 +138,7 @@ export function CustomMinesweeperBoard({ ); const [seconds, setSeconds] = useState(0); const [lastRun, setLastRun] = useState(null); + const [lost, setLost] = useState(false); const [submitting, setSubmitting] = useState(false); const submittingRef = useRef(false); @@ -161,6 +165,7 @@ export function CustomMinesweeperBoard({ setBoard(createBoardFromMines(minefield.rows, minefield.cols, minefield.mines)); setSeconds(0); setLastRun(null); + setLost(false); setPhaseSafe("ready"); }, [minefield, setPhaseSafe]); @@ -213,7 +218,7 @@ export function CustomMinesweeperBoard({ setSubmitting(false); if (result.success) { onGameRecorded(); - setLastRun({ won, seconds: elapsedSeconds }); + setLastRun({ won, seconds: elapsedSeconds, xp: result.data?.xpEarned }); } else { setLastRun({ won, @@ -229,23 +234,46 @@ export function CustomMinesweeperBoard({ (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 } + 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); }, - [phase, recordResult, seconds, setPhaseSafe], + [minefield, phase, recordResult, seconds, setPhaseSafe], ); const reveal = useCallback( @@ -326,8 +354,8 @@ export function CustomMinesweeperBoard({ {minefield.name}

- {minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom - minefields do not award official XP + {minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom wins + award reduced XP (10% of standard payouts)

@@ -362,7 +390,10 @@ export function CustomMinesweeperBoard({ The charge went off. MIA. {incorrectFlagCount > 0 && - ` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`} + ` ${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…} @@ -416,6 +447,11 @@ export function CustomMinesweeperBoard({ "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 && @@ -460,6 +496,9 @@ export function CustomMinesweeperBoard({ ) : null} {revealedMine ? : null} + {lost && !cell.revealed && !cell.flagged && !cell.incorrectFlagged ? ( + + ) : null} {cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null} ); @@ -470,7 +509,10 @@ export function CustomMinesweeperBoard({

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

);