1
0
Fork 0

Compare commits

..

No commits in common. "2922df23ddfe02510e64f89079c47c5b4420403b" and "3112b72972232dfe51896d4697b90a1058141628" have entirely different histories.

13 changed files with 13 additions and 20349 deletions

View file

@ -1,212 +0,0 @@
"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<T = undefined> {
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<ActionResult<EodStats>> {
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 };
}
}

View file

@ -1,62 +0,0 @@
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 (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Bomb className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Minefield Clearance</h1>
</div>
<p className="text-sm text-muted-foreground">
Clear the minefield to earn XP. Faster clears score higher.
</p>
</div>
<EodArena initialStats={stats} currentUserId={user.id as number} />
</div>
);
}

View file

@ -107,17 +107,11 @@ export const Profiles: CollectionConfig = {
let xpAcc = 0; let xpAcc = 0;
const progressionData = siblingData as { const progressionData = siblingData as {
awards?: Array< awards?: Array<{
| {
award?: number | Award; award?: number | Award;
} } | number>;
| number
>;
scoreboard?: Record<string, number | null>; scoreboard?: Record<string, number | null>;
minigames?: { minigames?: { flappyBird?: { xpEarned?: number | null } };
flappyBird?: { xpEarned?: number | null };
eodMinesweeper?: { xpEarned?: number | null };
};
}; };
const awardsList = progressionData?.awards || []; const awardsList = progressionData?.awards || [];
const awardIds = awardsList.flatMap((entry) => { const awardIds = awardsList.flatMap((entry) => {
@ -128,7 +122,6 @@ export const Profiles: CollectionConfig = {
}); });
const scoreboard = progressionData?.scoreboard || {}; const scoreboard = progressionData?.scoreboard || {};
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0; const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
if (awardIds.length > 0) { if (awardIds.length > 0) {
await req.payload await req.payload
@ -166,8 +159,6 @@ export const Profiles: CollectionConfig = {
// Flappy Bird training sim: 1 XP per 15 pipes passed in a single run // Flappy Bird training sim: 1 XP per 15 pipes passed in a single run
xpAcc += flappyXp; xpAcc += flappyXp;
// EOD Minesweeper: XP from cleared minefields
xpAcc += eodXp;
return xpAcc; return xpAcc;
}, },
@ -184,7 +175,7 @@ export const Profiles: CollectionConfig = {
relationTo: "awards", relationTo: "awards",
required: true, required: true,
admin: { admin: {
appearance: "drawer", appearance: "drawer"
}, },
}, },
{ {
@ -195,8 +186,7 @@ export const Profiles: CollectionConfig = {
// issuer's progression/awards when a profile is read. // issuer's progression/awards when a profile is read.
maxDepth: 0, maxDepth: 0,
admin: { admin: {
description: description: 'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."',
'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."',
}, },
}, },
{ {
@ -295,60 +285,6 @@ export const Profiles: CollectionConfig = {
}, },
], ],
}, },
{
name: "eodMinesweeper",
label: "EOD Minesweeper",
type: "group",
fields: [
{
name: "gamesPlayed",
type: "number",
defaultValue: 0,
},
{
name: "wins",
label: "Wins",
type: "number",
defaultValue: 0,
},
{
name: "losses",
label: "Losses",
type: "number",
defaultValue: 0,
},
{
name: "bestScore",
label: "Best Score",
type: "number",
defaultValue: 0,
},
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
{
name: "bestTrainingTime",
label: "Best Training Pad Time (s)",
type: "number",
defaultValue: 0,
},
{
name: "bestPerimeterTime",
label: "Best FOB Perimeter Time (s)",
type: "number",
defaultValue: 0,
},
{
name: "bestSupplyRoadTime",
label: "Best Main Supply Road Time (s)",
type: "number",
defaultValue: 0,
},
],
},
], ],
}, },
], ],

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { BadgeCheck, Bird, Bomb, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react"; import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@ -107,12 +107,6 @@ export function NavUser({
Flight Simulator Flight Simulator
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/eod">
<Bomb />
Minefield Clearance
</Link>
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>

View file

@ -1,37 +0,0 @@
"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 (
<div className="flex flex-col items-center gap-6">
<MinesweeperBoard
initialStats={initialStats}
onGameRecorded={() => setRefreshKey((key) => key + 1)}
onPhaseChange={onPhaseChange}
/>
<EodLeaderboard
refreshKey={refreshKey}
currentUserId={currentUserId}
activeTeams={activeTeams}
/>
</div>
);
}

View file

@ -1,215 +0,0 @@
"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<LeaderboardRow[] | null>(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 (
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Trophy className="size-4 text-amber-400" />
<h2 className="text-sm font-semibold">Minefield Clearance Leaderboard</h2>
<span className="ml-auto text-xs text-muted-foreground">Top 10 · best clearance</span>
</div>
<div
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
aria-live="polite"
>
<span className="font-medium text-emerald-400">{activeTeams.length} defusal teams on site</span>
{activeTeams.length > 0 && (
<span className="ml-1 text-foreground">
({activeTeams.map((team) => team.name).join(", ")})
</span>
)}
</div>
{failed ? (
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
) : rows === null ? (
<div className="space-y-2 px-4 py-4">
<div className="h-4 w-full animate-pulse rounded bg-border" />
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
</div>
) : rows.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">
No clearances logged yet — take the first sector!
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2 font-medium">#</th>
<th className="px-2 py-2 font-medium">Operator</th>
<th className="px-2 py-2 text-right font-medium">Games</th>
<th className="px-2 py-2 text-right font-medium">Wins</th>
<th className="px-2 py-2 text-right font-medium">Best</th>
<th className="px-4 py-2 text-right font-medium">XP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row, index) => (
<tr
key={row.id}
className={cn(
row.isCurrentUser && "bg-accent/40",
row.isOutsideTopTen && "text-muted-foreground opacity-70",
)}
title={
row.isOutsideTopTen
? "Operator status — not currently ranked in the top 10"
: undefined
}
>
<td className="px-4 py-2">
<span className={rankClass(index)}>
{row.isOutsideTopTen ? "—" : index + 1}
</span>
</td>
<td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2">
<span className="max-w-[180px] truncate">{row.name}</span>
{row.isCurrentUser && (
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
)}
{row.isOutsideTopTen && (
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
unranked
</span>
)}
{activeTeams.some((team) => team.id === row.id) && (
<span
className="size-2 rounded-full bg-emerald-400"
title="Clearing now"
aria-label="Clearing now"
/>
)}
</span>
</td>
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
{row.gamesPlayed}
</td>
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
{row.wins}
</td>
<td className="px-2 py-2 text-right font-semibold tabular-nums">
{row.bestScore}
</td>
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
{row.xpEarned}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,519 +0,0 @@
"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<Record<number, string>> = {
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<number>();
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<number>();
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<EodDifficulty>("training");
const [phase, setPhase] = useState<Phase>("ready");
const [board, setBoard] = useState<Board>(() => emptyBoard(81));
const [seconds, setSeconds] = useState(0);
const [stats, setStats] = useState<EodStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [submitting, setSubmitting] = useState(false);
const generatedRef = useRef(false);
const submittingRef = useRef(false);
const statsRef = useRef<EodStats>(serverStats);
const longPressRef = useRef(false);
const longPressTimerRef = useRef<number | null>(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 (
<div className="flex w-full max-w-5xl flex-col gap-4">
<div className="flex flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-emerald-500" />
Clearance console
</div>
<p className="text-xs text-muted-foreground">
Mark suspected charges, then sweep every safe tile.
</p>
</div>
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
<span
className="flex items-center gap-1.5"
aria-label={`${config.mines - flaggedCount} mines remaining`}
>
<Flag className="size-4 text-amber-400" />
{config.mines - flaggedCount}
</span>
<span className="flex items-center gap-1.5" aria-label={`${seconds} seconds elapsed`}>
<Timer className="size-4 text-muted-foreground" />
{seconds}s
</span>
</div>
</div>
{phase !== "playing" && (
<div className="grid gap-2 md:grid-cols-3">
{DIFFICULTY_KEYS.map((key) => {
const option = DIFFICULTIES[key];
return (
<Button
key={key}
type="button"
variant={difficulty === key ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting}
aria-pressed={difficulty === key}
onClick={() => reset(key)}
>
<span className="flex flex-col items-start gap-0.5">
<span>{option.label}</span>
<span className="text-[10px] font-normal opacity-75">
{option.rows}×{option.cols} grid · {option.mines} charges
</span>
</span>
</Button>
);
})}
</div>
)}
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm",
statusClass,
)}
aria-live="polite"
>
<div className="flex flex-col gap-0.5">
{phase === "ready" && <span>Awaiting deployment — select a sector.</span>}
{phase === "playing" && <span>Live sweep — mark every charge before you reveal.</span>}
{phase === "over" && lastRun?.won && (
<span>
Sector clear. {config.label} defused in {lastRun.seconds}s.
</span>
)}
{phase === "over" && !lastRun?.won && lastRun && <span>The charge went off. MIA.</span>}
{phase === "over" && !lastRun && <span>Recording clearance…</span>}
{lastRun?.won && (
<span className="text-xs text-emerald-500">
Score {lastRun.score} · +{lastRun.xpEarned} XP earned.
</span>
)}
{lastRun?.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
</div>
{phase === "over" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={submitting}
onClick={() => reset()}
>
<RotateCcw /> Retry sector
</Button>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
<div
className="grid min-w-max gap-1"
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
role="grid"
aria-label={`${config.label} minefield`}
>
{board.map((cell, index) => {
const row = Math.floor(index / config.cols);
const col = index % config.cols;
const revealedMine = cell.revealed && cell.mine;
return (
<button
key={`${difficulty}-${index}`}
type="button"
role="gridcell"
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed && !cell.mine && "border-border/60 bg-muted/70",
!cell.revealed && "border-border bg-card hover:bg-accent",
cell.flagged &&
!cell.revealed &&
"border-amber-500/50 bg-amber-500/10 text-amber-400",
revealedMine && "border-destructive/60 bg-destructive/20 text-destructive",
cell.exploded &&
"border-destructive bg-destructive text-destructive-foreground",
cell.revealed && cell.adjacent > 0 && NUMBER_CLASSES[cell.adjacent],
)}
onClick={() => {
if (longPressRef.current) {
longPressRef.current = false;
return;
}
reveal(index);
}}
onContextMenu={(event) => {
event.preventDefault();
toggleFlag(index);
}}
onKeyDown={(event) => {
if (event.key.toLowerCase() === "f") {
event.preventDefault();
toggleFlag(index);
}
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" || phase === "over") return;
longPressRef.current = false;
clearLongPress();
longPressTimerRef.current = window.setTimeout(() => {
longPressRef.current = true;
toggleFlag(index);
}, 350);
}}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
>
{cell.flagged && !revealedMine ? <Flag className="size-3.5" /> : null}
{revealedMine ? <Bomb className="size-3.5" /> : null}
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
</button>
);
})}
</div>
</div>
<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
</p>
</div>
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<span>Games</span>
<span className="font-semibold text-foreground">{stats.gamesPlayed}</span>
</div>
<div className="flex items-center gap-1.5">
<span>Wins</span>
<span className="font-semibold text-foreground">{stats.wins}</span>
</div>
<div className="flex items-center gap-1.5">
<span>Best score</span>
<span className="font-semibold text-foreground">{stats.bestScore}</span>
</div>
<div className="flex items-center gap-1.5">
<span>XP from clearance</span>
<span className="font-semibold text-foreground">{stats.eodXp}</span>
</div>
</div>
</div>
);
}

