"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); }} />
); }