feat(eod): add high-contrast toggle, improved leaderboard, new effects on bad-flag cells
This commit is contained in:
parent
b99520bc64
commit
a247e61fa9
8 changed files with 19274 additions and 26 deletions
|
|
@ -59,7 +59,14 @@ const BEST_TIME_FIELDS = {
|
||||||
supplyRoad: "bestSupplyRoadTime",
|
supplyRoad: "bestSupplyRoadTime",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const BEST_FLAGS_FIELDS = {
|
||||||
|
training: "bestTrainingFlags",
|
||||||
|
perimeter: "bestPerimeterFlags",
|
||||||
|
supplyRoad: "bestSupplyRoadFlags",
|
||||||
|
} as const;
|
||||||
|
|
||||||
type EodTimeField = (typeof BEST_TIME_FIELDS)[EodDifficulty];
|
type EodTimeField = (typeof BEST_TIME_FIELDS)[EodDifficulty];
|
||||||
|
type EodFlagsField = (typeof BEST_FLAGS_FIELDS)[EodDifficulty];
|
||||||
|
|
||||||
function isEodDifficulty(value: string): value is EodDifficulty {
|
function isEodDifficulty(value: string): value is EodDifficulty {
|
||||||
return Object.prototype.hasOwnProperty.call(DIFFICULTIES, value);
|
return Object.prototype.hasOwnProperty.call(DIFFICULTIES, value);
|
||||||
|
|
@ -85,6 +92,7 @@ async function authenticate() {
|
||||||
export async function submitEodResult(input: {
|
export async function submitEodResult(input: {
|
||||||
difficulty: EodDifficulty;
|
difficulty: EodDifficulty;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
|
flags: number;
|
||||||
won: boolean;
|
won: boolean;
|
||||||
}): Promise<ActionResult<EodStats>> {
|
}): Promise<ActionResult<EodStats>> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -104,6 +112,9 @@ export async function submitEodResult(input: {
|
||||||
) {
|
) {
|
||||||
return { success: false, error: "Invalid result." };
|
return { success: false, error: "Invalid result." };
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(input.flags) || input.flags < 0 || input.flags > difficultyConfig.mines) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
const profileRes = await payload.find({
|
const profileRes = await payload.find({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
|
|
@ -129,6 +140,9 @@ export async function submitEodResult(input: {
|
||||||
bestTrainingTime?: number;
|
bestTrainingTime?: number;
|
||||||
bestPerimeterTime?: number;
|
bestPerimeterTime?: number;
|
||||||
bestSupplyRoadTime?: number;
|
bestSupplyRoadTime?: number;
|
||||||
|
bestTrainingFlags?: number;
|
||||||
|
bestPerimeterFlags?: number;
|
||||||
|
bestSupplyRoadFlags?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -143,11 +157,16 @@ export async function submitEodResult(input: {
|
||||||
const bestScore = Math.max(current.bestScore ?? 0, score);
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
const eodXp = (current.xpEarned ?? 0) + runXp;
|
const eodXp = (current.xpEarned ?? 0) + runXp;
|
||||||
const bestTimeField: EodTimeField = BEST_TIME_FIELDS[difficulty];
|
const bestTimeField: EodTimeField = BEST_TIME_FIELDS[difficulty];
|
||||||
|
const bestFlagsField: EodFlagsField = BEST_FLAGS_FIELDS[difficulty];
|
||||||
const previousBestTime = current[bestTimeField] ?? 0;
|
const previousBestTime = current[bestTimeField] ?? 0;
|
||||||
const bestTime =
|
const previousBestFlags = current[bestFlagsField] ?? 0;
|
||||||
input.won && (previousBestTime === 0 || input.seconds < previousBestTime)
|
const isBestRun =
|
||||||
? input.seconds
|
input.won &&
|
||||||
: previousBestTime;
|
(previousBestTime === 0 ||
|
||||||
|
input.seconds < previousBestTime ||
|
||||||
|
(input.seconds === previousBestTime && input.flags < previousBestFlags));
|
||||||
|
const bestTime = isBestRun ? input.seconds : previousBestTime;
|
||||||
|
const bestFlags = isBestRun ? input.flags : previousBestFlags;
|
||||||
|
|
||||||
const { xp: _xp, ...progressionRest } = progression;
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
|
@ -168,7 +187,11 @@ export async function submitEodResult(input: {
|
||||||
bestTrainingTime: current.bestTrainingTime ?? 0,
|
bestTrainingTime: current.bestTrainingTime ?? 0,
|
||||||
bestPerimeterTime: current.bestPerimeterTime ?? 0,
|
bestPerimeterTime: current.bestPerimeterTime ?? 0,
|
||||||
bestSupplyRoadTime: current.bestSupplyRoadTime ?? 0,
|
bestSupplyRoadTime: current.bestSupplyRoadTime ?? 0,
|
||||||
|
bestTrainingFlags: current.bestTrainingFlags ?? 0,
|
||||||
|
bestPerimeterFlags: current.bestPerimeterFlags ?? 0,
|
||||||
|
bestSupplyRoadFlags: current.bestSupplyRoadFlags ?? 0,
|
||||||
[bestTimeField]: bestTime,
|
[bestTimeField]: bestTime,
|
||||||
|
[bestFlagsField]: bestFlags,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -192,6 +215,7 @@ export async function submitEodResult(input: {
|
||||||
data: {
|
data: {
|
||||||
difficulty,
|
difficulty,
|
||||||
seconds: input.seconds,
|
seconds: input.seconds,
|
||||||
|
flags: input.flags,
|
||||||
score,
|
score,
|
||||||
xpEarned: runXp,
|
xpEarned: runXp,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,24 @@ export const Profiles: CollectionConfig = {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bestTrainingFlags",
|
||||||
|
label: "Flags on Best Training Pad Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestPerimeterFlags",
|
||||||
|
label: "Flags on Best FOB Perimeter Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestSupplyRoadFlags",
|
||||||
|
label: "Flags on Best Main Supply Road Run",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trophy } from "lucide-react";
|
import { Flag, Trophy } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
|
||||||
|
|
@ -23,6 +30,12 @@ interface ApiProfile {
|
||||||
wins?: number | null;
|
wins?: number | null;
|
||||||
bestScore?: number | null;
|
bestScore?: number | null;
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
|
bestTrainingTime?: number | null;
|
||||||
|
bestPerimeterTime?: number | null;
|
||||||
|
bestSupplyRoadTime?: number | null;
|
||||||
|
bestTrainingFlags?: number | null;
|
||||||
|
bestPerimeterFlags?: number | null;
|
||||||
|
bestSupplyRoadFlags?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
@ -35,6 +48,8 @@ interface LeaderboardRow {
|
||||||
wins: number;
|
wins: number;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
xpEarned: number;
|
xpEarned: number;
|
||||||
|
bestTime: number;
|
||||||
|
bestFlags: number;
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
isOutsideTopTen?: boolean;
|
isOutsideTopTen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +72,7 @@ export function EodLeaderboard({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
activeTeams = [],
|
activeTeams = [],
|
||||||
}: EodLeaderboardProps) {
|
}: EodLeaderboardProps) {
|
||||||
|
const [difficulty, setDifficulty] = useState<"training" | "perimeter" | "supplyRoad">("training");
|
||||||
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
|
@ -64,11 +80,20 @@ export function EodLeaderboard({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
|
||||||
|
const difficultyLabel =
|
||||||
|
difficulty === "training"
|
||||||
|
? "Training"
|
||||||
|
: difficulty === "perimeter"
|
||||||
|
? "Perimeter"
|
||||||
|
: "SupplyRoad";
|
||||||
|
const timeField = `progression.minigames.eodMinesweeper.best${difficultyLabel}Time`;
|
||||||
|
const flagsField = `progression.minigames.eodMinesweeper.best${difficultyLabel}Flags`;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
sort: "-progression.minigames.eodMinesweeper.bestScore",
|
sort: `${timeField},${flagsField}`,
|
||||||
limit: "100",
|
limit: "100",
|
||||||
depth: "1",
|
depth: "1",
|
||||||
});
|
});
|
||||||
|
params.set(`where[${timeField}][greater_than]`, "0");
|
||||||
|
|
||||||
fetch(`/api/profiles?${params.toString()}`)
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
@ -89,10 +114,14 @@ export function EodLeaderboard({
|
||||||
wins: eod.wins ?? 0,
|
wins: eod.wins ?? 0,
|
||||||
bestScore: eod.bestScore ?? 0,
|
bestScore: eod.bestScore ?? 0,
|
||||||
xpEarned: eod.xpEarned ?? 0,
|
xpEarned: eod.xpEarned ?? 0,
|
||||||
|
bestTime: eod[`best${difficultyLabel}Time` as keyof typeof eod] ?? 0,
|
||||||
|
bestFlags: eod[`best${difficultyLabel}Flags` as keyof typeof eod] ?? 0,
|
||||||
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const scored = mapped.filter((row) => row.bestScore > 0);
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestTime > 0)
|
||||||
|
.sort((a, b) => a.bestTime - b.bestTime || a.bestFlags - b.bestFlags);
|
||||||
const visible = scored.slice(0, 10);
|
const visible = scored.slice(0, 10);
|
||||||
const current = mapped.find((row) => row.isCurrentUser);
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
if (current && !visible.some((row) => row.id === current.id)) {
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
|
@ -107,20 +136,37 @@ export function EodLeaderboard({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [refreshKey, currentUserId]);
|
}, [difficulty, refreshKey, currentUserId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
<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">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
<Trophy className="size-4 text-amber-400" />
|
<Trophy className="size-4 text-amber-400" />
|
||||||
<h2 className="text-sm font-semibold">Minefield Clearance Leaderboard</h2>
|
<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 className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => setDifficulty(value as typeof difficulty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Leaderboard sector" className="h-8 w-[150px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="training">Training Pad</SelectItem>
|
||||||
|
<SelectItem value="perimeter">FOB Perimeter</SelectItem>
|
||||||
|
<SelectItem value="supplyRoad">Main Supply Road</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · fastest clear</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-emerald-400">{activeTeams.length} defusal teams on site</span>
|
<span className="font-medium text-emerald-400">
|
||||||
|
{activeTeams.length} defusal teams on site
|
||||||
|
</span>
|
||||||
{activeTeams.length > 0 && (
|
{activeTeams.length > 0 && (
|
||||||
<span className="ml-1 text-foreground">
|
<span className="ml-1 text-foreground">
|
||||||
({activeTeams.map((team) => team.name).join(", ")})
|
({activeTeams.map((team) => team.name).join(", ")})
|
||||||
|
|
@ -149,7 +195,10 @@ export function EodLeaderboard({
|
||||||
<th className="px-2 py-2 font-medium">Operator</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">Games</th>
|
||||||
<th className="px-2 py-2 text-right font-medium">Wins</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-2 py-2 text-right font-medium">Time</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">
|
||||||
|
<Flag className="ml-auto size-3" aria-label="Flags" />
|
||||||
|
</th>
|
||||||
<th className="px-4 py-2 text-right font-medium">XP</th>
|
<th className="px-4 py-2 text-right font-medium">XP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -199,7 +248,10 @@ export function EodLeaderboard({
|
||||||
{row.wins}
|
{row.wins}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
{row.bestScore}
|
{row.bestTime}s
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{row.bestFlags}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
{row.xpEarned}
|
{row.xpEarned}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
|
// 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 { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||||
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer } from "lucide-react";
|
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
||||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -19,6 +20,7 @@ type Cell = {
|
||||||
readonly revealed: boolean;
|
readonly revealed: boolean;
|
||||||
readonly flagged: boolean;
|
readonly flagged: boolean;
|
||||||
readonly exploded: boolean;
|
readonly exploded: boolean;
|
||||||
|
readonly incorrectFlagged: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Board = readonly Cell[];
|
type Board = readonly Cell[];
|
||||||
|
|
@ -59,7 +61,7 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
|
||||||
2: "text-green-400",
|
2: "text-green-400",
|
||||||
3: "text-red-400",
|
3: "text-red-400",
|
||||||
4: "text-purple-400",
|
4: "text-purple-400",
|
||||||
5: "text-rose-700",
|
5: "text-rose-400",
|
||||||
6: "text-teal-400",
|
6: "text-teal-400",
|
||||||
7: "text-slate-200",
|
7: "text-slate-200",
|
||||||
8: "text-gray-400",
|
8: "text-gray-400",
|
||||||
|
|
@ -72,6 +74,7 @@ function emptyBoard(size: number): Board {
|
||||||
revealed: false,
|
revealed: false,
|
||||||
flagged: false,
|
flagged: false,
|
||||||
exploded: false,
|
exploded: false,
|
||||||
|
incorrectFlagged: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +129,7 @@ function revealFrom(start: number, board: Board, rows: number, cols: number): Bo
|
||||||
|
|
||||||
function cellLabel(cell: Cell, row: number, col: number): string {
|
function cellLabel(cell: Cell, row: number, col: number): string {
|
||||||
const position = `row ${row + 1} col ${col + 1}`;
|
const position = `row ${row + 1} col ${col + 1}`;
|
||||||
|
if (cell.incorrectFlagged) return `${position} incorrect flag`;
|
||||||
if (cell.flagged) return `${position} flagged`;
|
if (cell.flagged) return `${position} flagged`;
|
||||||
if (!cell.revealed) return `${position} hidden`;
|
if (!cell.revealed) return `${position} hidden`;
|
||||||
if (cell.mine) return `${position} mine`;
|
if (cell.mine) return `${position} mine`;
|
||||||
|
|
@ -156,6 +160,7 @@ export function MinesweeperBoard({
|
||||||
const [phase, setPhase] = useState<Phase>("ready");
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
const [board, setBoard] = useState<Board>(() => emptyBoard(81));
|
const [board, setBoard] = useState<Board>(() => emptyBoard(81));
|
||||||
const [seconds, setSeconds] = useState(0);
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const [highContrast, setHighContrast] = useState(true);
|
||||||
const [stats, setStats] = useState<EodStats>(serverStats);
|
const [stats, setStats] = useState<EodStats>(serverStats);
|
||||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -165,6 +170,7 @@ export function MinesweeperBoard({
|
||||||
const longPressRef = useRef(false);
|
const longPressRef = useRef(false);
|
||||||
const longPressTimerRef = useRef<number | null>(null);
|
const longPressTimerRef = useRef<number | null>(null);
|
||||||
const config = DIFFICULTIES[difficulty];
|
const config = DIFFICULTIES[difficulty];
|
||||||
|
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
||||||
|
|
||||||
const setPhaseSafe = useCallback(
|
const setPhaseSafe = useCallback(
|
||||||
(nextPhase: Phase) => {
|
(nextPhase: Phase) => {
|
||||||
|
|
@ -207,12 +213,17 @@ export function MinesweeperBoard({
|
||||||
);
|
);
|
||||||
|
|
||||||
const recordResult = useCallback(
|
const recordResult = useCallback(
|
||||||
async (won: boolean, elapsedSeconds: number) => {
|
async (won: boolean, elapsedSeconds: number, flags: number) => {
|
||||||
if (submittingRef.current) return;
|
if (submittingRef.current) return;
|
||||||
submittingRef.current = true;
|
submittingRef.current = true;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const resultSeconds = Math.max(1, Math.min(elapsedSeconds, config.maxSeconds));
|
const resultSeconds = Math.max(1, Math.min(elapsedSeconds, config.maxSeconds));
|
||||||
const result = await submitEodResult({ difficulty, seconds: resultSeconds, won });
|
const result = await submitEodResult({
|
||||||
|
difficulty,
|
||||||
|
seconds: resultSeconds,
|
||||||
|
flags,
|
||||||
|
won,
|
||||||
|
});
|
||||||
submittingRef.current = false;
|
submittingRef.current = false;
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
const score = won ? config.baseScore + Math.max(0, config.parSeconds - resultSeconds) : 0;
|
const score = won ? config.baseScore + Math.max(0, config.parSeconds - resultSeconds) : 0;
|
||||||
|
|
@ -262,12 +273,13 @@ export function MinesweeperBoard({
|
||||||
: cell
|
: cell
|
||||||
: cell.mine
|
: cell.mine
|
||||||
? { ...cell, revealed: true }
|
? { ...cell, revealed: true }
|
||||||
: cell,
|
: { ...cell, incorrectFlagged: cell.flagged },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (won) playClear();
|
if (won) playClear();
|
||||||
else playExplosion();
|
else playExplosion();
|
||||||
void recordResult(won, seconds);
|
const flags = finalBoard.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
||||||
|
void recordResult(won, seconds, flags);
|
||||||
},
|
},
|
||||||
[phase, recordResult, seconds, setPhaseSafe],
|
[phase, recordResult, seconds, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
@ -321,7 +333,10 @@ export function MinesweeperBoard({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
|
const incorrectFlagCount = board.reduce(
|
||||||
|
(count, cell) => count + (cell.incorrectFlagged ? 1 : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const statusClass =
|
const statusClass =
|
||||||
phase === "over"
|
phase === "over"
|
||||||
? lastRun?.won
|
? lastRun?.won
|
||||||
|
|
@ -357,6 +372,20 @@ export function MinesweeperBoard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/50 px-3 py-2 text-xs">
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium text-foreground">High-contrast cells</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Use stronger grass-and-dirt colors to distinguish untouched and revealed cells.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={highContrast}
|
||||||
|
onCheckedChange={setHighContrast}
|
||||||
|
aria-label="Use high-contrast cell colors"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
{phase !== "playing" && (
|
{phase !== "playing" && (
|
||||||
<div className="grid gap-2 md:grid-cols-3">
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
{DIFFICULTY_KEYS.map((key) => {
|
{DIFFICULTY_KEYS.map((key) => {
|
||||||
|
|
@ -398,7 +427,13 @@ export function MinesweeperBoard({
|
||||||
Sector clear. {config.label} defused in {lastRun.seconds}s.
|
Sector clear. {config.label} defused in {lastRun.seconds}s.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{phase === "over" && !lastRun?.won && lastRun && <span>The charge went off. MIA.</span>}
|
{phase === "over" && !lastRun?.won && lastRun && (
|
||||||
|
<span>
|
||||||
|
The charge went off. MIA.
|
||||||
|
{incorrectFlagCount > 0 &&
|
||||||
|
` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{phase === "over" && !lastRun && <span>Recording clearance…</span>}
|
{phase === "over" && !lastRun && <span>Recording clearance…</span>}
|
||||||
{lastRun?.won && (
|
{lastRun?.won && (
|
||||||
<span className="text-xs text-emerald-500">
|
<span className="text-xs text-emerald-500">
|
||||||
|
|
@ -442,12 +477,22 @@ export function MinesweeperBoard({
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || cell.revealed}
|
||||||
className={cn(
|
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",
|
"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 &&
|
||||||
!cell.revealed && "border-border bg-card hover:bg-accent",
|
!cell.mine &&
|
||||||
|
(highContrast
|
||||||
|
? "border-yellow-900/40 bg-yellow-950/15 text-amber-50 shadow-inner"
|
||||||
|
: "border-border/60 bg-muted/70"),
|
||||||
|
!cell.revealed &&
|
||||||
|
(highContrast
|
||||||
|
? "border-emerald-500 bg-emerald-800/90 text-emerald-50 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] hover:border-emerald-400 hover:bg-emerald-700"
|
||||||
|
: "border-border bg-card hover:bg-accent"),
|
||||||
|
cell.incorrectFlagged &&
|
||||||
|
"border-destructive bg-destructive/25 text-red-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] animate-pulse",
|
||||||
cell.flagged &&
|
cell.flagged &&
|
||||||
!cell.revealed &&
|
!cell.revealed &&
|
||||||
"border-amber-500/50 bg-amber-500/10 text-amber-400",
|
!cell.incorrectFlagged &&
|
||||||
revealedMine && "border-destructive/60 bg-destructive/20 text-destructive",
|
"border-amber-300 bg-amber-500/25 text-amber-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.14),0_1px_2px_rgb(0_0_0_/_0.35)]",
|
||||||
|
revealedMine && "border-destructive bg-destructive/30 text-red-300",
|
||||||
cell.exploded &&
|
cell.exploded &&
|
||||||
"border-destructive bg-destructive text-destructive-foreground",
|
"border-destructive bg-destructive text-destructive-foreground",
|
||||||
cell.revealed && cell.adjacent > 0 && NUMBER_CLASSES[cell.adjacent],
|
cell.revealed && cell.adjacent > 0 && NUMBER_CLASSES[cell.adjacent],
|
||||||
|
|
@ -481,7 +526,10 @@ export function MinesweeperBoard({
|
||||||
onPointerUp={clearLongPress}
|
onPointerUp={clearLongPress}
|
||||||
onPointerCancel={clearLongPress}
|
onPointerCancel={clearLongPress}
|
||||||
>
|
>
|
||||||
{cell.flagged && !revealedMine ? <Flag className="size-3.5" /> : null}
|
{cell.incorrectFlagged ? <X className="size-3.5" /> : null}
|
||||||
|
{cell.flagged && !cell.incorrectFlagged && !revealedMine ? (
|
||||||
|
<Flag className="size-3.5" />
|
||||||
|
) : null}
|
||||||
{revealedMine ? <Bomb className="size-3.5" /> : null}
|
{revealedMine ? <Bomb className="size-3.5" /> : null}
|
||||||
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
|
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
19079
src/migrations/20260827_022334_add_eod_minesweeper_best_flags.json
Normal file
19079
src/migrations/20260827_022334_add_eod_minesweeper_best_flags.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,15 @@
|
||||||
|
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_best_training_flags" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_perimeter_flags" numeric DEFAULT 0;
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_eod_minesweeper_best_supply_road_flags" 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_best_training_flags";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_perimeter_flags";
|
||||||
|
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_eod_minesweeper_best_supply_road_flags";`)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ 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_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
|
||||||
import * as migration_20260827_005757 from './20260827_005757';
|
import * as migration_20260827_005757 from './20260827_005757';
|
||||||
|
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +29,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260827_005757.up,
|
up: migration_20260827_005757.up,
|
||||||
down: migration_20260827_005757.down,
|
down: migration_20260827_005757.down,
|
||||||
name: '20260827_005757'
|
name: '20260827_005757',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
||||||
|
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
|
||||||
|
name: '20260827_022334_add_eod_minesweeper_best_flags'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1686,6 +1686,9 @@ export interface Profile {
|
||||||
bestTrainingTime?: number | null;
|
bestTrainingTime?: number | null;
|
||||||
bestPerimeterTime?: number | null;
|
bestPerimeterTime?: number | null;
|
||||||
bestSupplyRoadTime?: number | null;
|
bestSupplyRoadTime?: number | null;
|
||||||
|
bestTrainingFlags?: number | null;
|
||||||
|
bestPerimeterFlags?: number | null;
|
||||||
|
bestSupplyRoadFlags?: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -3477,6 +3480,9 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
bestTrainingTime?: T;
|
bestTrainingTime?: T;
|
||||||
bestPerimeterTime?: T;
|
bestPerimeterTime?: T;
|
||||||
bestSupplyRoadTime?: T;
|
bestSupplyRoadTime?: T;
|
||||||
|
bestTrainingFlags?: T;
|
||||||
|
bestPerimeterFlags?: T;
|
||||||
|
bestSupplyRoadFlags?: T;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue