feat(call-for-fire): add page, server actions and fire mission UI
Page, actions, and components are one vertical feature slice; the actions types are imported by the client components Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
cce22c1fcc
commit
c76710502e
5 changed files with 1283 additions and 0 deletions
231
src/app/(frontend)/call-for-fire/actions.ts
Normal file
231
src/app/(frontend)/call-for-fire/actions.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"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";
|
||||||
|
import {
|
||||||
|
BEST_MISSIONS_FIELDS,
|
||||||
|
CALL_FOR_FIRE_DIFFICULTIES,
|
||||||
|
MAX_MISSIONS_PER_RUN,
|
||||||
|
METERS_PER_CELL,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
isCallForFireDifficulty,
|
||||||
|
type CallForFireDifficulty,
|
||||||
|
} from "@/lib/call-for-fire";
|
||||||
|
|
||||||
|
export type { CallForFireDifficulty };
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallForFireStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
bestScore: number;
|
||||||
|
callForFireXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestPatrolMissions: number;
|
||||||
|
bestCheckpointMissions: number;
|
||||||
|
bestCompoundMissions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BestMissionsField = (typeof BEST_MISSIONS_FIELDS)[CallForFireDifficulty];
|
||||||
|
|
||||||
|
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 submitCallForFireResult(input: {
|
||||||
|
difficulty: CallForFireDifficulty;
|
||||||
|
roundsUsed: number;
|
||||||
|
seconds: number;
|
||||||
|
finalDistanceM: number;
|
||||||
|
won: boolean;
|
||||||
|
}): Promise<ActionResult<CallForFireStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (typeof input.difficulty !== "string" || !isCallForFireDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = input.difficulty;
|
||||||
|
const difficultyConfig = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.roundsUsed) ||
|
||||||
|
input.roundsUsed <= 0 ||
|
||||||
|
input.roundsUsed > Math.min(difficultyConfig.maxRounds, MAX_MISSIONS_PER_RUN)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.seconds) ||
|
||||||
|
input.seconds <= 0 ||
|
||||||
|
input.seconds > difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof input.finalDistanceM !== "number" ||
|
||||||
|
!Number.isFinite(input.finalDistanceM) ||
|
||||||
|
input.finalDistanceM < 0 ||
|
||||||
|
input.finalDistanceM > difficultyConfig.size * METERS_PER_CELL
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (typeof input.won !== "boolean") {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (input.won && input.finalDistanceM > difficultyConfig.lethalRadiusM) {
|
||||||
|
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?: {
|
||||||
|
callForFire?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
bestPatrolMissions?: number;
|
||||||
|
bestCheckpointMissions?: number;
|
||||||
|
bestCompoundMissions?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.callForFire ?? {};
|
||||||
|
const score = calculateScore(
|
||||||
|
difficulty,
|
||||||
|
input.roundsUsed,
|
||||||
|
input.seconds,
|
||||||
|
input.finalDistanceM,
|
||||||
|
input.won,
|
||||||
|
);
|
||||||
|
const runXp = calculateXp(score, input.won);
|
||||||
|
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 totalXp = (current.xpEarned ?? 0) + runXp;
|
||||||
|
const bestMissionsField = BEST_MISSIONS_FIELDS[difficulty];
|
||||||
|
const previousBestMissions = current[bestMissionsField as keyof typeof current] ?? 0;
|
||||||
|
const isBestRun =
|
||||||
|
input.won && (previousBestMissions === 0 || input.roundsUsed < previousBestMissions);
|
||||||
|
const bestMissions = isBestRun ? input.roundsUsed : previousBestMissions;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
callForFire: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
xpEarned: totalXp,
|
||||||
|
bestPatrolMissions: current.bestPatrolMissions ?? 0,
|
||||||
|
bestCheckpointMissions: current.bestCheckpointMissions ?? 0,
|
||||||
|
bestCompoundMissions: current.bestCompoundMissions ?? 0,
|
||||||
|
[bestMissionsField]: bestMissions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.MinigameCallForFire,
|
||||||
|
message: `Brought fire onto target in ${difficultyConfig.label} with ${input.roundsUsed} round(s) (+${runXp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty,
|
||||||
|
roundsUsed: input.roundsUsed,
|
||||||
|
seconds: input.seconds,
|
||||||
|
finalDistanceM: input.finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: runXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
callForFireXp: totalXp,
|
||||||
|
xpEarned: runXp,
|
||||||
|
bestPatrolMissions:
|
||||||
|
bestMissionsField === "bestPatrolMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestPatrolMissions ?? 0),
|
||||||
|
bestCheckpointMissions:
|
||||||
|
bestMissionsField === "bestCheckpointMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestCheckpointMissions ?? 0),
|
||||||
|
bestCompoundMissions:
|
||||||
|
bestMissionsField === "bestCompoundMissions"
|
||||||
|
? bestMissions
|
||||||
|
: (current.bestCompoundMissions ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/(frontend)/call-for-fire/page.tsx
Normal file
66
src/app/(frontend)/call-for-fire/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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 { Target } from "lucide-react";
|
||||||
|
import { CallForFireArena } from "@/components/frontend/call-for-fire/CallForFireArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Call for Fire (Polaris Task Force)",
|
||||||
|
description:
|
||||||
|
"Adjust artillery onto hidden targets across three sectors to earn XP for the task force.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CallForFirePage() {
|
||||||
|
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("/call-for-fire")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 callForFire = profileRes.docs[0]?.progression?.minigames?.callForFire ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: callForFire.gamesPlayed ?? 0,
|
||||||
|
wins: callForFire.wins ?? 0,
|
||||||
|
losses: callForFire.losses ?? 0,
|
||||||
|
bestScore: callForFire.bestScore ?? 0,
|
||||||
|
callForFireXp: callForFire.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
bestPatrolMissions: callForFire.bestPatrolMissions ?? 0,
|
||||||
|
bestCheckpointMissions: callForFire.bestCheckpointMissions ?? 0,
|
||||||
|
bestCompoundMissions: callForFire.bestCompoundMissions ?? 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">
|
||||||
|
<Target className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Call for Fire</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjust artillery onto the hidden target. Fewer fire missions score higher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CallForFireArena initialStats={stats} currentUserId={user.id as number} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/frontend/call-for-fire/CallForFireArena.tsx
Normal file
47
src/components/frontend/call-for-fire/CallForFireArena.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { CallForFireDifficulty, CallForFireStats } from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { CallForFireLeaderboard } from "./CallForFireLeaderboard";
|
||||||
|
import { FireMissionBoard } from "./FireMissionBoard";
|
||||||
|
|
||||||
|
export function CallForFireArena({
|
||||||
|
initialStats,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialStats: CallForFireStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}) {
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [selectedDifficulty, setSelectedDifficulty] = useState<CallForFireDifficulty>("patrol");
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
const activeTeams = useRealtimePresence("call-for-fire", playing, true);
|
||||||
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
|
setPlaying(phase === "playing");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing && isMobile) setOpenMobile(false);
|
||||||
|
}, [isMobile, playing, setOpenMobile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<FireMissionBoard
|
||||||
|
initialStats={initialStats}
|
||||||
|
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
||||||
|
onPhaseChange={onPhaseChange}
|
||||||
|
onDifficultyChange={setSelectedDifficulty}
|
||||||
|
/>
|
||||||
|
<CallForFireLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeTeams={activeTeams}
|
||||||
|
difficulty={selectedDifficulty}
|
||||||
|
onDifficultyChange={setSelectedDifficulty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx
Normal file
251
src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import type { CallForFireDifficulty } from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiProfile {
|
||||||
|
id: number;
|
||||||
|
profileTitle?: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
} | null;
|
||||||
|
progression?: {
|
||||||
|
minigames?: {
|
||||||
|
callForFire?: Record<string, number | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
name: string;
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
bestScore: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestMissions: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallForFireLeaderboardProps {
|
||||||
|
refreshKey?: number;
|
||||||
|
currentUserId?: number | string;
|
||||||
|
activeTeams?: PresenceParticipant[];
|
||||||
|
difficulty: CallForFireDifficulty;
|
||||||
|
onDifficultyChange: (difficulty: CallForFireDifficulty) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_LABELS: Record<CallForFireDifficulty, string> = {
|
||||||
|
patrol: "Patrol",
|
||||||
|
checkpoint: "Checkpoint",
|
||||||
|
compound: "Compound",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 CallForFireLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
activeTeams = [],
|
||||||
|
difficulty,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: CallForFireLeaderboardProps) {
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
const missionsField = `progression.minigames.callForFire.best${DIFFICULTY_LABELS[difficulty]}Missions`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: missionsField,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
params.set(`where[${missionsField}][greater_than]`, "0");
|
||||||
|
|
||||||
|
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 game = doc.progression?.minigames?.callForFire ?? {};
|
||||||
|
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,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
name,
|
||||||
|
gamesPlayed: game.gamesPlayed ?? 0,
|
||||||
|
wins: game.wins ?? 0,
|
||||||
|
bestScore: game.bestScore ?? 0,
|
||||||
|
xpEarned: game.xpEarned ?? 0,
|
||||||
|
bestMissions: game[`best${DIFFICULTY_LABELS[difficulty]}Missions`] ?? 0,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestMissions > 0)
|
||||||
|
.sort((a, b) => a.bestMissions - b.bestMissions || b.bestScore - a.bestScore);
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, [difficulty, 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">Call for Fire Leaderboard</h2>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => onDifficultyChange(value as CallForFireDifficulty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Leaderboard sector" className="h-8 w-[150px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="patrol">Patrol Support</SelectItem>
|
||||||
|
<SelectItem value="checkpoint">Checkpoint Defence</SelectItem>
|
||||||
|
<SelectItem value="compound">Compound Strike</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · fewest missions</span>
|
||||||
|
</div>
|
||||||
|
</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} observers on the net
|
||||||
|
</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 effective missions 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">Missions</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-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>
|
||||||
|
)}
|
||||||
|
{row.userId !== null &&
|
||||||
|
activeTeams.some((team) => team.id === row.userId) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Firing now"
|
||||||
|
aria-label="Firing now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{row.bestMissions}
|
||||||
|
</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-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.xpEarned}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
688
src/components/frontend/call-for-fire/FireMissionBoard.tsx
Normal file
688
src/components/frontend/call-for-fire/FireMissionBoard.tsx
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import { Crosshair, Radio, RotateCcw, Target, Timer } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
submitCallForFireResult,
|
||||||
|
type CallForFireDifficulty,
|
||||||
|
type CallForFireStats,
|
||||||
|
} from "@/app/(frontend)/call-for-fire/actions";
|
||||||
|
import {
|
||||||
|
CALL_FOR_FIRE_DIFFICULTIES,
|
||||||
|
METERS_PER_CELL,
|
||||||
|
applyCorrection,
|
||||||
|
createScenario,
|
||||||
|
distanceMeters,
|
||||||
|
gridDesignation,
|
||||||
|
calculateScore,
|
||||||
|
isEffectiveHit,
|
||||||
|
observerReport,
|
||||||
|
type FireScenario,
|
||||||
|
type GridPoint,
|
||||||
|
} from "@/lib/call-for-fire";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
|
||||||
|
type LastRun = {
|
||||||
|
readonly won: boolean;
|
||||||
|
readonly roundsUsed: number;
|
||||||
|
readonly seconds: number;
|
||||||
|
readonly finalDistanceM: number;
|
||||||
|
readonly score: number;
|
||||||
|
readonly xpEarned: number;
|
||||||
|
readonly best: number;
|
||||||
|
readonly reason?: "rounds" | "time";
|
||||||
|
readonly error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Impact = {
|
||||||
|
readonly point: GridPoint;
|
||||||
|
readonly effective: boolean;
|
||||||
|
readonly spotting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_KEYS: readonly CallForFireDifficulty[] = ["patrol", "checkpoint", "compound"];
|
||||||
|
const CORRECTION_STEP = 50;
|
||||||
|
|
||||||
|
function formatClock(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FireMissionBoard({
|
||||||
|
initialStats: serverStats,
|
||||||
|
onGameRecorded,
|
||||||
|
onPhaseChange,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: {
|
||||||
|
initialStats: CallForFireStats;
|
||||||
|
onGameRecorded: () => void;
|
||||||
|
onPhaseChange: (phase: Phase) => void;
|
||||||
|
onDifficultyChange: (difficulty: CallForFireDifficulty) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const [difficulty, setDifficulty] = useState<CallForFireDifficulty>("patrol");
|
||||||
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
|
const [scenario, setScenario] = useState<FireScenario | null>(null);
|
||||||
|
const [impacts, setImpacts] = useState<Impact[]>([]);
|
||||||
|
const [lastImpact, setLastImpact] = useState<GridPoint | null>(null);
|
||||||
|
const [radioLog, setRadioLog] = useState<string[]>([]);
|
||||||
|
const [roundsUsed, setRoundsUsed] = useState(0);
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const [rangeM, setRangeM] = useState(0);
|
||||||
|
const [deflectionM, setDeflectionM] = useState(0);
|
||||||
|
const [stats, setStats] = useState<CallForFireStats>(serverStats);
|
||||||
|
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const statsRef = useRef<CallForFireStats>(serverStats);
|
||||||
|
const secondsLeftRef = useRef(0);
|
||||||
|
|
||||||
|
const config = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||||
|
const roundsLeft = config.maxRounds - roundsUsed;
|
||||||
|
const elapsedSeconds = config.maxSeconds - secondsLeftRef.current;
|
||||||
|
|
||||||
|
const setPhaseSafe = useCallback(
|
||||||
|
(nextPhase: Phase) => {
|
||||||
|
setPhase(nextPhase);
|
||||||
|
onPhaseChange(nextPhase);
|
||||||
|
},
|
||||||
|
[onPhaseChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPhaseChange("ready");
|
||||||
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
|
const reset = useCallback(
|
||||||
|
(nextDifficulty: CallForFireDifficulty = difficulty) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
|
setDifficulty(nextDifficulty);
|
||||||
|
onDifficultyChange(nextDifficulty);
|
||||||
|
setScenario(null);
|
||||||
|
setImpacts([]);
|
||||||
|
setLastImpact(null);
|
||||||
|
setRadioLog([]);
|
||||||
|
setRoundsUsed(0);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
setLastRun(null);
|
||||||
|
secondsLeftRef.current = CALL_FOR_FIRE_DIFFICULTIES[nextDifficulty].maxSeconds;
|
||||||
|
setSecondsLeft(CALL_FOR_FIRE_DIFFICULTIES[nextDifficulty].maxSeconds);
|
||||||
|
setPhaseSafe("ready");
|
||||||
|
},
|
||||||
|
[difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordResult = useCallback(
|
||||||
|
async (won: boolean, used: number, seconds: number, finalDistanceM: number) => {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score: 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: 0,
|
||||||
|
error: "Your session expired. Result not recorded.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submittingRef.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
const result = await submitCallForFireResult({
|
||||||
|
difficulty,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
won,
|
||||||
|
});
|
||||||
|
submittingRef.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
const score = calculateScore(difficulty, used, seconds, finalDistanceM, won);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
statsRef.current = result.data;
|
||||||
|
setStats(result.data);
|
||||||
|
onGameRecorded();
|
||||||
|
if (result.data.xpEarned > 0) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: result.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: result.data.xpEarned,
|
||||||
|
best: result.data.bestScore,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLastRun({
|
||||||
|
won,
|
||||||
|
roundsUsed: used,
|
||||||
|
seconds,
|
||||||
|
finalDistanceM,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
best: statsRef.current.bestScore,
|
||||||
|
error: result.error ?? "Could not record fire mission.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[difficulty, expiredRef, onGameRecorded, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishGameRef = useRef<(won: boolean, reason?: "rounds" | "time") => void>(() => {});
|
||||||
|
finishGameRef.current = (won: boolean, reason?: "rounds" | "time") => {
|
||||||
|
if (submittingRef.current || phase === "over") return;
|
||||||
|
setPhaseSafe("over");
|
||||||
|
if (!scenario || !lastImpact) return;
|
||||||
|
const seconds = reason === "time" ? config.maxSeconds : elapsedSeconds;
|
||||||
|
const distance = distanceMeters(scenario.target, lastImpact);
|
||||||
|
void recordResult(won, roundsUsed, seconds, distance);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "playing") return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setSecondsLeft((prev) => {
|
||||||
|
const next = prev - 1;
|
||||||
|
secondsLeftRef.current = next;
|
||||||
|
if (next <= 0) {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
finishGameRef.current(false, "time");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const begin = useCallback(() => {
|
||||||
|
if (expiredRef.current || submittingRef.current) return;
|
||||||
|
const nextScenario = createScenario(difficulty);
|
||||||
|
const report = observerReport(nextScenario.target, nextScenario.spotting);
|
||||||
|
setScenario(nextScenario);
|
||||||
|
setImpacts([
|
||||||
|
{ point: nextScenario.spotting, effective: false, spotting: true },
|
||||||
|
]);
|
||||||
|
setLastImpact(nextScenario.spotting);
|
||||||
|
setRadioLog([
|
||||||
|
`Splash, over. Spotting round ${gridDesignation(nextScenario.spotting)}: ${report.message}`,
|
||||||
|
]);
|
||||||
|
setRoundsUsed(1);
|
||||||
|
secondsLeftRef.current = config.maxSeconds;
|
||||||
|
setSecondsLeft(config.maxSeconds);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
setLastRun(null);
|
||||||
|
setPhaseSafe("playing");
|
||||||
|
}, [config.maxSeconds, difficulty, expiredRef, setPhaseSafe]);
|
||||||
|
|
||||||
|
const fireMission = useCallback(() => {
|
||||||
|
if (phase !== "playing" || submittingRef.current || expiredRef.current) return;
|
||||||
|
if (!scenario || !lastImpact) return;
|
||||||
|
|
||||||
|
const impactPoint = applyCorrection(lastImpact, { rangeM, deflectionM }, config.size);
|
||||||
|
const distance = distanceMeters(scenario.target, impactPoint);
|
||||||
|
const effective = isEffectiveHit(scenario.target, impactPoint, config.lethalRadiusM);
|
||||||
|
const nextImpacts = [
|
||||||
|
...impacts,
|
||||||
|
{ point: impactPoint, effective, spotting: false },
|
||||||
|
];
|
||||||
|
const nextUsed = roundsUsed + 1;
|
||||||
|
setImpacts(nextImpacts);
|
||||||
|
setLastImpact(impactPoint);
|
||||||
|
setRoundsUsed(nextUsed);
|
||||||
|
setRangeM(0);
|
||||||
|
setDeflectionM(0);
|
||||||
|
|
||||||
|
if (effective) {
|
||||||
|
setRadioLog((log) =>
|
||||||
|
[
|
||||||
|
...log,
|
||||||
|
`Splash, over. Effective on grid ${gridDesignation(impactPoint)}. Target destroyed.`,
|
||||||
|
].slice(-6),
|
||||||
|
);
|
||||||
|
setPhaseSafe("over");
|
||||||
|
void recordResult(true, nextUsed, elapsedSeconds, distance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = observerReport(scenario.target, impactPoint);
|
||||||
|
setRadioLog((log) =>
|
||||||
|
[
|
||||||
|
...log,
|
||||||
|
`Impact grid ${gridDesignation(impactPoint)}: ${report.message}`,
|
||||||
|
].slice(-6),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextUsed >= config.maxRounds) {
|
||||||
|
setPhaseSafe("over");
|
||||||
|
void recordResult(false, nextUsed, elapsedSeconds, distance);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config.lethalRadiusM,
|
||||||
|
config.maxRounds,
|
||||||
|
config.size,
|
||||||
|
elapsedSeconds,
|
||||||
|
expiredRef,
|
||||||
|
impacts,
|
||||||
|
lastImpact,
|
||||||
|
phase,
|
||||||
|
rangeM,
|
||||||
|
roundsUsed,
|
||||||
|
scenario,
|
||||||
|
setPhaseSafe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timeCritical = phase === "playing" && secondsLeft <= 15;
|
||||||
|
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">
|
||||||
|
{expired && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-6 text-center">
|
||||||
|
<span className="text-xl font-black uppercase tracking-widest text-amber-400">
|
||||||
|
Session Ending Soon
|
||||||
|
</span>
|
||||||
|
<span className="max-w-md text-sm text-white/80">
|
||||||
|
Your session is about to end. The app will refresh when it expires.
|
||||||
|
</span>
|
||||||
|
<LoginLink>
|
||||||
|
<Button type="button" size="sm">
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</LoginLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex select-none 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">
|
||||||
|
<Target className="size-4 text-amber-500" />
|
||||||
|
Fire mission console
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Bracket the hidden target from the spotting report, then walk fire on with range and
|
||||||
|
deflection corrections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
|
||||||
|
{phase !== "ready" && (
|
||||||
|
<span
|
||||||
|
className={cn("flex items-center gap-1.5", timeCritical && "text-destructive")}
|
||||||
|
aria-label={`${formatClock(Math.max(0, secondsLeft))} remaining`}
|
||||||
|
>
|
||||||
|
<Timer className="size-4" />
|
||||||
|
{formatClock(Math.max(0, secondsLeft))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase !== "ready" && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
aria-label={`${Math.max(0, roundsLeft)} rounds remaining`}
|
||||||
|
>
|
||||||
|
<Crosshair className="size-4 text-amber-400" />
|
||||||
|
{Math.max(0, roundsLeft)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
disabled={phase === "playing"}
|
||||||
|
className={cn(
|
||||||
|
"m-0 flex min-w-0 flex-col gap-4 border-0 p-0 transition-opacity duration-300",
|
||||||
|
phase === "playing" && "pointer-events-none opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
|
{DIFFICULTY_KEYS.map((key) => {
|
||||||
|
const option = CALL_FOR_FIRE_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 || expired}
|
||||||
|
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.size}×{option.size} grid · {option.maxRounds} rounds max ·{" "}
|
||||||
|
{option.parRounds} par · {formatClock(option.maxSeconds)} limit
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<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>Ready to observe. Begin the fire mission to receive your spotting round.</span>
|
||||||
|
)}
|
||||||
|
{phase === "playing" && (
|
||||||
|
<span>
|
||||||
|
Live fire mission: correct range (ADD/DROP) and deflection (RIGHT/LEFT) from the
|
||||||
|
last impact.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && lastRun?.won && (
|
||||||
|
<span>
|
||||||
|
Fire effect achieved. {config.label} target destroyed with {lastRun.roundsUsed}{" "}
|
||||||
|
round{lastRun.roundsUsed === 1 ? "" : "s"} in {lastRun.seconds}s.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun?.won && lastRun?.reason === "rounds" && (
|
||||||
|
<span>Mission failed. Rounds expended before effect. Adjust and try again.</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun?.won && lastRun?.reason === "time" && (
|
||||||
|
<span>Mission failed. Time expired before fire effect.</span>
|
||||||
|
)}
|
||||||
|
{phase === "over" && !lastRun && <span>Recording fire mission…</span>}
|
||||||
|
{lastRun?.won && (
|
||||||
|
<span className="text-xs text-emerald-500">
|
||||||
|
Score {lastRun.score} · accuracy {Math.round(lastRun.finalDistanceM)}m · +
|
||||||
|
{lastRun.xpEarned} XP earned.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lastRun?.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
|
||||||
|
</div>
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Button type="button" size="sm" disabled={submitting || expired} onClick={begin}>
|
||||||
|
<Crosshair /> Begin fire mission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{phase === "over" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={submitting || expired}
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
<RotateCcw /> New fire mission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 items-start justify-center overflow-auto rounded-lg border border-border bg-background p-2"
|
||||||
|
style={{ containerType: "inline-size" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid min-w-max gap-1"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--board-cols": config.size,
|
||||||
|
"--cell-size": `max(1.25rem, min(2rem, calc((100cqw - ${(config.size + 1) * 0.25}rem) / ${config.size})))`,
|
||||||
|
gridTemplateColumns: `auto repeat(var(--board-cols), var(--cell-size))`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
role="grid"
|
||||||
|
aria-label={`${config.label} fire mission grid`}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{Array.from({ length: config.size }, (_, column) => (
|
||||||
|
<div
|
||||||
|
key={`col-${column}`}
|
||||||
|
className="flex size-[var(--cell-size)] items-center justify-center font-mono text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{String.fromCharCode(65 + column)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: config.size }, (_, row) => (
|
||||||
|
<ImpactRow
|
||||||
|
key={`row-${row}`}
|
||||||
|
row={row}
|
||||||
|
size={config.size}
|
||||||
|
impacts={impacts}
|
||||||
|
target={phase === "over" ? scenario?.target : undefined}
|
||||||
|
disabled={phase !== "playing" || expired}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/50 p-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fireMission();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Crosshair className="size-3.5" />
|
||||||
|
Correction (metres, from last impact)
|
||||||
|
</div>
|
||||||
|
<CorrectionField
|
||||||
|
id="cff-range"
|
||||||
|
label="Range (+ ADD / - DROP)"
|
||||||
|
value={rangeM}
|
||||||
|
disabled={phase !== "playing"}
|
||||||
|
onChange={setRangeM}
|
||||||
|
/>
|
||||||
|
<CorrectionField
|
||||||
|
id="cff-deflection"
|
||||||
|
label="Deflection (+ RIGHT / - LEFT)"
|
||||||
|
value={deflectionM}
|
||||||
|
disabled={phase !== "playing"}
|
||||||
|
onChange={setDeflectionM}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={phase !== "playing" || submitting || expired}>
|
||||||
|
Fire mission
|
||||||
|
</Button>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
1 cell = {METERS_PER_CELL}m. ADD pushes the burst north, RIGHT pushes it east.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="min-w-0 rounded-lg border border-border/70 bg-background/50 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
Observer net
|
||||||
|
</div>
|
||||||
|
{radioLog.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Net quiet. Awaiting your first call.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 font-mono text-xs tabular-nums">
|
||||||
|
{radioLog.map((line, index) => (
|
||||||
|
<li
|
||||||
|
key={`${index}-${line}`}
|
||||||
|
className={cn(
|
||||||
|
line.includes("Effective") ? "text-emerald-400" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
> {line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Read the spotting report, correct range and deflection, and walk the rounds onto the target
|
||||||
|
before time runs out
|
||||||
|
</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>Missions</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 fires</span>
|
||||||
|
<span className="font-semibold text-foreground">{stats.callForFireXp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImpactRow({
|
||||||
|
row,
|
||||||
|
size,
|
||||||
|
impacts,
|
||||||
|
target,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
row: number;
|
||||||
|
size: number;
|
||||||
|
impacts: Impact[];
|
||||||
|
target?: GridPoint;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const impactByColumn = new Map<number, Impact>();
|
||||||
|
for (const impact of impacts) {
|
||||||
|
if (impact.point.y === row) {
|
||||||
|
impactByColumn.set(impact.point.x, impact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-[var(--cell-size)] items-center justify-center font-mono text-[10px] text-muted-foreground">
|
||||||
|
{row + 1}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: size }, (_, column) => {
|
||||||
|
const impact = impactByColumn.get(column);
|
||||||
|
const isTarget = target !== undefined && target.x === column && target.y === row;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${row}-${column}`}
|
||||||
|
role="gridcell"
|
||||||
|
aria-label={`grid ${gridDesignation({ x: column, y: row })}${
|
||||||
|
impact ? (impact.effective ? " effective impact" : impact.spotting ? " spotting round" : " impact") : ""
|
||||||
|
}`}
|
||||||
|
className={cn(
|
||||||
|
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors",
|
||||||
|
"border-border bg-card",
|
||||||
|
impact?.effective &&
|
||||||
|
"border-emerald-400 bg-emerald-600/80 text-emerald-50 shadow-[0_0_8px_rgb(16_185_129_/_0.6)]",
|
||||||
|
impact && !impact.effective && impact.spotting &&
|
||||||
|
"border-sky-400/80 bg-sky-500/20 text-sky-200",
|
||||||
|
impact && !impact.effective && !impact.spotting &&
|
||||||
|
"border-amber-500/70 bg-amber-500/20 text-amber-200",
|
||||||
|
isTarget && "border-destructive bg-destructive/25 text-red-300",
|
||||||
|
disabled && "opacity-90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{impact ? (
|
||||||
|
<Crosshair
|
||||||
|
className={cn("size-3.5", impact.spotting && "text-sky-300")}
|
||||||
|
strokeWidth={impact.spotting ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorrectionField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
const clamp = (next: number) => Math.min(2000, Math.max(-2000, next));
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className="text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="size-8 shrink-0 p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Decrease ${label}`}
|
||||||
|
onClick={() => onChange(clamp(value - CORRECTION_STEP))}
|
||||||
|
>
|
||||||
|
-{CORRECTION_STEP}
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
step={CORRECTION_STEP}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-8 text-center font-mono tabular-nums"
|
||||||
|
onChange={(event) => {
|
||||||
|
const parsed = Number(event.target.value);
|
||||||
|
onChange(Number.isFinite(parsed) ? clamp(Math.round(parsed)) : 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="size-8 shrink-0 p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Increase ${label}`}
|
||||||
|
onClick={() => onChange(clamp(value + CORRECTION_STEP))}
|
||||||
|
>
|
||||||
|
+{CORRECTION_STEP}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue