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,
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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