View file

@ -1,103 +0,0 @@
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 });
}

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_games_played" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_wins" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_losses" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_xp_earned" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_training_time" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_perimeter_time" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_supply_road_time" numeric DEFAULT 0;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_games_played";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_wins";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_losses";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_xp_earned";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_training_time";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_perimeter_time";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_supply_road_time";`)
}

View file

@ -1,33 +1,21 @@
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from './20260825_220000'; import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables'; import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: '20260820_004157', name: "20260820_004157",
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: '20260825_220000', name: "20260825_220000",
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables', name: "20260826_050000_repair_shims_auxiliary_tables",
},
{
up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target',
},
{
up: migration_20260827_005757.up,
down: migration_20260827_005757.down,
name: '20260827_005757'
}, },
]; ];

View file

@ -1677,16 +1677,6 @@ export interface Profile {
bestScore?: number | null; bestScore?: number | null;
xpEarned?: number | null; xpEarned?: number | null;
}; };
eodMinesweeper?: {
gamesPlayed?: number | null;
wins?: number | null;
losses?: number | null;
bestScore?: number | null;
xpEarned?: number | null;
bestTrainingTime?: number | null;
bestPerimeterTime?: number | null;
bestSupplyRoadTime?: number | null;
};
}; };
}; };
updatedAt: string; updatedAt: string;
@ -3466,18 +3456,6 @@ export interface ProfilesSelect<T extends boolean = true> {
bestScore?: T; bestScore?: T;
xpEarned?: T; xpEarned?: T;
}; };
eodMinesweeper?:
| T
| {
gamesPlayed?: T;
wins?: T;
losses?: T;
bestScore?: T;
xpEarned?: T;
bestTrainingTime?: T;
bestPerimeterTime?: T;
bestSupplyRoadTime?: T;
};
}; };
}; };
updatedAt?: T; updatedAt?: T;

View file

@ -62,7 +62,6 @@ export const EventTypes = {
// Minigames // Minigames
MinigameFlappy: "minigame:flappy", MinigameFlappy: "minigame:flappy",
MinigameEod: "minigame:eod",
// Market // Market
MarketListingCreate: "market:listing-create", MarketListingCreate: "market:listing-create",