import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Map as MissionMapDoc, Mission, User } from "@/payload-types"; import { ARMA_MARKER_BRUSHES, ARMA_MARKER_COLORS, ARMA_MARKER_TYPES, markerTypeOf, markerTypesByCategory, resolveMarkerRgba, rgbaToHex, } from "@/lib/arma-markers"; import { areaOutline, armaGridRef, hatchSegments, markerFromDoc, markersToSqf, normalizeMarkerInput, toArmaPosition, type MissionMarker, } from "@/lib/arma-markers/missionMarkers"; import { canEditMissionPlan, clearMissionMarkers, createMissionMarker, deleteMissionMarker, findVisibleMission, listMissionMarkers, updateMissionMarker, } from "@/lib/intelligence/missionMap"; import { existsSync } from "fs"; import { join } from "path"; const WORLD = { width: 8192, height: 8192 }; describe("Arma marker catalog", () => { it("contains the core vanilla classes with resolved names", () => { expect(markerTypeOf("b_inf")?.name).toBe("Infantry"); expect(markerTypeOf("B_INF")?.className).toBe("b_inf"); expect(markerTypeOf("mil_dot")).not.toBeNull(); expect(markerTypeOf("hd_dot")).not.toBeNull(); expect(ARMA_MARKER_COLORS.some((c) => c.className === "ColorRed")).toBe(true); expect(ARMA_MARKER_BRUSHES.some((b) => b.className === "Grid")).toBe(true); }); it("ships an icon file for every marker type", () => { const missing = ARMA_MARKER_TYPES.filter( (type) => !existsSync(join(process.cwd(), "public", type.iconUrl)), ); expect(missing.map((type) => type.className)).toEqual([]); }); it("groups every type under a category", () => { const grouped = markerTypesByCategory().reduce((sum, group) => sum + group.types.length, 0); expect(grouped).toBe(ARMA_MARKER_TYPES.length); }); it("resolves Default to the type's own (side) color", () => { expect(rgbaToHex(resolveMarkerRgba("Default", "b_inf"))).toBe(rgbaToHex([0, 0.3, 0.6, 1])); expect(rgbaToHex(resolveMarkerRgba("ColorRed", "b_inf"))).toBe("#e60000"); expect(rgbaToHex(resolveMarkerRgba("Default", null))).toBe("#000000"); }); }); describe("Mission marker validation", () => { it("accepts and normalizes an icon", () => { const result = normalizeMarkerInput( { shape: "icon", markerType: "b_inf", color: "ColorRed", text: " Alpha\nSquad ", position: [100.456, 200], size: [20, 20], direction: -90, alpha: 3, }, WORLD, ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.data.text).toBe("Alpha Squad"); expect(result.data.position).toEqual([100.46, 200]); expect(result.data.size).toEqual([8, 8]); expect(result.data.direction).toBe(270); expect(result.data.alpha).toBe(1); expect(result.data.brush).toBeNull(); }); it("rejects unknown classes, bad shapes and off-world positions", () => { expect( normalizeMarkerInput({ shape: "icon", markerType: "nope", position: [1, 1] }, WORLD), ).toMatchObject({ ok: false }); expect( normalizeMarkerInput( { shape: "icon", markerType: "b_inf", color: "ColorPlaid", position: [1, 1] }, WORLD, ), ).toMatchObject({ ok: false }); expect(normalizeMarkerInput({ shape: "star", position: [1, 1] }, WORLD)).toMatchObject({ ok: false, }); expect( normalizeMarkerInput({ shape: "icon", markerType: "b_inf", position: [99999, 1] }, WORLD), ).toMatchObject({ ok: false }); expect( normalizeMarkerInput({ shape: "rectangle", position: [10, 10], size: [0.1, 5] }, WORLD), ).toMatchObject({ ok: false }); expect(normalizeMarkerInput({ shape: "polyline", points: [[1, 1]] }, WORLD)).toMatchObject({ ok: false, }); }); it("defaults area brush and derives line position", () => { const area = normalizeMarkerInput({ shape: "ellipse", position: [10, 10], size: [50] }, WORLD); expect(area.ok && area.data.brush).toBe("Solid"); expect(area.ok && area.data.size).toEqual([50, 50]); const line = normalizeMarkerInput( { shape: "polyline", points: [ [5, 6], [7, 8], ], }, WORLD, ); expect(line.ok && line.data.position).toEqual([5, 6]); }); it("drops corrupt stored rows instead of throwing", () => { expect(markerFromDoc({ id: 1, shape: "icon", position: "bad" })).toBeNull(); expect( markerFromDoc({ id: 2, shape: "polyline", position: [1, 1], points: [[1, 1]] }), ).toBeNull(); expect(markerFromDoc({ id: 3, shape: "icon", position: [1, 2] })?.position).toEqual([1, 2]); }); }); describe("Mission marker geometry and export", () => { it("rotates area outlines clockwise from north", () => { // 100 m east-west half-width, 10 m north-south, rotated 90 deg: now long // north-south. y runs south on the map. const ring = areaOutline("rectangle", [1000, 1000], [100, 10], 90); const xs = ring.map((p) => Math.round(p[0])); const ys = ring.map((p) => Math.round(p[1])); expect(Math.max(...xs) - Math.min(...xs)).toBe(20); expect(Math.max(...ys) - Math.min(...ys)).toBe(200); expect(areaOutline("ellipse", [0, 0], [10, 10], 0, 32)).toHaveLength(32); }); it("clips hatch lines to the area", () => { const square = areaOutline("rectangle", [50, 50], [50, 50], 0); const lines = hatchSegments(square, 0, 10); expect(lines).toHaveLength(10); for (const [a, b] of lines) { expect(a[0]).toBeCloseTo(0, 5); expect(b[0]).toBeCloseTo(100, 5); expect(a[1]).toBe(b[1]); } expect(hatchSegments(square, 0, 0)).toEqual([]); }); it("flips y for Arma positions and grid refs", () => { expect(toArmaPosition([100, 8000], 8192)).toEqual([100, 192]); expect(armaGridRef([4567, 8192 - 1234], 8192)).toBe("045 012"); expect(armaGridRef([4567, 8192 - 1234], 8192, 8)).toBe("0456 0123"); }); it("exports a createMarker script", () => { const markers: MissionMarker[] = [ { id: 7, shape: "icon", markerType: "b_inf", color: "ColorBLUFOR", brush: null, text: 'Say "hi"', position: [100, 8092], size: [1, 1], direction: 45, alpha: 1, points: [], }, { id: 8, shape: "polyline", markerType: null, color: "ColorRed", brush: null, text: "", position: [0, 0], size: [1, 1], direction: 0, alpha: 0.5, points: [ [0, 8192], [10, 8182], ], }, ]; const sqf = markersToSqf(markers, 8192); expect(sqf).toContain('_m = createMarker ["ptf_plan_7", [100, 100]];'); expect(sqf).toContain('_m setMarkerType "b_inf";'); expect(sqf).toContain('_m setMarkerText "Say ""hi""";'); expect(sqf).toContain("_m setMarkerDir 45;"); expect(sqf).toContain('_m setMarkerShape "POLYLINE";'); expect(sqf).toContain("_m setMarkerPolyline [0, 0, 10, 10];"); expect(sqf).toContain("_m setMarkerAlpha 0.5;"); }); }); describe("Mission planning map service", () => { let payload: Payload; const RUN = `mmap-${Date.now().toString(36)}`; let mapId: number; let mission: Mission; let creator: User; let editor: User; let outsider: User; let roleId: number; const userIds: number[] = []; const makeUser = async (username: string, roleDocs: number[] = []): Promise => { const user = (await payload.create({ collection: "users", data: { username, discordUsername: username, displayName: "MISSION MAP TEST", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], roleDocs, }, overrideAccess: true, depth: 0, })) as unknown as User; userIds.push(user.id); return user; }; const makeMission = async (label: string, createdBy: number | null) => (await payload.create({ collection: "missions", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, summary: "Planning map test mission", operationType: "main", createdBy, classification: { map: mapId, missionType: "PvE", startDateTime: new Date(Date.now() + 2 * 86_400_000).toISOString(), estimatedDuration: 60, planningMapEnabled: true, }, ownershipAndStatus: { authors: [creator.id], status: "Scheduled", visibility: "unit" }, briefing: [], gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, missionRoles: { maxPlayers: 32, providedRoles: [] }, }, overrideAccess: true, depth: 1, })) as unknown as Mission; beforeAll(async () => { payload = await getPayload({ config: await config }); const map = (await payload.create({ collection: "maps", data: { name: `${RUN} Map`, worldSizeWidth: 4096, worldSizeHeight: 4096, basemapMode: "image", }, overrideAccess: true, depth: 0, })) as unknown as MissionMapDoc; mapId = map.id; const role = await payload.create({ collection: "roles", data: { name: `${RUN}-planner`, slug: `${RUN}-planner`, permissions: ["missions:update"] }, overrideAccess: true, depth: 0, }); roleId = role.id; creator = await makeUser(`${RUN}-creator`); editor = await makeUser(`${RUN}-editor`, [roleId]); outsider = await makeUser(`${RUN}-outsider`); mission = await makeMission("Op", creator.id); }); afterAll(async () => { if (!payload) return; if (mission) await payload .delete({ collection: "missions", id: mission.id, overrideAccess: true }) .catch(() => {}); for (const id of userIds) { for (const collection of ["bank-accounts", "profiles"] as const) { const field = collection === "bank-accounts" ? "ownerUser" : "user"; const docs = await payload .find({ collection, where: { [field]: { equals: id } }, depth: 0, overrideAccess: true }) .catch(() => null); for (const doc of docs?.docs ?? []) { await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {}); } } await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "roles", id: roleId, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); }); it("lets the creator and missions:update holders edit, nobody else", async () => { expect(await canEditMissionPlan(payload, creator, mission)).toBe(true); expect(await canEditMissionPlan(payload, editor, mission)).toBe(true); expect(await canEditMissionPlan(payload, outsider, mission)).toBe(false); expect(await canEditMissionPlan(payload, null, mission)).toBe(false); }); it("finds unit-visible missions with the world size", async () => { const visible = await findVisibleMission(payload, outsider, mission.id); expect(visible?.id).toBe(mission.id); expect(visible?.classification.planningMapEnabled).toBe(true); expect(await findVisibleMission(payload, outsider, -1)).toBeNull(); }); it("creates, updates, lists and deletes markers within one mission", async () => { const icon = await createMissionMarker( payload, mission, { shape: "icon", markerType: "o_armor", color: "Default", text: "Tank", position: [500, 600], }, creator.id, ); expect(icon.markerType).toBe("o_armor"); const area = await createMissionMarker( payload, mission, { shape: "rectangle", brush: "Grid", color: "ColorRed", position: [1000, 1000], size: [200, 50], direction: 30, }, editor.id, ); const moved = await updateMissionMarker(payload, mission, area.id, { ...area, position: [1100, 900], }); expect(moved.position).toEqual([1100, 900]); expect(moved.size).toEqual([200, 50]); expect(moved.direction).toBe(30); // Outside the 4096 m world (+2 km margin): rejected. await expect( updateMissionMarker(payload, mission, area.id, { ...area, position: [9000, 9000] }), ).rejects.toThrow("on the map"); const listed = await listMissionMarkers(payload, mission.id); expect(listed.map((marker) => marker.id)).toEqual([icon.id, area.id]); // A marker id from another mission cannot be touched through this one. const other = await makeMission("Other", null); try { await expect(deleteMissionMarker(payload, other.id, icon.id)).rejects.toThrow( "Marker not found", ); } finally { await payload.delete({ collection: "missions", id: other.id, overrideAccess: true }); } await deleteMissionMarker(payload, mission.id, icon.id); expect((await listMissionMarkers(payload, mission.id)).map((m) => m.id)).toEqual([area.id]); expect(await clearMissionMarkers(payload, mission.id)).toBe(1); expect(await listMissionMarkers(payload, mission.id)).toEqual([]); }); it("removes a mission's markers when the mission is deleted", async () => { const doomed = await makeMission("Doomed", creator.id); const marker = await createMissionMarker( payload, doomed, { shape: "polyline", color: "ColorBlack", points: [ [1, 1], [50, 50], ], }, creator.id, ); await payload.delete({ collection: "missions", id: doomed.id, overrideAccess: true }); const left = await payload .findByID({ collection: "mission-map-markers", id: marker.id, overrideAccess: true }) .catch(() => null); expect(left).toBeNull(); }); it("keeps the collection closed to plain users over REST/local access", async () => { await expect( payload.find({ collection: "mission-map-markers", user: outsider, overrideAccess: false, }), ).rejects.toThrow(); }); });