From 512cdd62ad8825ec7961597afcf7f7e1450801bd Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 26 Aug 2026 22:04:48 -0400 Subject: [PATCH] feat(eod): add minesweeper clearance game --- src/app/(frontend)/eod/actions.ts | 212 +++++++ src/app/(frontend)/eod/page.tsx | 62 +++ src/components/frontend/blocks/NavUser.tsx | 8 +- src/components/frontend/eod/EodArena.tsx | 37 ++ .../frontend/eod/EodLeaderboard.tsx | 215 ++++++++ .../frontend/eod/MinesweeperBoard.tsx | 519 ++++++++++++++++++ src/components/frontend/eod/sounds.ts | 103 ++++ 7 files changed, 1155 insertions(+), 1 deletion(-) create mode 100644 src/app/(frontend)/eod/actions.ts create mode 100644 src/app/(frontend)/eod/page.tsx create mode 100644 src/components/frontend/eod/EodArena.tsx create mode 100644 src/components/frontend/eod/EodLeaderboard.tsx create mode 100644 src/components/frontend/eod/MinesweeperBoard.tsx create mode 100644 src/components/frontend/eod/sounds.ts diff --git a/src/app/(frontend)/eod/actions.ts b/src/app/(frontend)/eod/actions.ts new file mode 100644 index 0000000..c97f34c --- /dev/null +++ b/src/app/(frontend)/eod/actions.ts @@ -0,0 +1,212 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { broadcastToUser } from "@/lib/realtime/bus"; + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +export interface EodStats { + gamesPlayed: number; + wins: number; + losses: number; + bestScore: number; + eodXp: number; + xpEarned: number; +} + +const DIFFICULTIES = { + training: { + label: "Training Pad", + rows: 9, + cols: 9, + mines: 10, + baseScore: 100, + parSeconds: 60, + maxSeconds: 300, + }, + perimeter: { + label: "FOB Perimeter", + rows: 16, + cols: 16, + mines: 40, + baseScore: 300, + parSeconds: 180, + maxSeconds: 900, + }, + supplyRoad: { + label: "Main Supply Road", + rows: 16, + cols: 30, + mines: 99, + baseScore: 600, + parSeconds: 420, + maxSeconds: 1800, + }, +} as const; + +export type EodDifficulty = keyof typeof DIFFICULTIES; + +const BEST_TIME_FIELDS = { + training: "bestTrainingTime", + perimeter: "bestPerimeterTime", + supplyRoad: "bestSupplyRoadTime", +} as const; + +type EodTimeField = (typeof BEST_TIME_FIELDS)[EodDifficulty]; + +function isEodDifficulty(value: string): value is EodDifficulty { + return Object.prototype.hasOwnProperty.call(DIFFICULTIES, value); +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ + headers: hdrs, + canSetHeaders: false, + }); + + if (!user) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +export async function submitEodResult(input: { + difficulty: EodDifficulty; + seconds: number; + won: boolean; +}): Promise> { + try { + const { payload, user } = await authenticate(); + + if (typeof input.difficulty !== "string" || !isEodDifficulty(input.difficulty)) { + return { success: false, error: "Invalid result." }; + } + + const difficulty = input.difficulty; + const difficultyConfig = DIFFICULTIES[difficulty]; + if ( + !Number.isFinite(input.seconds) || + !Number.isInteger(input.seconds) || + input.seconds <= 0 || + input.seconds > difficultyConfig.maxSeconds + ) { + return { success: false, error: "Invalid result." }; + } + + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: user.id as number } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profile = profileRes.docs[0]; + if (!profile) { + return { success: false, error: "No profile found for this user." }; + } + + const progression = (profile.progression ?? {}) as { + xp?: unknown; + minigames?: { + eodMinesweeper?: { + gamesPlayed?: number; + wins?: number; + losses?: number; + bestScore?: number; + xpEarned?: number; + bestTrainingTime?: number; + bestPerimeterTime?: number; + bestSupplyRoadTime?: number; + }; + }; + }; + const current = progression.minigames?.eodMinesweeper ?? {}; + const score = input.won + ? difficultyConfig.baseScore + Math.max(0, difficultyConfig.parSeconds - input.seconds) + : 0; + const runXp = input.won ? Math.max(1, Math.floor(score / 50)) : 0; + const gamesPlayed = (current.gamesPlayed ?? 0) + 1; + const wins = (current.wins ?? 0) + (input.won ? 1 : 0); + const losses = (current.losses ?? 0) + (input.won ? 0 : 1); + const bestScore = Math.max(current.bestScore ?? 0, score); + const eodXp = (current.xpEarned ?? 0) + runXp; + const bestTimeField: EodTimeField = BEST_TIME_FIELDS[difficulty]; + const previousBestTime = current[bestTimeField] ?? 0; + const bestTime = + input.won && (previousBestTime === 0 || input.seconds < previousBestTime) + ? input.seconds + : previousBestTime; + + const { xp: _xp, ...progressionRest } = progression; + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progressionRest, + minigames: { + ...(progressionRest.minigames ?? {}), + eodMinesweeper: { + gamesPlayed, + wins, + losses, + bestScore, + xpEarned: eodXp, + bestTrainingTime: current.bestTrainingTime ?? 0, + bestPerimeterTime: current.bestPerimeterTime ?? 0, + bestSupplyRoadTime: current.bestSupplyRoadTime ?? 0, + [bestTimeField]: bestTime, + }, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + broadcastToUser("profile-update", Number(user.id), { + reason: "xp", + xpGained: runXp, + profileId: profile.id, + }); + + if (input.won) { + await emitGameEvent(payload, { + type: EventTypes.MinigameEod, + message: `Cleared ${difficultyConfig.label} in ${input.seconds}s (+${runXp} XP)`, + actor: user.id as number, + targetCollection: "users", + targetId: user.id as number, + data: { + difficulty, + seconds: input.seconds, + score, + xpEarned: runXp, + }, + }); + } + + return { + success: true, + data: { gamesPlayed, wins, losses, bestScore, eodXp, xpEarned: runXp }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} diff --git a/src/app/(frontend)/eod/page.tsx b/src/app/(frontend)/eod/page.tsx new file mode 100644 index 0000000..1763915 --- /dev/null +++ b/src/app/(frontend)/eod/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { Bomb } from "lucide-react"; +import { EodArena } from "@/components/frontend/eod/EodArena"; + +export const metadata: Metadata = { + title: "Minefield Clearance — Polaris Task Force", + description: "Clear simulated minefields across three EOD sectors to earn XP for the task force.", +}; + +export default async function EodPage() { + const headers = await nextHeaders(); + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + if (!user) { + redirect(`/login?returnTo=${encodeURIComponent("/eod")}`); + } + + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: user.id as number } }, + limit: 1, + depth: 0, + overrideAccess: true, + select: { + progression: { + minigames: true, + }, + }, + }); + + const eod = profileRes.docs[0]?.progression?.minigames?.eodMinesweeper ?? {}; + const stats = { + gamesPlayed: eod.gamesPlayed ?? 0, + wins: eod.wins ?? 0, + losses: eod.losses ?? 0, + bestScore: eod.bestScore ?? 0, + eodXp: eod.xpEarned ?? 0, + xpEarned: 0, + }; + + return ( +
+
+
+ +

Minefield Clearance

+
+

+ Clear the minefield to earn XP. Faster clears score higher. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx index 90c2d15..0971298 100644 --- a/src/components/frontend/blocks/NavUser.tsx +++ b/src/components/frontend/blocks/NavUser.tsx @@ -1,6 +1,6 @@ "use client"; -import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react"; +import { BadgeCheck, Bird, Bomb, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -107,6 +107,12 @@ export function NavUser({ Flight Simulator + + + + Minefield Clearance + + diff --git a/src/components/frontend/eod/EodArena.tsx b/src/components/frontend/eod/EodArena.tsx new file mode 100644 index 0000000..dda3793 --- /dev/null +++ b/src/components/frontend/eod/EodArena.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { EodStats } from "@/app/(frontend)/eod/actions"; +import { useRealtimePresence } from "@/hooks/useRealtimePresence"; +import { EodLeaderboard } from "./EodLeaderboard"; +import { MinesweeperBoard } from "./MinesweeperBoard"; + +export function EodArena({ + initialStats, + currentUserId, +}: { + initialStats: EodStats; + currentUserId: number | string; +}) { + const [refreshKey, setRefreshKey] = useState(0); + const [playing, setPlaying] = useState(false); + const activeTeams = useRealtimePresence("eod", playing, true); + const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { + setPlaying(phase === "playing"); + }, []); + + return ( +
+ setRefreshKey((key) => key + 1)} + onPhaseChange={onPhaseChange} + /> + +
+ ); +} diff --git a/src/components/frontend/eod/EodLeaderboard.tsx b/src/components/frontend/eod/EodLeaderboard.tsx new file mode 100644 index 0000000..d4faa02 --- /dev/null +++ b/src/components/frontend/eod/EodLeaderboard.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Trophy } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; + +interface ApiUser { + id: number; + username?: string | null; + displayName?: string | null; + payloadDisplayName?: string | null; +} + +interface ApiProfile { + id: number; + profileTitle?: string | null; + user?: ApiUser | number | null; + progression?: { + minigames?: { + eodMinesweeper?: { + gamesPlayed?: number | null; + wins?: number | null; + bestScore?: number | null; + xpEarned?: number | null; + } | null; + } | null; + } | null; +} + +interface LeaderboardRow { + id: number; + name: string; + gamesPlayed: number; + wins: number; + bestScore: number; + xpEarned: number; + isCurrentUser: boolean; + isOutsideTopTen?: boolean; +} + +interface EodLeaderboardProps { + refreshKey?: number; + currentUserId?: number | string; + activeTeams?: PresenceParticipant[]; +} + +function rankClass(index: number) { + if (index === 0) return "font-bold text-amber-400"; + if (index === 1) return "font-semibold text-slate-300"; + if (index === 2) return "font-semibold text-amber-700"; + return "text-muted-foreground"; +} + +export function EodLeaderboard({ + refreshKey = 0, + currentUserId, + activeTeams = [], +}: EodLeaderboardProps) { + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setFailed(false); + + const params = new URLSearchParams({ + sort: "-progression.minigames.eodMinesweeper.bestScore", + limit: "100", + depth: "1", + }); + + fetch(`/api/profiles?${params.toString()}`) + .then((res) => { + if (!res.ok) throw new Error("leaderboard fetch failed"); + return res.json() as Promise<{ docs?: ApiProfile[] }>; + }) + .then((data) => { + if (cancelled) return; + const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => { + const eod = doc.progression?.minigames?.eodMinesweeper ?? {}; + const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null; + const name = + user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`; + return { + id: doc.id, + name, + gamesPlayed: eod.gamesPlayed ?? 0, + wins: eod.wins ?? 0, + bestScore: eod.bestScore ?? 0, + xpEarned: eod.xpEarned ?? 0, + isCurrentUser: user ? String(user.id) === String(currentUserId) : false, + }; + }); + const scored = mapped.filter((row) => row.bestScore > 0); + const visible = scored.slice(0, 10); + const current = mapped.find((row) => row.isCurrentUser); + if (current && !visible.some((row) => row.id === current.id)) { + visible.push({ ...current, isOutsideTopTen: true }); + } + setRows(visible); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [refreshKey, currentUserId]); + + return ( +
+
+ +

Minefield Clearance Leaderboard

+ Top 10 · best clearance +
+
+ {activeTeams.length} defusal teams on site + {activeTeams.length > 0 && ( + + ({activeTeams.map((team) => team.name).join(", ")}) + + )} +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

+ No clearances logged yet — take the first sector! +

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
#OperatorGamesWinsBestXP
+ + {row.isOutsideTopTen ? "—" : index + 1} + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.isOutsideTopTen && ( + + unranked + + )} + {activeTeams.some((team) => team.id === row.id) && ( + + )} + + + {row.gamesPlayed} + + {row.wins} + + {row.bestScore} + + {row.xpEarned} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/eod/MinesweeperBoard.tsx b/src/components/frontend/eod/MinesweeperBoard.tsx new file mode 100644 index 0000000..038bd4a --- /dev/null +++ b/src/components/frontend/eod/MinesweeperBoard.tsx @@ -0,0 +1,519 @@ +"use client"; + +// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract. + +import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { Bomb, Flag, RotateCcw, ShieldCheck, Timer } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions"; +import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; +import { cn } from "@/lib/utils"; +import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds"; + +type Phase = "ready" | "playing" | "over"; + +type Cell = { + readonly mine: boolean; + readonly adjacent: number; + readonly revealed: boolean; + readonly flagged: boolean; + readonly exploded: boolean; +}; + +type Board = readonly Cell[]; + +const DIFFICULTIES = { + training: { + label: "Training Pad", + rows: 9, + cols: 9, + mines: 10, + baseScore: 100, + parSeconds: 60, + maxSeconds: 300, + }, + perimeter: { + label: "FOB Perimeter", + rows: 16, + cols: 16, + mines: 40, + baseScore: 300, + parSeconds: 180, + maxSeconds: 900, + }, + supplyRoad: { + label: "Main Supply Road", + rows: 16, + cols: 30, + mines: 99, + baseScore: 600, + parSeconds: 420, + maxSeconds: 1800, + }, +} as const; + +const DIFFICULTY_KEYS: readonly EodDifficulty[] = ["training", "perimeter", "supplyRoad"]; +const NUMBER_CLASSES: Readonly> = { + 1: "text-blue-400", + 2: "text-green-400", + 3: "text-red-400", + 4: "text-purple-400", + 5: "text-rose-700", + 6: "text-teal-400", + 7: "text-slate-200", + 8: "text-gray-400", +}; + +function emptyBoard(size: number): Board { + return Array.from({ length: size }, () => ({ + mine: false, + adjacent: 0, + revealed: false, + flagged: false, + exploded: 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 < cols) { + nearby.push(nextRow * cols + nextCol); + } + } + } + return nearby; +} + +function createBoard(config: (typeof DIFFICULTIES)[EodDifficulty], safeIndex: number): Board { + const size = config.rows * config.cols; + const safe = new Set([safeIndex, ...neighbors(safeIndex, config.rows, config.cols)]); + const mines = new Set(); + while (mines.size < config.mines) { + const candidate = Math.floor(Math.random() * size); + if (!safe.has(candidate)) mines.add(candidate); + } + const mined = emptyBoard(size).map((cell, index) => ({ ...cell, mine: mines.has(index) })); + return mined.map((cell, index) => ({ + ...cell, + adjacent: neighbors(index, config.rows, config.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.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 score: number; + readonly xpEarned: number; + readonly best: number; + readonly error?: string; +}; + +export function MinesweeperBoard({ + initialStats: serverStats, + onGameRecorded, + onPhaseChange, +}: { + initialStats: EodStats; + onGameRecorded: () => void; + onPhaseChange: (phase: Phase) => void; +}) { + const router = useRouter(); + const [difficulty, setDifficulty] = useState("training"); + const [phase, setPhase] = useState("ready"); + const [board, setBoard] = useState(() => emptyBoard(81)); + const [seconds, setSeconds] = useState(0); + const [stats, setStats] = useState(serverStats); + const [lastRun, setLastRun] = useState(null); + const [submitting, setSubmitting] = useState(false); + const generatedRef = useRef(false); + const submittingRef = useRef(false); + const statsRef = useRef(serverStats); + const longPressRef = useRef(false); + const longPressTimerRef = useRef(null); + const config = DIFFICULTIES[difficulty]; + + 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) => Math.min(current + 1, config.maxSeconds)); + }, 1000); + return () => window.clearInterval(timer); + }, [config.maxSeconds, phase]); + + useEffect(() => { + return () => { + if (longPressTimerRef.current !== null) window.clearTimeout(longPressTimerRef.current); + }; + }, []); + + const reset = useCallback( + (nextDifficulty: EodDifficulty = difficulty) => { + if (submittingRef.current) return; + const nextConfig = DIFFICULTIES[nextDifficulty]; + setDifficulty(nextDifficulty); + setBoard(emptyBoard(nextConfig.rows * nextConfig.cols)); + generatedRef.current = false; + setSeconds(0); + setLastRun(null); + setPhaseSafe("ready"); + }, + [difficulty, setPhaseSafe], + ); + + const recordResult = useCallback( + async (won: boolean, elapsedSeconds: number) => { + if (submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + const resultSeconds = Math.max(1, Math.min(elapsedSeconds, config.maxSeconds)); + const result = await submitEodResult({ difficulty, seconds: resultSeconds, won }); + submittingRef.current = false; + setSubmitting(false); + const score = won ? config.baseScore + Math.max(0, config.parSeconds - resultSeconds) : 0; + if (result.success && result.data) { + statsRef.current = result.data; + setStats(result.data); + onGameRecorded(); + if (result.data.xpEarned > 0) { + playXp(); + window.dispatchEvent( + new CustomEvent(PROFILE_UPDATE_EVENT, { + detail: { reason: "xp", xpGained: result.data.xpEarned }, + }), + ); + window.setTimeout(() => router.refresh(), 1700); + } + setLastRun({ + won, + seconds: resultSeconds, + score, + xpEarned: result.data.xpEarned, + best: result.data.bestScore, + }); + } else { + setLastRun({ + won, + seconds: resultSeconds, + score, + xpEarned: 0, + best: statsRef.current.bestScore, + error: result.error ?? "Could not record clearance.", + }); + } + }, + [config.baseScore, config.maxSeconds, config.parSeconds, difficulty, onGameRecorded, router], + ); + + const finishGame = useCallback( + (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 } + : cell, + ), + ); + if (won) playClear(); + else playExplosion(); + void recordResult(won, seconds); + }, + [phase, recordResult, seconds, setPhaseSafe], + ); + + const reveal = useCallback( + (index: number) => { + if (phase === "over" || submittingRef.current) return; + const existing = board[index]; + if (!existing || existing.flagged || existing.revealed) return; + const workingBoard = generatedRef.current ? board : createBoard(config, index); + generatedRef.current = true; + const cell = workingBoard[index]; + if (!cell) return; + if (phase === "ready") setPhaseSafe("playing"); + if (cell.mine) { + finishGame( + false, + workingBoard.map((item, cellIndex) => + cellIndex === index ? { ...item, exploded: true } : item, + ), + ); + return; + } + playReveal(); + const nextBoard = revealFrom(index, workingBoard, config.rows, config.cols); + setBoard(nextBoard); + if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard); + }, + [board, config, finishGame, phase, setPhaseSafe], + ); + + const toggleFlag = useCallback( + (index: number) => { + if (phase === "over" || submittingRef.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, phase], + ); + + const clearLongPress = useCallback(() => { + if (longPressTimerRef.current !== null) { + window.clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 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 ( +
+
+
+
+
+ + Clearance console +
+

+ Mark suspected charges, then sweep every safe tile. +

+
+
+ + + {config.mines - flaggedCount} + + + + {seconds}s + +
+
+ + {phase !== "playing" && ( +
+ {DIFFICULTY_KEYS.map((key) => { + const option = DIFFICULTIES[key]; + return ( + + ); + })} +
+ )} + +
+
+ {phase === "ready" && Awaiting deployment — select a sector.} + {phase === "playing" && Live sweep — mark every charge before you reveal.} + {phase === "over" && lastRun?.won && ( + + Sector clear. {config.label} defused in {lastRun.seconds}s. + + )} + {phase === "over" && !lastRun?.won && lastRun && The charge went off. MIA.} + {phase === "over" && !lastRun && Recording clearance…} + {lastRun?.won && ( + + Score {lastRun.score} · +{lastRun.xpEarned} XP earned. + + )} + {lastRun?.error && {lastRun.error}} +
+ {phase === "over" && ( + + )} +
+ +
+
+ {board.map((cell, index) => { + const row = Math.floor(index / config.cols); + const col = index % config.cols; + const revealedMine = cell.revealed && cell.mine; + return ( + + ); + })} +
+
+ +

+ Left click or tap to reveal · right click,{" "} + F, or long-press to flag +

+
+ +
+
+ Games + {stats.gamesPlayed} +
+
+ Wins + {stats.wins} +
+
+ Best score + {stats.bestScore} +
+
+ XP from clearance + {stats.eodXp} +
+
+
+ ); +} diff --git a/src/components/frontend/eod/sounds.ts b/src/components/frontend/eod/sounds.ts new file mode 100644 index 0000000..ff259e1 --- /dev/null +++ b/src/components/frontend/eod/sounds.ts @@ -0,0 +1,103 @@ +let audioCtx: AudioContext | null = null; + +function getCtx(): AudioContext | null { + if (typeof window === "undefined") return null; + const AudioCtor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioCtor) return null; + if (!audioCtx) audioCtx = new AudioCtor(); + if (audioCtx.state === "suspended") { + void audioCtx.resume(); + } + return audioCtx; +} + +interface ToneOptions { + frequency: number; + endFrequency?: number; + duration: number; + type?: OscillatorType; + volume?: number; + delay?: number; +} + +function playTone(ctx: AudioContext, opts: ToneOptions) { + const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = opts; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t0 = ctx.currentTime + delay; + osc.type = type; + osc.frequency.setValueAtTime(Math.max(frequency, 1), t0); + if (endFrequency && endFrequency !== frequency) { + osc.frequency.exponentialRampToValueAtTime(Math.max(endFrequency, 1), t0 + duration); + } + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t0); + osc.stop(t0 + duration + 0.05); +} + +function playNoise( + ctx: AudioContext, + opts: { duration: number; volume?: number; delay?: number; lowpass?: number }, +) { + const { duration, volume = 0.2, delay = 0, lowpass = 800 } = opts; + const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration)); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(lowpass, ctx.currentTime + delay); + const gain = ctx.createGain(); + const t0 = ctx.currentTime + delay; + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); + source.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + source.start(t0); +} + +export function playReveal() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 400, duration: 0.05, type: "sine", volume: 0.08 }); +} + +export function playFlag() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 300, duration: 0.07, type: "triangle", volume: 0.12 }); +} + +export function playExplosion() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 120, endFrequency: 30, duration: 0.35, type: "square", volume: 0.28 }); + playNoise(ctx, { duration: 0.28, volume: 0.35, lowpass: 900 }); +} + +export function playClear() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 523, duration: 0.1, type: "sine", volume: 0.16 }); + playTone(ctx, { frequency: 659, duration: 0.1, type: "sine", volume: 0.16, delay: 0.1 }); + playTone(ctx, { frequency: 784, duration: 0.18, type: "sine", volume: 0.18, delay: 0.2 }); +} + +export function playXp() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 }); + playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 }); +}