diff --git a/src/app/(frontend)/call-for-fire/actions.ts b/src/app/(frontend)/call-for-fire/actions.ts new file mode 100644 index 0000000..d31742f --- /dev/null +++ b/src/app/(frontend)/call-for-fire/actions.ts @@ -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 { + 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> { + 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 }; + } +} diff --git a/src/app/(frontend)/call-for-fire/page.tsx b/src/app/(frontend)/call-for-fire/page.tsx new file mode 100644 index 0000000..1969082 --- /dev/null +++ b/src/app/(frontend)/call-for-fire/page.tsx @@ -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 ( +
+
+
+ +

Call for Fire

+
+

+ Adjust artillery onto the hidden target. Fewer fire missions score higher. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/call-for-fire/CallForFireArena.tsx b/src/components/frontend/call-for-fire/CallForFireArena.tsx new file mode 100644 index 0000000..3ac1e4d --- /dev/null +++ b/src/components/frontend/call-for-fire/CallForFireArena.tsx @@ -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("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 ( +
+ setRefreshKey((key) => key + 1)} + onPhaseChange={onPhaseChange} + onDifficultyChange={setSelectedDifficulty} + /> + +
+ ); +} diff --git a/src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx b/src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx new file mode 100644 index 0000000..da96c46 --- /dev/null +++ b/src/components/frontend/call-for-fire/CallForFireLeaderboard.tsx @@ -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; + }; + }; +} + +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 = { + 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(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 ( +
+
+ +

Call for Fire Leaderboard

+
+ + Top 10 · fewest missions +
+
+
+ + {activeTeams.length} observers on the net + + {activeTeams.length > 0 && ( + + ({activeTeams.map((team) => team.name).join(", ")}) + + )} +
+ + {failed ? ( +

Could not load the leaderboard.

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

+ No effective missions yet. Take the first sector! +

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
#OperatorMissionsGamesWinsXP
+ + {row.isOutsideTopTen ? "-" : index + 1} + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.isOutsideTopTen && ( + + unranked + + )} + {row.userId !== null && + activeTeams.some((team) => team.id === row.userId) && ( + + )} + + + {row.bestMissions} + + {row.gamesPlayed} + + {row.wins} + + {row.xpEarned} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/call-for-fire/FireMissionBoard.tsx b/src/components/frontend/call-for-fire/FireMissionBoard.tsx new file mode 100644 index 0000000..b817083 --- /dev/null +++ b/src/components/frontend/call-for-fire/FireMissionBoard.tsx @@ -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("patrol"); + const [phase, setPhase] = useState("ready"); + const [scenario, setScenario] = useState(null); + const [impacts, setImpacts] = useState([]); + const [lastImpact, setLastImpact] = useState(null); + const [radioLog, setRadioLog] = useState([]); + const [roundsUsed, setRoundsUsed] = useState(0); + const [secondsLeft, setSecondsLeft] = useState(0); + const [rangeM, setRangeM] = useState(0); + const [deflectionM, setDeflectionM] = useState(0); + const [stats, setStats] = useState(serverStats); + const [lastRun, setLastRun] = useState(null); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); + const statsRef = useRef(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 ( +
+ {expired && ( +
+ + Session Ending Soon + + + Your session is about to end. The app will refresh when it expires. + + + + +
+ )} +
+
+
+
+ + Fire mission console +
+

+ Bracket the hidden target from the spotting report, then walk fire on with range and + deflection corrections. +

+
+
+ {phase !== "ready" && ( + + + {formatClock(Math.max(0, secondsLeft))} + + )} + {phase !== "ready" && ( + + + {Math.max(0, roundsLeft)} + + )} +
+
+ +
+
+ {DIFFICULTY_KEYS.map((key) => { + const option = CALL_FOR_FIRE_DIFFICULTIES[key]; + return ( + + ); + })} +
+
+ +
+
+ {phase === "ready" && ( + Ready to observe. Begin the fire mission to receive your spotting round. + )} + {phase === "playing" && ( + + Live fire mission: correct range (ADD/DROP) and deflection (RIGHT/LEFT) from the + last impact. + + )} + {phase === "over" && lastRun?.won && ( + + Fire effect achieved. {config.label} target destroyed with {lastRun.roundsUsed}{" "} + round{lastRun.roundsUsed === 1 ? "" : "s"} in {lastRun.seconds}s. + + )} + {phase === "over" && !lastRun?.won && lastRun?.reason === "rounds" && ( + Mission failed. Rounds expended before effect. Adjust and try again. + )} + {phase === "over" && !lastRun?.won && lastRun?.reason === "time" && ( + Mission failed. Time expired before fire effect. + )} + {phase === "over" && !lastRun && Recording fire mission…} + {lastRun?.won && ( + + Score {lastRun.score} · accuracy {Math.round(lastRun.finalDistanceM)}m · + + {lastRun.xpEarned} XP earned. + + )} + {lastRun?.error && {lastRun.error}} +
+ {phase === "ready" && ( + + )} + {phase === "over" && ( + + )} +
+ +
+
+
+
+ {Array.from({ length: config.size }, (_, column) => ( +
+ {String.fromCharCode(65 + column)} +
+ ))} + {Array.from({ length: config.size }, (_, row) => ( + + ))} +
+
+ +
+
{ + event.preventDefault(); + fireMission(); + }} + > +
+ + Correction (metres, from last impact) +
+ + + +

+ 1 cell = {METERS_PER_CELL}m. ADD pushes the burst north, RIGHT pushes it east. +

+ + +
+
+ + Observer net +
+ {radioLog.length === 0 ? ( +

Net quiet. Awaiting your first call.

+ ) : ( +
    + {radioLog.map((line, index) => ( +
  • + > {line} +
  • + ))} +
+ )} +
+
+
+ +

+ Read the spotting report, correct range and deflection, and walk the rounds onto the target + before time runs out +

+
+ +
+
+ Missions + {stats.gamesPlayed} +
+
+ Wins + {stats.wins} +
+
+ Best score + {stats.bestScore} +
+
+ XP from fires + {stats.callForFireXp} +
+
+
+ ); +} + +function ImpactRow({ + row, + size, + impacts, + target, + disabled, +}: { + row: number; + size: number; + impacts: Impact[]; + target?: GridPoint; + disabled: boolean; +}) { + const impactByColumn = new Map(); + for (const impact of impacts) { + if (impact.point.y === row) { + impactByColumn.set(impact.point.x, impact); + } + } + return ( + <> +
+ {row + 1} +
+ {Array.from({ length: size }, (_, column) => { + const impact = impactByColumn.get(column); + const isTarget = target !== undefined && target.x === column && target.y === row; + return ( +
+ {impact ? ( + + ) : null} +
+ ); + })} + + ); +} + +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 ( +
+ +
+ + { + const parsed = Number(event.target.value); + onChange(Number.isFinite(parsed) ? clamp(Math.round(parsed)) : 0); + }} + /> + +
+
+ ); +}