1
0
Fork 0
polaris-task-force/src/components/frontend/call-for-fire/FireMissionBoard.tsx
Z8MB1E c76710502e 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>
2026-09-08 10:23:21 -04:00

688 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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",
)}
>
&gt; {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>
);
}