140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|