add land navigation exercises menu, command palette entry and lib tests
This commit is contained in:
parent
0e65260ea0
commit
4143749c39
3 changed files with 171 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Bomb,
|
||||
ChevronsUpDown,
|
||||
Crosshair,
|
||||
Compass,
|
||||
FileTextIcon,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
|
|
@ -174,6 +175,12 @@ export function NavUser({
|
|||
Call for Fire
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/land-nav">
|
||||
<Compass />
|
||||
Land Navigation
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
|
|
|||
140
tests/int/land-nav.int.spec.ts
Normal file
140
tests/int/land-nav.int.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue