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:
parent
efda519569
commit
6d4b5ad297
1 changed files with 59 additions and 17 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue