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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { submitCustomMinefieldScore } from "@/app/(frontend)/eod/custom/actions";
|
import { submitCustomMinefieldScore } from "@/app/(frontend)/eod/custom/actions";
|
||||||
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { edgeMineIndices } from "@/lib/custom-minigames/eod";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { playClear, playExplosion, playFlag, playReveal } from "./sounds";
|
import { playClear, playExplosion, playFlag, playReveal } from "./sounds";
|
||||||
|
|
||||||
|
|
@ -38,6 +39,7 @@ export type CustomMinefieldData = {
|
||||||
rows: number;
|
rows: number;
|
||||||
cols: number;
|
cols: number;
|
||||||
mines: { row: number; col: number }[];
|
mines: { row: number; col: number }[];
|
||||||
|
exposeEdgeMines: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function emptyBoard(size: number): Board {
|
function emptyBoard(size: number): Board {
|
||||||
|
|
@ -116,6 +118,7 @@ function cellLabel(cell: Cell, row: number, col: number): string {
|
||||||
type LastRun = {
|
type LastRun = {
|
||||||
readonly won: boolean;
|
readonly won: boolean;
|
||||||
readonly seconds: number;
|
readonly seconds: number;
|
||||||
|
readonly xp?: number;
|
||||||
readonly error?: string;
|
readonly error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,6 +138,7 @@ export function CustomMinesweeperBoard({
|
||||||
);
|
);
|
||||||
const [seconds, setSeconds] = useState(0);
|
const [seconds, setSeconds] = useState(0);
|
||||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
|
const [lost, setLost] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -161,6 +165,7 @@ export function CustomMinesweeperBoard({
|
||||||
setBoard(createBoardFromMines(minefield.rows, minefield.cols, minefield.mines));
|
setBoard(createBoardFromMines(minefield.rows, minefield.cols, minefield.mines));
|
||||||
setSeconds(0);
|
setSeconds(0);
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
|
setLost(false);
|
||||||
setPhaseSafe("ready");
|
setPhaseSafe("ready");
|
||||||
}, [minefield, setPhaseSafe]);
|
}, [minefield, setPhaseSafe]);
|
||||||
|
|
||||||
|
|
@ -213,7 +218,7 @@ export function CustomMinesweeperBoard({
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
onGameRecorded();
|
onGameRecorded();
|
||||||
setLastRun({ won, seconds: elapsedSeconds });
|
setLastRun({ won, seconds: elapsedSeconds, xp: result.data?.xpEarned });
|
||||||
} else {
|
} else {
|
||||||
setLastRun({
|
setLastRun({
|
||||||
won,
|
won,
|
||||||
|
|
@ -229,23 +234,46 @@ export function CustomMinesweeperBoard({
|
||||||
(won: boolean, finalBoard: Board) => {
|
(won: boolean, finalBoard: Board) => {
|
||||||
if (submittingRef.current || phase === "over") return;
|
if (submittingRef.current || phase === "over") return;
|
||||||
setPhaseSafe("over");
|
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(
|
setBoard(
|
||||||
finalBoard.map((cell) =>
|
finalBoard.map((cell, index) =>
|
||||||
won
|
cell.mine
|
||||||
? cell.mine
|
? edgeMines.has(index) || cell.exploded
|
||||||
? { ...cell, flagged: true }
|
|
||||||
: cell
|
|
||||||
: cell.mine
|
|
||||||
? { ...cell, revealed: true }
|
? { ...cell, revealed: true }
|
||||||
|
: cell
|
||||||
: { ...cell, incorrectFlagged: cell.flagged },
|
: { ...cell, incorrectFlagged: cell.flagged },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (won) playClear();
|
if (won) playClear();
|
||||||
else playExplosion();
|
else playExplosion();
|
||||||
const flags = finalBoard.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
const flags = finalBoard.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
||||||
void recordResult(won, seconds, flags);
|
void recordResult(won, seconds, flags);
|
||||||
},
|
},
|
||||||
[phase, recordResult, seconds, setPhaseSafe],
|
[minefield, phase, recordResult, seconds, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reveal = useCallback(
|
const reveal = useCallback(
|
||||||
|
|
@ -326,8 +354,8 @@ export function CustomMinesweeperBoard({
|
||||||
{minefield.name}
|
{minefield.name}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom
|
{minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom wins
|
||||||
minefields do not award official XP
|
award reduced XP (10% of standard payouts)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
|
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
|
||||||
|
|
@ -362,7 +390,10 @@ export function CustomMinesweeperBoard({
|
||||||
<span>
|
<span>
|
||||||
The charge went off. MIA.
|
The charge went off. MIA.
|
||||||
{incorrectFlagCount > 0 &&
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{phase === "over" && !lastRun && <span>Recording result…</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",
|
"border-yellow-900/40 bg-yellow-950/15 text-amber-50 shadow-inner",
|
||||||
!cell.revealed &&
|
!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",
|
"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 &&
|
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",
|
"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.flagged &&
|
||||||
|
|
@ -460,6 +496,9 @@ export function CustomMinesweeperBoard({
|
||||||
<Flag className="size-3.5" />
|
<Flag className="size-3.5" />
|
||||||
) : null}
|
) : null}
|
||||||
{revealedMine ? <Bomb 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}
|
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -470,7 +509,10 @@ export function CustomMinesweeperBoard({
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Left click or tap to reveal · right click,{" "}
|
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">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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue