From 4143749c3970969ec376be1d170648cbd3c12168 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 10:34:55 -0400 Subject: [PATCH] add land navigation exercises menu, command palette entry and lib tests --- .../command-palette/commands/navigation.ts | 26 +++- src/components/frontend/blocks/NavUser.tsx | 7 + tests/int/land-nav.int.spec.ts | 140 ++++++++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/int/land-nav.int.spec.ts diff --git a/src/components/command-palette/commands/navigation.ts b/src/components/command-palette/commands/navigation.ts index 3b6fa11..c56b4f1 100644 --- a/src/components/command-palette/commands/navigation.ts +++ b/src/components/command-palette/commands/navigation.ts @@ -25,6 +25,7 @@ import { Radio, Bomb, Target, + Compass, } from "lucide-react"; export interface NavigationCommand { @@ -39,8 +40,22 @@ export interface NavigationCommand { } export const navigationCommands: NavigationCommand[] = [ - { id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" }, - { id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" }, + { + id: "nav:dashboard", + label: "Dashboard", + icon: Bolt, + href: "/", + keywords: ["home", "main"], + shortcut: "G D", + }, + { + id: "nav:roster", + label: "Roster", + icon: Users, + href: "/roster", + keywords: ["members", "personnel"], + shortcut: "G R", + }, { id: "nav:transfers", label: "Transfers", @@ -255,6 +270,13 @@ export const navigationCommands: NavigationCommand[] = [ href: "/call-for-fire", keywords: ["minigame", "artillery", "observer", "fire mission", "training"], }, + { + id: "nav:land-nav", + label: "Land Navigation", + icon: Compass, + href: "/land-nav", + keywords: ["minigame", "navigation", "map", "grid", "azimuth", "training"], + }, { id: "nav:account", label: "Account", diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx index 99e69b6..a314f25 100644 --- a/src/components/frontend/blocks/NavUser.tsx +++ b/src/components/frontend/blocks/NavUser.tsx @@ -6,6 +6,7 @@ import { Bomb, ChevronsUpDown, Crosshair, + Compass, FileTextIcon, Gamepad2, LogOut, @@ -174,6 +175,12 @@ export function NavUser({ Call for Fire + + + + Land Navigation + + diff --git a/tests/int/land-nav.int.spec.ts b/tests/int/land-nav.int.spec.ts new file mode 100644 index 0000000..12cf76b --- /dev/null +++ b/tests/int/land-nav.int.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + GRID_COLS, + GRID_ROWS, + LAND_NAV_DIFFICULTIES, + calculateScore, + calculateXp, + createCourse, + gridRef, + isLegCorrect, + isOptionCorrect, + isLandNavDifficulty, +} from "@/lib/land-nav"; + +function seededRng(seed: number) { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) % 4294967296; + return state / 4294967296; + }; +} + +describe("land-nav lib", () => { + it("guards the difficulty keys", () => { + expect(isLandNavDifficulty("waypoint")).toBe(true); + expect(isLandNavDifficulty("route")).toBe(true); + expect(isLandNavDifficulty("expedition")).toBe(true); + expect(isLandNavDifficulty("patrol")).toBe(false); + expect(isLandNavDifficulty(42)).toBe(false); + }); + + it("creates a valid course for every difficulty", () => { + for (const difficulty of ["waypoint", "route", "expedition"] as const) { + const config = LAND_NAV_DIFFICULTIES[difficulty]; + const course = createCourse(difficulty, seededRng(1)); + expect(course.legs).toHaveLength(config.legs); + expect(course.features).toHaveLength(config.features); + expect(course.cols).toBe(GRID_COLS); + expect(course.rows).toBe(GRID_ROWS); + + const featureCells = new Set(course.features.map((f) => `${f.col},${f.row}`)); + expect(featureCells.size).toBe(config.features); + for (const feature of course.features) { + expect(feature.col).toBeGreaterThanOrEqual(0); + expect(feature.col).toBeLessThan(GRID_COLS); + expect(feature.row).toBeGreaterThanOrEqual(0); + expect(feature.row).toBeLessThan(GRID_ROWS); + expect(feature.name.length).toBeGreaterThan(0); + } + + for (const leg of course.legs) { + expect(leg.prompt.length).toBeGreaterThan(0); + expect(leg.answerCell.col).toBeGreaterThanOrEqual(0); + expect(leg.answerCell.col).toBeLessThan(GRID_COLS); + expect(leg.answerCell.row).toBeGreaterThanOrEqual(0); + expect(leg.answerCell.row).toBeLessThan(GRID_ROWS); + if (leg.kind === "azimuth") { + expect(leg.azimuth).toBeGreaterThanOrEqual(0); + expect(leg.azimuth).toBeLessThanOrEqual(359); + expect(leg.distanceM).toBeGreaterThan(0); + expect(leg.startCell).toBeDefined(); + } + if (leg.kind === "identify") { + expect(leg.options).toHaveLength(4); + expect(new Set(leg.options).size).toBe(4); + } + } + } + }); + + it("produces 6-figure grid references", () => { + const course = createCourse("waypoint", seededRng(7)); + const ref = gridRef(course, { col: 0, row: 0 }); + expect(ref).toMatch(/^\d{6}$/); + expect(ref).toBe( + `${String(course.easting0).padStart(3, "0")}${String(course.northing0).padStart(3, "0")}`, + ); + }); + + it("scores passes higher than fails and awards XP only on a pass", () => { + const passScore = calculateScore("route", 6, 60, true); + const failScore = calculateScore("route", 2, 60, false); + expect(passScore).toBeGreaterThan(failScore); + const faster = calculateScore("route", 6, 30, true); + expect(faster).toBeGreaterThanOrEqual(passScore); + expect(calculateXp(passScore, true)).toBeGreaterThanOrEqual(1); + expect(calculateXp(passScore, false)).toBe(0); + }); + + it("validates leg answers", () => { + const course = createCourse("waypoint", seededRng(3)); + const locateLeg = course.legs.find((leg) => leg.kind === "locate"); + const identifyLeg = course.legs.find((leg) => leg.kind === "identify"); + expect(locateLeg).toBeDefined(); + expect(identifyLeg).toBeDefined(); + if (locateLeg) { + expect(isLegCorrect(locateLeg, locateLeg.answerCell)).toBe(true); + expect(isLegCorrect(locateLeg, { col: 0, row: 0 })).toBe( + locateLeg.answerCell.col === 0 && locateLeg.answerCell.row === 0, + ); + } + if (identifyLeg) { + const answerFeature = course.features.find((f) => f.id === identifyLeg.answerFeatureId); + expect(answerFeature).toBeDefined(); + expect(isOptionCorrect(identifyLeg, course, answerFeature!.name)).toBe(true); + const wrongOption = (identifyLeg.options ?? []).find( + (option) => option !== answerFeature!.name, + ); + expect(isOptionCorrect(identifyLeg, course, wrongOption ?? "")).toBe(false); + } + }); + + it("generates declination legs on harder courses with a consistent conversion", () => { + let seen = 0; + for (let seed = 1; seed <= 40 && seen < 5; seed += 1) { + const course = createCourse("expedition", seededRng(seed)); + const declLegs = course.legs.filter((leg) => leg.kind === "declination"); + seen += declLegs.length; + for (const leg of declLegs) { + expect(leg.declination).not.toBe(0); + expect(leg.startCell).toBeDefined(); + const radians = ((leg.azimuth ?? 0) * Math.PI) / 180; + const distanceCells = (leg.distanceM ?? 0) / 100; + const dx = Math.round(Math.sin(radians) * distanceCells); + const dy = -Math.round(Math.cos(radians) * distanceCells) || 0; + expect(leg.answerCell.col - leg.startCell!.col).toBe(dx); + expect(leg.answerCell.row - leg.startCell!.row).toBe(dy); + expect(leg.prompt).toMatch(/declination/i); + } + } + expect(seen).toBeGreaterThan(0); + }); + + it("never generates declination legs on the waypoint course", () => { + for (let seed = 1; seed <= 20; seed += 1) { + const course = createCourse("waypoint", seededRng(seed)); + expect(course.legs.every((leg) => leg.kind !== "declination")).toBe(true); + } + }); +});