1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-08 15:57:01 -04:00
parent efda519569
commit 6d4b5ad297

View file

@ -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<LastRun | null>(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<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);
},
[phase, recordResult, seconds, setPhaseSafe],
[minefield, phase, recordResult, seconds, setPhaseSafe],
);
const reveal = useCallback(
@ -326,8 +354,8 @@ export function CustomMinesweeperBoard({
{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
{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">
@ -362,7 +390,10 @@ export function CustomMinesweeperBoard({
<span>
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."}
</span>
)}
{phase === "over" && !lastRun && <span>Recording result…</span>}
@ -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({
<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>
);
@ -470,7 +509,10 @@ export function CustomMinesweeperBoard({
<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
<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>
);