Compare commits
3 commits
3112b72972
...
2922df23dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 2922df23dd | |||
| 3e82ef4ed9 | |||
| 512cdd62ad |
13 changed files with 20349 additions and 13 deletions
212
src/app/(frontend)/eod/actions.ts
Normal file
212
src/app/(frontend)/eod/actions.ts
Normal file
|
|
@ -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<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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/(frontend)/eod/page.tsx
Normal file
62
src/app/(frontend)/eod/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -107,11 +107,17 @@ 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;
|
| {
|
||||||
} | number>;
|
award?: number | Award;
|
||||||
|
}
|
||||||
|
| number
|
||||||
|
>;
|
||||||
scoreboard?: Record<string, number | null>;
|
scoreboard?: Record<string, number | null>;
|
||||||
minigames?: { flappyBird?: { xpEarned?: number | null } };
|
minigames?: {
|
||||||
|
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) => {
|
||||||
|
|
@ -122,6 +128,7 @@ 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
|
||||||
|
|
@ -159,6 +166,8 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
@ -175,7 +184,7 @@ export const Profiles: CollectionConfig = {
|
||||||
relationTo: "awards",
|
relationTo: "awards",
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
appearance: "drawer"
|
appearance: "drawer",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -186,7 +195,8 @@ 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: 'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."',
|
description:
|
||||||
|
'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -285,6 +295,60 @@ 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -107,6 +107,12 @@ 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>
|
||||||
|
|
|
||||||
37
src/components/frontend/eod/EodArena.tsx
Normal file
37
src/components/frontend/eod/EodArena.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/frontend/eod/EodLeaderboard.tsx
Normal file
215
src/components/frontend/eod/EodLeaderboard.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
519
src/components/frontend/eod/MinesweeperBoard.tsx
Normal file
519
src/components/frontend/eod/MinesweeperBoard.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/frontend/eod/sounds.ts
Normal file
103
src/components/frontend/eod/sounds.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
19058
src/migrations/20260827_005757.json
Normal file
19058
src/migrations/20260827_005757.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260827_005757.ts
Normal file
25
src/migrations/20260827_005757.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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";`)
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,33 @@
|
||||||
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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1677,6 +1677,16 @@ 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;
|
||||||
|
|
@ -3456,6 +3466,18 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Minigames
|
// Minigames
|
||||||
MinigameFlappy: "minigame:flappy",
|
MinigameFlappy: "minigame:flappy",
|
||||||
|
MinigameEod: "minigame:eod",
|
||||||
|
|
||||||
// Market
|
// Market
|
||||||
MarketListingCreate: "market:listing-create",
|
MarketListingCreate: "market:listing-create",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue