// Phase 29 pure helpers in `src/map/visibility.ts`. The tests exercise // `computeHiddenPlanetNumbers`, `computeHiddenIds`, `computeFogCircles`, // and `isCategoryVisible` directly so the map view can stay a thin // wiring layer. import { describe, expect, test } from "vitest"; import type { GameReport, ReportPlanet } from "../src/api/game-state"; import { DEFAULT_MAP_TOGGLES, type MapToggles } from "../src/lib/game-state.svelte"; import type { MapCategory } from "../src/map/state-binding"; import { FLIGHT_DISTANCE_PER_DRIVE, VISIBILITY_DISTANCE_PER_DRIVE, computeFogCircles, computeHiddenIds, computeHiddenPlanetNumbers, fingerprintHiddenPlanets, isCategoryVisible, } from "../src/map/visibility"; import type { PrimitiveID } from "../src/map/world"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; function makeReport(overrides: Partial = {}): GameReport { return { turn: 1, mapWidth: 4000, mapHeight: 4000, planetCount: 0, planets: [], race: "", localShipClass: [], routes: [], localPlayerDrive: 0, localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, ...overrides, }; } function makePlanet(overrides: Partial): ReportPlanet { return { number: 0, name: "", x: 0, y: 0, kind: "local", owner: null, size: null, resources: null, industryStockpile: null, materialsStockpile: null, industry: null, population: null, colonists: null, production: null, freeIndustry: null, ...overrides, }; } function toggles(overrides: Partial = {}): MapToggles { return { ...DEFAULT_MAP_TOGGLES, ...overrides }; } describe("isCategoryVisible", () => { test("local planets are always visible regardless of toggles", () => { expect( isCategoryVisible("planet-local", toggles({ foreignPlanets: false })), ).toBe(true); }); test("each kind toggle controls its planet category", () => { const t = toggles({ foreignPlanets: false, uninhabitedPlanets: false, unidentifiedPlanets: false, }); expect(isCategoryVisible("planet-foreign", t)).toBe(false); expect(isCategoryVisible("planet-uninhabited", t)).toBe(false); expect(isCategoryVisible("planet-unidentified", t)).toBe(false); }); test("battleMarker toggle hides battle X-crosses without touching other layers", () => { const t = toggles({ battleMarkers: false }); expect(isCategoryVisible("battleMarker", t)).toBe(false); expect(isCategoryVisible("planet-foreign", t)).toBe(true); }); }); describe("computeHiddenPlanetNumbers", () => { test("returns an empty set when defaults are in effect", () => { const report = makeReport({ localPlayerDrive: 10, planets: [ makePlanet({ number: 1, kind: "local", x: 100, y: 100 }), makePlanet({ number: 2, kind: "other", x: 200, y: 100 }), ], }); expect(computeHiddenPlanetNumbers(report, toggles())).toEqual(new Set()); }); test("kind-toggle off hides every planet of that kind", () => { const report = makeReport({ localPlayerDrive: 10, planets: [ makePlanet({ number: 1, kind: "local", x: 100, y: 100 }), makePlanet({ number: 2, kind: "other", x: 200, y: 100 }), makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 100 }), makePlanet({ number: 4, kind: "unidentified", x: 400, y: 100 }), ], }); const hidden = computeHiddenPlanetNumbers( report, toggles({ foreignPlanets: false, unidentifiedPlanets: false }), ); expect(hidden).toEqual(new Set([2, 4])); }); test("unreachablePlanets=off hides planets beyond FlightDistance", () => { const report = makeReport({ localPlayerDrive: 10, mapWidth: 4000, mapHeight: 4000, planets: [ makePlanet({ number: 1, kind: "local", x: 100, y: 100 }), // Foreign within reach: distance ≈ 100 < 400. makePlanet({ number: 2, kind: "other", x: 200, y: 100 }), // Foreign beyond reach: distance ≈ 500 > 400. makePlanet({ number: 3, kind: "other", x: 600, y: 100 }), ], }); const reachLimit = 10 * FLIGHT_DISTANCE_PER_DRIVE; expect(reachLimit).toBe(400); const hidden = computeHiddenPlanetNumbers( report, toggles({ unreachablePlanets: false }), ); expect(hidden).toEqual(new Set([3])); }); test("torus wrap shortens reach distance across the seam", () => { const report = makeReport({ localPlayerDrive: 10, mapWidth: 1000, mapHeight: 1000, planets: [ makePlanet({ number: 1, kind: "local", x: 50, y: 500 }), // Wrap distance is 100 (50 → 950 via the left seam), well // inside the 400-unit reach. Without the torus metric this // would resolve to 900 and the planet would hide. makePlanet({ number: 2, kind: "other", x: 950, y: 500 }), ], }); const hidden = computeHiddenPlanetNumbers( report, toggles({ unreachablePlanets: false }), ); expect(hidden).toEqual(new Set()); }); test("localPlayerDrive=0 hides every non-local planet when reach filter is on", () => { const report = makeReport({ localPlayerDrive: 0, planets: [ makePlanet({ number: 1, kind: "local", x: 100, y: 100 }), makePlanet({ number: 2, kind: "other", x: 101, y: 100 }), ], }); const hidden = computeHiddenPlanetNumbers( report, toggles({ unreachablePlanets: false }), ); expect(hidden).toEqual(new Set([2])); }); test("a report with no LOCAL planets keeps everything visible (no reach anchor)", () => { const report = makeReport({ localPlayerDrive: 10, planets: [makePlanet({ number: 9, kind: "other", x: 9000, y: 9000 })], }); const hidden = computeHiddenPlanetNumbers( report, toggles({ unreachablePlanets: false }), ); expect(hidden).toEqual(new Set()); }); test("LOCAL planets are never hidden", () => { const report = makeReport({ localPlayerDrive: 10, planets: [makePlanet({ number: 1, kind: "local", x: 1, y: 1 })], }); expect( computeHiddenPlanetNumbers( report, toggles({ foreignPlanets: false, unreachablePlanets: false }), ), ).toEqual(new Set()); }); }); describe("computeHiddenIds", () => { // F8-12 / #30: bombings no longer ride the cascade as their own // primitive — they paint a planet outline directly. The fixture // here mirrors what `reportToWorld` currently emits. const categories: Map = new Map< PrimitiveID, MapCategory >([ [1, "planet-local"], [2, "planet-foreign"], [100, "hyperspaceGroup"], [150, "hyperspaceGroup"], [200, "incomingGroup"], [300, "battleMarker"], ]); const planetDependents = new Map>([ [1, new Set([1])], [2, new Set([2, 100, 150, 200, 300])], ]); test("category-toggle off hides every primitive in that category", () => { const hidden = computeHiddenIds( categories, planetDependents, new Set(), toggles({ hyperspaceGroups: false }), ); expect(hidden.has(100)).toBe(true); expect(hidden.has(150)).toBe(true); expect(hidden.has(200)).toBe(false); expect(hidden.has(2)).toBe(false); }); test("hiding a planet cascades onto its dependent primitives", () => { const hidden = computeHiddenIds( categories, planetDependents, new Set([2]), toggles(), ); expect(hidden).toEqual(new Set([2, 100, 150, 200, 300])); }); test("battle markers honour the battleMarkers toggle independently", () => { const hidden = computeHiddenIds( categories, planetDependents, new Set(), toggles({ battleMarkers: false }), ); expect(hidden.has(300)).toBe(true); expect(hidden.has(150)).toBe(false); }); test("planet cascade and category toggle compose without duplicates", () => { const hidden = computeHiddenIds( categories, planetDependents, new Set([2]), toggles({ battleMarkers: false }), ); // 300 is already present from the cascade; the category toggle // re-adds it but Set semantics dedupe. expect(hidden).toEqual(new Set([2, 100, 150, 200, 300])); }); }); describe("computeFogCircles", () => { test("disabled toggle returns an empty list", () => { const report = makeReport({ localPlayerDrive: 10, planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })], }); expect( computeFogCircles(report, toggles({ visibleHyperspace: false })), ).toEqual([]); }); test("zero drive returns an empty list (radius would be zero)", () => { const report = makeReport({ localPlayerDrive: 0, planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })], }); expect(computeFogCircles(report, toggles())).toEqual([]); }); test("emits one circle per LOCAL planet at VisibilityDistance", () => { const report = makeReport({ localPlayerDrive: 10, planets: [ makePlanet({ number: 1, kind: "local", x: 100, y: 100 }), makePlanet({ number: 2, kind: "local", x: 300, y: 200 }), makePlanet({ number: 3, kind: "other", x: 500, y: 500 }), ], }); const radius = 10 * VISIBILITY_DISTANCE_PER_DRIVE; expect(radius).toBe(300); expect(computeFogCircles(report, toggles())).toEqual([ { x: 100, y: 100, radius }, { x: 300, y: 200, radius }, ]); }); }); describe("fingerprintHiddenPlanets", () => { test("sorts numerically for deterministic fingerprint", () => { expect(fingerprintHiddenPlanets(new Set([3, 1, 2]))).toBe("1,2,3"); }); test("empty set returns an empty string", () => { expect(fingerprintHiddenPlanets(new Set())).toBe(""); }); });