1
0
Fork 0

add land navigation exercises menu, command palette entry and lib tests

This commit is contained in:
Jason Fraley 2026-09-08 10:34:55 -04:00
parent 0e65260ea0
commit 4143749c39
3 changed files with 171 additions and 2 deletions

View file

@ -25,6 +25,7 @@ import {
Radio, Radio,
Bomb, Bomb,
Target, Target,
Compass,
} from "lucide-react"; } from "lucide-react";
export interface NavigationCommand { export interface NavigationCommand {
@ -39,8 +40,22 @@ export interface NavigationCommand {
} }
export const navigationCommands: 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", id: "nav:transfers",
label: "Transfers", label: "Transfers",
@ -255,6 +270,13 @@ export const navigationCommands: NavigationCommand[] = [
href: "/call-for-fire", href: "/call-for-fire",
keywords: ["minigame", "artillery", "observer", "fire mission", "training"], 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", id: "nav:account",
label: "Account", label: "Account",

View file

@ -6,6 +6,7 @@ import {
Bomb, Bomb,
ChevronsUpDown, ChevronsUpDown,
Crosshair, Crosshair,
Compass,
FileTextIcon, FileTextIcon,
Gamepad2, Gamepad2,
LogOut, LogOut,
@ -174,6 +175,12 @@ export function NavUser({
Call for Fire Call for Fire
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/land-nav">
<Compass />
Land Navigation
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -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);
}
});
});