import { getPayload, Payload, type CollectionSlug, type Where } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import type { GameStructure, GameVehicle, Map as MapDoc, Resource, Shipment, Structure, User, Vehicle, } from "@/payload-types"; import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing"; import { zoneLabelPoint } from "@/lib/map/points"; import { pointInPolygon } from "@/lib/map/points"; import { labelScreenAngle, roadLabelShown, metersPerPx, polygonCentroid, roadLabelPoints, splinePolyline, validateHexColor, } from "@/lib/map/style"; import { closestPointOnSegment, findSnap, segmentMidpoints, type SnapTarget, } from "@/lib/map/snapping"; import { checkResourcesInRange, checkStructuresInRange, checkTerrain, type NodeInput, type StructureInput, type ZoneInput, } from "@/lib/map/placement"; import { aggregateCompoundStorage, type CompoundMemberLike } from "@/lib/map/compounds"; import { maybeStartConstruction, processConstructionTick } from "@/lib/map/construction"; import { placeStructure, saveMapNode, saveMapRoad, saveMapZone, } from "@/app/(frontend)/logistics/map/actions"; import { createShipment } from "@/app/(frontend)/logistics/shipments/actions"; import { EventTypes } from "@/utils/event-log/eventTypes"; // Mock next/headers to avoid "headers was called outside a request scope" error vi.mock("next/headers", () => ({ headers: async () => new Headers(), })); let payload: Payload; let authSpy: MockInstance; const RUN = `map-${Date.now().toString(36)}`; const LEXICAL_EMPTY = { root: { type: "root", children: [ { type: "paragraph", children: [ { type: "text", text: "Test blueprint.", format: 0, style: "", mode: "normal", detail: 0, direction: "ltr", indent: 0, version: 1, }, ], direction: "ltr", format: "", indent: 0, textFormat: 0, textStyle: "", version: 1, }, ], direction: "ltr", format: "", indent: 0, version: 1, }, }; const createdIds = { maps: [] as number[], resources: [] as number[], structureBlueprints: [] as number[], vehicleBlueprints: [] as number[], gameStructures: [] as number[], gameVehicles: [] as number[], roads: [] as number[], zones: [] as number[], nodes: [] as number[], shipments: [] as number[], roles: [] as number[], users: [] as number[], qualifications: [] as number[], }; async function makeMap(name: string): Promise { return (await payload.create({ collection: "maps", data: { name, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, })) as unknown as MapDoc; } async function makeResource(name: string): Promise { return (await payload.create({ collection: "resources", data: { name, codeName: `${RUN}-${name.toLowerCase()}`, approvalStatus: "in_progress", type: "physical", baseValue: 1, rarity: "common", unitOfMeasure: "kg", massPerUnit: 1, gridWidth: 1, gridHeight: 1, }, overrideAccess: true, depth: 0, })) as unknown as Resource; } async function makeStructureBlueprint(data: { name: string; materials: { resource: number; amount: number }[]; constructionDurationMinutes: number; resourcesInRange?: { resource: number; range: number }[]; }): Promise { return (await payload.create({ collection: "structures", data: { name: data.name, codeName: data.name.toLowerCase().replace(/\s+/g, "_"), approvalStatus: "in_progress", description: LEXICAL_EMPTY as unknown as Structure["description"], category: "logistics", materials: data.materials, constructionDurationMinutes: data.constructionDurationMinutes, terrainType: "land", maxHealth: 100, ...(data.resourcesInRange ? { resourcesInRange: data.resourcesInRange } : {}), }, overrideAccess: true, depth: 0, })) as unknown as Structure; } async function makeVehicleBlueprint(data: { name: string; transportMode: "ground" | "air" | "sea"; fuelTypeId: number; }): Promise { return (await payload.create({ collection: "vehicles", data: { name: data.name, approvalStatus: "in_progress", transportMode: data.transportMode, fuel: { fuelType: data.fuelTypeId, fuelCapacity: 100000, fuelConsumptionRate: 0.01 }, ...(data.transportMode === "ground" ? { maxSpeedOnRoad: 60 } : data.transportMode === "air" ? { maxAirspeed: 120 } : { maxKnots: 30 }), }, overrideAccess: true, depth: 0, })) as unknown as Vehicle; } async function makeGameStructure(data: { name: string; blueprintId: number; mapId: number; point: [number, number]; constructionStatus?: "awaiting_materials" | "building" | "complete"; }): Promise { return (await payload.create({ collection: "game-structures", data: { name: data.name, type: data.blueprintId, map: data.mapId, coordinates: data.point, ...(data.constructionStatus ? { constructionStatus: data.constructionStatus } : {}), }, overrideAccess: true, depth: 0, })) as unknown as GameStructure; } async function makeGameVehicle(data: { name: string; blueprint: Vehicle; atId: number; }): Promise { return (await payload.create({ collection: "game-vehicles", data: { name: data.name, type: data.blueprint.id, deployedAt: data.atId, status: "idle", currentFuel: 50000, currentHealth: 100, }, overrideAccess: true, depth: 0, })) as unknown as GameVehicle; } async function makeRoad(mapId: number, points: [number, number][], speedMultiplier = 1) { const road = await payload.create({ collection: "map-roads", data: { map: mapId, surface: "paved", speedMultiplier, points }, overrideAccess: true, depth: 0, }); createdIds.roads.push(road.id); return road; } describe("Game Map System", () => { let mapA: MapDoc; let mapB: MapDoc; let steel: Resource; let diesel: Resource; let depotBlueprint: Structure; let rangeBlueprint: Structure; let truckBlueprint: Vehicle; let aircraftBlueprint: Vehicle; let originA: GameStructure; let destA: GameStructure; let destB: GameStructure; let logisticsUser: User; let plainUser: User; let mapper: User; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); authSpy = vi.spyOn(payload, "auth"); mapA = await makeMap(`${RUN} Altis`); mapB = await makeMap(`${RUN} Stratis`); createdIds.maps.push(mapA.id, mapB.id); steel = await makeResource(`${RUN} Steel`); diesel = await makeResource(`${RUN} Diesel`); createdIds.resources.push(steel.id, diesel.id); depotBlueprint = await makeStructureBlueprint({ name: `${RUN} Depot`, materials: [{ resource: steel.id, amount: 5 }], constructionDurationMinutes: 30, }); rangeBlueprint = await makeStructureBlueprint({ name: `${RUN} Mine Head`, materials: [{ resource: steel.id, amount: 1 }], constructionDurationMinutes: 30, resourcesInRange: [{ resource: steel.id, range: 1000 }], }); createdIds.structureBlueprints.push(depotBlueprint.id, rangeBlueprint.id); truckBlueprint = await makeVehicleBlueprint({ name: `${RUN} Truck`, transportMode: "ground", fuelTypeId: diesel.id, }); aircraftBlueprint = await makeVehicleBlueprint({ name: `${RUN} Helicopter`, transportMode: "air", fuelTypeId: diesel.id, }); createdIds.vehicleBlueprints.push(truckBlueprint.id, aircraftBlueprint.id); originA = await makeGameStructure({ name: `${RUN} Origin`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [1000, 1000], }); destA = await makeGameStructure({ name: `${RUN} Yard`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [6000, 6000], }); destB = await makeGameStructure({ name: `${RUN} Islet`, blueprintId: depotBlueprint.id, mapId: mapB.id, point: [500, 500], }); createdIds.gameStructures.push(originA.id, destA.id, destB.id); const role = await payload.create({ collection: "roles", data: { name: `${RUN} logistics`, slug: `${RUN}-logistics`, permissions: ["shipments:create", "structures:read"], }, overrideAccess: true, depth: 0, }); createdIds.roles.push(role.id); logisticsUser = (await payload.create({ collection: "users", data: { username: `${RUN}-log`, discordUsername: `${RUN}-log`, displayName: `${RUN} Logistics`, steamId: `${RUN}-log-steam`, password: `${RUN}-log-pass`, roleDocs: [role.id], }, overrideAccess: true, depth: 0, })) as unknown as User; plainUser = (await payload.create({ collection: "users", data: { username: `${RUN}-plain`, discordUsername: `${RUN}-plain`, displayName: `${RUN} Plain`, steamId: `${RUN}-plain-steam`, password: `${RUN}-plain-pass`, }, overrideAccess: true, depth: 0, })) as unknown as User; createdIds.users.push(logisticsUser.id, plainUser.id); const mapperRole = await payload.create({ collection: "roles", data: { name: `${RUN} mapper`, slug: `${RUN}-mapper`, permissions: ["maps:create", "maps:update", "map-roads:read", "admin:map-roads:manage"], }, overrideAccess: true, depth: 0, }); createdIds.roles.push(mapperRole.id); mapper = (await payload.create({ collection: "users", data: { username: `${RUN}-mapper`, discordUsername: `${RUN}-mapper`, displayName: `${RUN} Mapper`, steamId: `${RUN}-mapper-steam`, password: `${RUN}-mapper-pass`, roleDocs: [mapperRole.id], }, overrideAccess: true, depth: 0, })) as unknown as User; createdIds.users.push(mapper.id); }); afterAll(async () => { authSpy.mockRestore(); // Per-doc deletes (bulk delete by where is unreliable on this adapter) and // pattern predicates, so partial beforeAll failures cannot leave orphans. const sweep = async (collection: CollectionSlug, where: Where) => { const found = await payload .find({ collection, where, limit: 1000, depth: 0, overrideAccess: true }) .catch(() => null); for (const doc of found?.docs ?? []) { await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {}); } }; const runLike = { name: { like: `${RUN}%` } }; await sweep("shipments", { map: { in: createdIds.maps } }); await sweep("game-vehicles", runLike); await sweep("map-roads", { map: { in: createdIds.maps } }); await sweep("map-zones", { map: { in: createdIds.maps } }); await sweep("resource-nodes", { map: { in: createdIds.maps } }); await sweep("game-structures", runLike); await sweep("structures", runLike); await sweep("vehicles", runLike); await sweep("resources", runLike); await sweep("maps", runLike); await sweep("roles", runLike); await sweep("qualifications", runLike); for (const id of createdIds.users) { await sweep("bank-accounts", { ownerUser: { equals: id } }); await sweep("profiles", { user: { equals: id } }); } await sweep("users", { username: { like: `${RUN}%` } }); await payload .delete({ collection: "game-event-logs", where: { and: [ { or: [ { type: { equals: EventTypes.StructurePlaced } }, { type: { equals: EventTypes.StructureConstructionComplete } }, ], }, { or: createdIds.gameStructures.map((id) => ({ targetId: { equals: id } })), }, ], }, overrideAccess: true, }) .catch(() => {}); }); describe("map styling helpers (pure)", () => { it("validates hex colors and allows empty", () => { expect(validateHexColor("#38bdf8")).toBe(true); expect(validateHexColor("#38BDF8")).toBe(true); expect(validateHexColor("")).toBe(true); expect(validateHexColor(undefined)).toBe(true); expect(validateHexColor("38bdf8")).not.toBe(true); expect(validateHexColor("#38b")).not.toBe(true); expect(validateHexColor("#38bdf88")).not.toBe(true); expect(validateHexColor("red")).not.toBe(true); }); it("converts meters to screen pixels per zoom level", () => { expect(metersPerPx(0, 8192)).toBe(32); expect(metersPerPx(5, 8192)).toBe(1); expect(metersPerPx(6, 8192)).toBeCloseTo(0.5, 5); }); it("places road labels at fixed intervals and flags upside-down directions", () => { const labels = roadLabelPoints( [ [0, 0], [1000, 0], [1000, 1000], ], 600, 10, ); expect(labels).toHaveLength(3); expect(labels[0].distance).toBeCloseTo(300, 5); expect(labels[1].distance).toBeCloseTo(900, 5); expect(labels[2].distance).toBeCloseTo(1500, 5); expect(labels[0].point[0]).toBeCloseTo(300, 5); expect(labels[0].point[1]).toBeCloseTo(0, 5); expect(labels.map((label) => label.flipped)).toEqual([false, false, true]); const reversed = roadLabelPoints( [ [2000, 0], [1000, 0], ], 600, 10, ); expect(reversed).toHaveLength(2); expect(reversed[0].distance).toBeCloseTo(300, 5); expect(reversed[1].distance).toBeCloseTo(900, 5); expect(reversed.map((label) => label.flipped)).toEqual([true, true]); }); it("caps road labels at the configured maximum", () => { const labels = roadLabelPoints( [ [0, 0], [10000, 0], ], 100, 5, ); expect(labels.length).toBe(5); }); it("densifies polylines with a spline that passes through every vertex", () => { const raw: [number, number][] = [ [0, 0], [1000, 0], [1000, 1000], [0, 1000], ]; const splined = splinePolyline(raw, 8); expect(splined.length).toBe((raw.length - 1) * 8 + 1); expect(splined[0][0]).toBeCloseTo(0, 5); expect(splined[0][1]).toBeCloseTo(0, 5); expect(splined[splined.length - 1][0]).toBeCloseTo(0, 5); expect(splined[splined.length - 1][1]).toBeCloseTo(1000, 5); for (let i = 0; i < raw.length; i++) { const vertex = splined[i * 8]; expect(vertex[0]).toBeCloseTo(raw[i][0], 5); expect(vertex[1]).toBeCloseTo(raw[i][1], 5); } const short = splinePolyline(raw.slice(0, 2)); expect(short).toEqual(raw.slice(0, 2)); }); it("derives upright straight-label angles with vertical text reading up", () => { expect(labelScreenAngle([0, 0], [1000, 0])).toBe(0); expect(labelScreenAngle([0, 0], [-1000, 0])).toBe(0); expect(labelScreenAngle([0, 0], [0, 1000])).toBe(-90); expect(labelScreenAngle([0, 0], [0, -1000])).toBe(-90); expect(labelScreenAngle([0, 0], [1000, 1000])).toBe(45); expect(labelScreenAngle([0, 0], [-1000, 1000])).toBe(-45); }); it("defaults road labels to paved roads, minor roads at close zoom, with explicit override", () => { expect(roadLabelShown({ labelVisible: null, surface: "paved" }, 32)).toBe(true); expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 32)).toBe(false); expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 32)).toBe(false); expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 4)).toBe(true); expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 3.9)).toBe(true); expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 4)).toBe(true); expect(roadLabelShown({ labelVisible: true, surface: "dirt" }, 32)).toBe(true); expect(roadLabelShown({ labelVisible: false, surface: "paved" }, 4)).toBe(false); expect(roadLabelShown({ labelVisible: null, surface: "paved" })).toBe(true); expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false); }); it("keeps zone label points inside and near the center of mass", () => { // C-shaped ring: the area centroid falls in the notch (outside). const cShape: [number, number][] = [ [0, 0], [3000, 0], [3000, 1000], [1000, 1000], [1000, 2000], [3000, 2000], [3000, 3000], [0, 3000], ]; const centroid = polygonCentroid(cShape); expect(pointInPolygon(centroid, cShape)).toBe(false); const label = zoneLabelPoint(cShape); expect(label).not.toBeNull(); expect(pointInPolygon(label!, cShape)).toBe(true); // Convex rings label at the centroid itself. const square: [number, number][] = [ [0, 0], [2000, 0], [2000, 2000], [0, 2000], ]; const squareLabel = zoneLabelPoint(square); expect(squareLabel).not.toBeNull(); expect(pointInPolygon(squareLabel!, square)).toBe(true); expect(squareLabel![0]).toBeGreaterThan(500); expect(squareLabel![0]).toBeLessThan(1500); expect(squareLabel![1]).toBeGreaterThan(500); expect(squareLabel![1]).toBeLessThan(1500); }); it("computes polygon centroids inside the ring", () => { const centroid = polygonCentroid([ [0, 0], [1000, 0], [1000, 1000], [0, 1000], ]); expect(centroid[0]).toBeCloseTo(500, 0); expect(centroid[1]).toBeCloseTo(500, 0); const degenerate = polygonCentroid([ [0, 0], [100, 0], ]); expect(degenerate[0]).toBeCloseTo(50, 0); expect(degenerate[1]).toBe(0); }); }); describe("authoring snapping (pure)", () => { it("projects to segments and clamps to endpoints", () => { expect(closestPointOnSegment([500, 500], [0, 0], [1000, 0])).toEqual([500, 0]); expect(closestPointOnSegment([-50, -50], [0, 0], [1000, 0])).toEqual([0, 0]); expect(closestPointOnSegment([2000, 50], [0, 0], [1000, 0])).toEqual([1000, 0]); expect(closestPointOnSegment([10, 10], [10, 10], [10, 10])).toEqual([10, 10]); }); it("snaps to the nearest vertex, preferring vertices over edges on ties", () => { const zone: SnapTarget = { key: "zone-1", vertices: [ [0, 0], [1000, 0], [1000, 1000], [0, 1000], ], closed: true, edges: true, }; const corner = findSnap([30, 20], [zone], 100); expect(corner?.kind).toBe("vertex"); expect(corner?.point).toEqual([0, 0]); // On the middle of an edge (>= 10% closer than any vertex) the edge wins. const edge = findSnap([500, 30], [zone], 100); expect(edge?.kind).toBe("edge"); expect(edge?.point).toEqual([500, 0]); }); it("includes the closing segment of closed targets and respects the radius", () => { const zone: SnapTarget = { key: "zone-1", vertices: [ [0, 0], [1000, 0], [1000, 1000], [0, 1000], ], closed: true, edges: true, }; const closing = findSnap([30, 500], [zone], 100); expect(closing?.kind).toBe("edge"); expect(closing?.point).toEqual([0, 500]); expect(findSnap([30, 500], [zone], 10)).toBeNull(); }); it("builds segment midpoints with insert indices for open and closed drafts", () => { const open = segmentMidpoints( [ [0, 0], [100, 0], [100, 100], ], false, ); expect(open).toEqual([ { index: 1, point: [50, 0] }, { index: 2, point: [100, 50] }, ]); const closed = segmentMidpoints( [ [0, 0], [100, 0], [100, 100], ], true, ); expect(closed).toHaveLength(3); expect(closed[2]).toEqual({ index: 3, point: [50, 50] }); }); }); describe("road routing (pure)", () => { it("connects roads that share a vertex", () => { const roads: RoadInput[] = [ { id: 1, points: [ [0, 0], [1000, 0], ], speedMultiplier: 1, }, { id: 2, points: [ [1000, 0], [1000, 1000], ], speedMultiplier: 1, }, ]; const graph = buildRoadGraph(roads); const route = findRoute(graph, [0, 0], [1000, 1000]); expect(route.connected).toBe(true); if (route.connected) { expect(route.distanceMeters).toBeCloseTo(2000, 0); expect(route.path.length).toBeGreaterThanOrEqual(3); } }); it("refuses when an endpoint is beyond the snap radius", () => { const graph = buildRoadGraph([ { id: 1, points: [ [0, 0], [1000, 0], ], speedMultiplier: 1, }, ]); const route = findRoute(graph, [5000, 5000], [500, 0]); expect(route.connected).toBe(false); }); it("refuses when origin and destination are on disconnected components", () => { const graph = buildRoadGraph([ { id: 1, points: [ [0, 0], [1000, 0], ], speedMultiplier: 1, }, { id: 2, points: [ [5000, 5000], [6000, 5000], ], speedMultiplier: 1, }, ]); const route = findRoute(graph, [0, 0], [5500, 5000]); expect(route.connected).toBe(false); }); it("prefers the faster road even when it is geometrically longer", () => { const graph = buildRoadGraph([ { id: 1, points: [ [0, 0], [1000, 0], ], speedMultiplier: 0.5, }, { id: 2, points: [ [0, 0], [500, 300], [1000, 0], ], speedMultiplier: 1, }, ]); const route = findRoute(graph, [0, 0], [1000, 0]); expect(route.connected).toBe(true); if (route.connected) { expect(route.distanceMeters).toBeGreaterThan(1100); expect(route.path.some((point) => point[0] === 500 && point[1] === 300)).toBe(true); } }); }); describe("shipment routing", () => { it("refuses cross-map shipments", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const vehicle = await makeGameVehicle({ name: `${RUN} cross-map truck`, blueprint: truckBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: destB.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 1 }], }); expect(result.success).toBe(false); expect(result.error).toContain("Cross-map"); }); it("refuses ground shipments with no road network", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const vehicle = await makeGameVehicle({ name: `${RUN} no-road truck`, blueprint: truckBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: destA.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 1 }], }); expect(result.success).toBe(false); expect(result.error).toContain("No connected road route"); }); it("routes ground shipments over the road network and snapshots the path", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); await makeRoad(mapA.id, [ [1000, 1000], [3000, 1000], [3000, 6000], [6000, 6000], ]); await payload.update({ collection: "game-structures", id: originA.id, data: { storedResources: [{ resource: steel.id, amount: 10, gridX: 0, gridY: 0, rotated: false }], }, overrideAccess: true, }); const vehicle = await makeGameVehicle({ name: `${RUN} routed truck`, blueprint: truckBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: destA.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 5 }], }); expect(result.success).toBe(true); const shipmentId = (result.data as Shipment).id; createdIds.shipments.push(shipmentId); const shipment = (await payload.findByID({ collection: "shipments", id: shipmentId, depth: 1, overrideAccess: true, })) as unknown as Shipment; expect(typeof shipment.map === "object" ? shipment.map?.id : shipment.map).toBe(mapA.id); expect(shipment.distance).toBeCloseTo(10000, 0); expect(shipment.fuelCost).toBe(Math.ceil(10000 * 0.01)); const path = shipment.routePath as [number, number][]; expect(path.length).toBeGreaterThanOrEqual(5); expect(path[0]).toEqual([1000, 1000]); expect(path[path.length - 1]).toEqual([6000, 6000]); }); it("refuses ground shipments whose endpoints snap to a disconnected road", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); await makeRoad(mapA.id, [ [7000, 7000], [8000, 8000], ]); const far = await makeGameStructure({ name: `${RUN} Far Yard`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [7050, 7050], }); createdIds.gameStructures.push(far.id); const vehicle = await makeGameVehicle({ name: `${RUN} stranded truck`, blueprint: truckBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: far.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 1 }], }); expect(result.success).toBe(false); expect(result.error).toContain("No connected road route"); }); it("ships straight-line with an air vehicle and no roads", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); await payload.update({ collection: "game-structures", id: originA.id, data: { storedResources: [{ resource: steel.id, amount: 10, gridX: 0, gridY: 0, rotated: false }], }, overrideAccess: true, }); const vehicle = await makeGameVehicle({ name: `${RUN} airlift`, blueprint: aircraftBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: destA.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 1 }], }); expect(result.success).toBe(true); const shipmentId = (result.data as Shipment).id; createdIds.shipments.push(shipmentId); const shipment = (await payload.findByID({ collection: "shipments", id: shipmentId, depth: 1, overrideAccess: true, })) as unknown as Shipment; expect(shipment.distance).toBeCloseTo(Math.round(Math.sqrt(2) * 5000 * 100) / 100, 1); expect(shipment.routePath).toHaveLength(2); }); it("refuses shipments inside the proximity threshold", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const near = await makeGameStructure({ name: `${RUN} Adjacent`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [1500, 1000], }); createdIds.gameStructures.push(near.id); const vehicle = await makeGameVehicle({ name: `${RUN} short hop`, blueprint: aircraftBlueprint, atId: originA.id, }); createdIds.gameVehicles.push(vehicle.id); const result = await createShipment({ originId: originA.id, destinationId: near.id, vehicleId: vehicle.id, cargo: [{ relationTo: "resources", value: steel.id, amount: 1 }], }); expect(result.success).toBe(false); expect(result.error).toContain("proximity"); }); }); describe("placement validation (pure)", () => { const waterZone: ZoneInput = { id: 1, name: "bay", type: "water", points: [ [0, 0], [100, 0], [100, 100], [0, 100], ], }; it("blocks land blueprints inside water zones and allows outside", () => { expect(checkTerrain([waterZone], "land", [50, 50]).allowed).toBe(false); expect(checkTerrain([waterZone], "land", [200, 200]).allowed).toBe(true); }); it("requires water blueprints inside a water zone", () => { expect(checkTerrain([waterZone], "water", [50, 50]).allowed).toBe(true); expect(checkTerrain([waterZone], "water", [200, 200]).allowed).toBe(false); }); it("accepts everything when a map has no zones", () => { expect(checkTerrain([], "land", [50, 50]).allowed).toBe(true); }); it("validates resourcesInRange against nodes", () => { const nodes: NodeInput[] = [ { id: 1, resources: [steel.id], position: [500, 500] }, { id: 2, resources: [diesel.id], position: [5000, 5000] }, ]; const mixed: NodeInput[] = [ { id: 3, resources: [steel.id, diesel.id], position: [500, 500] }, ]; const mixedOk = checkResourcesInRange( mixed, [ { resource: steel.id, range: 1000 }, { resource: diesel.id, range: 1000 }, ], [600, 600], ); expect(mixedOk.allowed).toBe(true); const ok = checkResourcesInRange(nodes, [{ resource: steel.id, range: 1000 }], [600, 600]); expect(ok.allowed).toBe(true); const wrongType = checkResourcesInRange( nodes, [{ resource: diesel.id, range: 1000 }], [600, 600], ); expect(wrongType.allowed).toBe(false); const tooFar = checkResourcesInRange( nodes, [{ resource: steel.id, range: 100 }], [1500, 500], ); expect(tooFar.allowed).toBe(false); }); it("satisfies resource requirements from resource-bearing zones at the site", () => { const zone: ZoneInput = { id: 9, name: "Oil field", type: "land", resources: [steel.id], points: [ [0, 0], [2000, 0], [2000, 2000], [0, 2000], ], }; const inside = checkResourcesInRange( [], [{ resource: steel.id, range: 1000 }], [1000, 1000], [zone], ); expect(inside.allowed).toBe(true); const outside = checkResourcesInRange( [], [{ resource: steel.id, range: 1000 }], [5000, 5000], [zone], ); expect(outside.allowed).toBe(false); const wrongResource = checkResourcesInRange( [], [{ resource: diesel.id, range: 1000 }], [1000, 1000], [zone], ); expect(wrongResource.allowed).toBe(false); }); it("validates structuresInRange against existing structures", () => { const structures: StructureInput[] = [ { id: 1, type: depotBlueprint.id, coordinates: [500, 500] }, ]; const ok = checkStructuresInRange( structures, [{ structure: depotBlueprint.id, range: 1000 }], [800, 800], ); expect(ok.allowed).toBe(true); const missing = checkStructuresInRange( structures, [{ structure: depotBlueprint.id, range: 100 }], [5000, 5000], ); expect(missing.allowed).toBe(false); }); }); describe("placeStructure action", () => { it("rejects users without the logistics qualification", async () => { authSpy.mockResolvedValue({ user: plainUser }); const result = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Denied`, x: 3000, y: 3000, }); expect(result.success).toBe(false); expect(result.error).toContain("Logistics qualification"); }); it("places a site that awaits materials and emits structure:placed", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const result = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Site`, x: 3000, y: 3000, }); expect(result.success).toBe(true); const siteId = (result.data as GameStructure).id; createdIds.gameStructures.push(siteId); const site = (await payload.findByID({ collection: "game-structures", id: siteId, depth: 1, overrideAccess: true, })) as unknown as GameStructure; expect(typeof site.map === "object" ? site.map?.id : site.map).toBe(mapA.id); expect(site.constructionStatus).toBe("awaiting_materials"); expect(site.coordinates).toEqual([3000, 3000]); const events = await payload.find({ collection: "game-event-logs", where: { and: [{ type: { equals: EventTypes.StructurePlaced } }, { targetId: { equals: siteId } }], }, limit: 1, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); }); it("refuses placement inside a drawn water zone", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const zone = await payload.create({ collection: "map-zones", data: { map: mapA.id, name: `${RUN} Lake`, type: "water", points: [ [2500, 2500], [4000, 2500], [4000, 4000], [2500, 4000], ], }, overrideAccess: true, depth: 0, }); createdIds.zones.push(zone.id); const result = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Wet Site`, x: 3000, y: 3000, }); expect(result.success).toBe(false); expect(result.error).toContain("water"); }); it("refuses placement without a required resource node in range", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const result = await placeStructure({ mapId: mapA.id, blueprintId: rangeBlueprint.id, name: `${RUN} Orphan Mine`, x: 5000, y: 2000, }); expect(result.success).toBe(false); expect(result.error).toContain("resource node"); }); it("accepts placement once a matching node exists in range", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const node = await payload.create({ collection: "resource-nodes", data: { map: mapA.id, name: `${RUN} Steel seam`, resources: [steel.id], position: [5400, 1900], richness: 50, }, overrideAccess: true, depth: 0, }); createdIds.nodes.push(node.id); const result = await placeStructure({ mapId: mapA.id, blueprintId: rangeBlueprint.id, name: `${RUN} Mine Head Site`, x: 5000, y: 2000, }); expect(result.success).toBe(true); createdIds.gameStructures.push((result.data as GameStructure).id); }); it("satisfies resource requirements from a zone carrying the resource", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const zone = await payload.create({ collection: "map-zones", data: { map: mapA.id, name: `${RUN} Steel Basin`, type: "land", resources: [steel.id], points: [ [6000, 1000], [7600, 1000], [7600, 2600], [6000, 2600], ], }, overrideAccess: true, depth: 0, }); createdIds.zones.push(zone.id); const inside = await placeStructure({ mapId: mapA.id, blueprintId: rangeBlueprint.id, name: `${RUN} Basin Site`, x: 6800, y: 1800, }); expect(inside.success).toBe(true); createdIds.gameStructures.push((inside.data as GameStructure).id); const outside = await placeStructure({ mapId: mapA.id, blueprintId: rangeBlueprint.id, name: `${RUN} Basin Edge Site`, x: 2000, y: 6000, }); expect(outside.success).toBe(false); expect(outside.error).toContain("resource node"); }); it("accepts political-boundary and generic zone types without gating placement", async () => { const territory = await payload.create({ collection: "map-zones", data: { map: mapA.id, name: `${RUN} Territory`, type: "territory", points: [ [0, 0], [1000, 0], [1000, 1000], [0, 1000], ], }, overrideAccess: true, depth: 0, }); createdIds.zones.push(territory.id); expect(territory.type).toBe("territory"); const border = await payload.create({ collection: "map-zones", data: { map: mapA.id, name: `${RUN} Border`, type: "border", points: [ [4000, 6000], [5000, 6000], [5000, 7000], [4000, 7000], ], }, overrideAccess: true, depth: 0, }); createdIds.zones.push(border.id); expect(border.type).toBe("border"); const other = await payload.create({ collection: "map-zones", data: { map: mapA.id, name: `${RUN} Compound`, type: "other", points: [ [3000, 5000], [3800, 5000], [3800, 5600], [3000, 5600], ], }, overrideAccess: true, depth: 0, }); createdIds.zones.push(other.id); expect(other.type).toBe("other"); // Political zones are informational: a land blueprint inside one places // normally (only water zones gate placement). authSpy.mockResolvedValue({ user: logisticsUser }); const inside = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Territory Site`, x: 500, y: 500, }); expect(inside.success).toBe(true); createdIds.gameStructures.push((inside.data as GameStructure).id); }); it("stores road labels as tri-state with paved-only auto default", async () => { const dirt = await payload.create({ collection: "map-roads", data: { map: mapA.id, name: `${RUN} Dirt Track`, surface: "dirt", speedMultiplier: 1, points: [ [0, 9000], [1000, 9000], ], }, overrideAccess: true, depth: 0, }); createdIds.roads.push(dirt.id); expect(dirt.labelVisible).toBeNull(); const paved = await payload.create({ collection: "map-roads", data: { map: mapA.id, name: `${RUN} Paved Way`, surface: "paved", speedMultiplier: 1, labelVisible: false, points: [ [0, 9500], [1000, 9500], ], }, overrideAccess: true, depth: 0, }); createdIds.roads.push(paved.id); expect(paved.labelVisible).toBe(false); }); }); describe("compound aggregation (pure)", () => { const member = (over: Partial & { id: number }): CompoundMemberLike => ({ constructionStatus: "complete", storageUsedKg: 0, storageCapacityKg: 1000, ...over, }); it("pools operational children into the hub and propagates uncapped members", () => { const hub = member({ id: 1, storageUsedKg: 300, storageCapacityKg: 1000 }); const totals = aggregateCompoundStorage(hub, [ member({ id: 2, storageUsedKg: 150, storageCapacityKg: 500 }), member({ id: 3, storageUsedKg: 250, storageCapacityKg: 500, constructionStatus: "building", }), member({ id: 4, storageUsedKg: 90, storageCapacityKg: null }), ]); expect(totals.storageUsedKg).toBe(540); expect(totals.storageCapacityKg).toBeNull(); }); it("keeps caps additive when every member is capped", () => { const hub = member({ id: 1, storageUsedKg: 300, storageCapacityKg: 1000 }); expect( aggregateCompoundStorage(hub, [ member({ id: 2, storageUsedKg: 100, storageCapacityKg: 500 }), ]), ).toEqual({ storageUsedKg: 400, storageCapacityKg: 1500 }); }); }); describe("compound placement", () => { it("stores the hub on a child placement", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const hub = await makeGameStructure({ name: `${RUN} Hub`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [3000, 3500], constructionStatus: "complete", }); createdIds.gameStructures.push(hub.id); const result = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Annex`, parentStructureId: hub.id, x: 4500, y: 3500, }); expect(result.success).toBe(true); const childId = (result.data as GameStructure).id; createdIds.gameStructures.push(childId); const child = (await payload.findByID({ collection: "game-structures", id: childId, depth: 0, overrideAccess: true, })) as unknown as GameStructure; expect(child.parentStructure).toBe(hub.id); }); it("refuses hubs on another map or hubs that are themselves children", async () => { authSpy.mockResolvedValue({ user: logisticsUser }); const hub = await makeGameStructure({ name: `${RUN} Far Hub`, blueprintId: depotBlueprint.id, mapId: mapB.id, point: [3000, 3500], constructionStatus: "complete", }); createdIds.gameStructures.push(hub.id); const crossMap = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Cross Map Child`, parentStructureId: hub.id, x: 3200, y: 3500, }); expect(crossMap.success).toBe(false); expect(crossMap.error).toContain("different map"); const childHub = (await payload.create({ collection: "game-structures", data: { name: `${RUN} Nested Hub`, type: depotBlueprint.id, map: mapA.id, coordinates: [3400, 3500], constructionStatus: "complete", parentStructure: hub.id, }, overrideAccess: true, depth: 0, })) as unknown as GameStructure; createdIds.gameStructures.push(childHub.id); const nested = await placeStructure({ mapId: mapA.id, blueprintId: depotBlueprint.id, name: `${RUN} Nested Child`, parentStructureId: childHub.id, x: 3600, y: 3500, }); expect(nested.success).toBe(false); expect(nested.error).toContain("cannot themselves be compound children"); }); }); describe("construction timestamps", () => { it("starts construction at delivery time and sweeps complete after the deadline", async () => { const site = await makeGameStructure({ name: `${RUN} Construction Site`, blueprintId: depotBlueprint.id, mapId: mapA.id, point: [2000, 2000], constructionStatus: "awaiting_materials", }); createdIds.gameStructures.push(site.id); const fetchSite = async () => (await payload.findByID({ collection: "game-structures", id: site.id, depth: 1, overrideAccess: true, })) as unknown as GameStructure; // The sweep ignores awaiting sites (starts happen at delivery time only); // other sites may legitimately complete, so assert on this site's state. await processConstructionTick(payload); let doc = await fetchSite(); expect(doc.constructionStatus).toBe("awaiting_materials"); // Missing materials: no start, storage untouched. const missing = await maybeStartConstruction(payload, site.id); expect(missing.started).toBe(false); expect(missing.reason).toBe("missing_materials"); // Deliver the materials and start construction at a fixed instant. await payload.update({ collection: "game-structures", id: site.id, data: { storedResources: [{ resource: steel.id, amount: 5, gridX: 0, gridY: 0, rotated: false }], }, overrideAccess: true, }); const startedAt = new Date(); const start = await maybeStartConstruction(payload, site.id, startedAt); expect(start.started).toBe(true); doc = await fetchSite(); expect(doc.constructionStatus).toBe("building"); expect(doc.storedResources ?? []).toHaveLength(0); expect(doc.voidStorage ?? []).toHaveLength(0); expect(new Date(doc.constructionStartedAt ?? 0).getTime()).toBe(startedAt.getTime()); expect(new Date(doc.constructionCompletesAt ?? 0).getTime()).toBe( startedAt.getTime() + 30 * 60 * 1000, ); // Starting again is a no-op (site is no longer awaiting materials). const again = await maybeStartConstruction(payload, site.id); expect(again.started).toBe(false); expect(again.reason).toBe("not_awaiting"); // Sweep before the deadline: the site keeps building. await processConstructionTick(payload, undefined, startedAt); doc = await fetchSite(); expect(doc.constructionStatus).toBe("building"); // Sweep after the deadline: the site completes. await processConstructionTick( payload, undefined, new Date(startedAt.getTime() + 30 * 60 * 1000 + 1000), ); doc = await fetchSite(); expect(doc.constructionStatus).toBe("complete"); // Idempotent: a further sweep does not emit a second event. await processConstructionTick(payload); const events = await payload.find({ collection: "game-event-logs", where: { and: [ { type: { equals: EventTypes.StructureConstructionComplete } }, { targetId: { equals: site.id } }, ], }, limit: 2, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); }); }); describe("permission gating", () => { it("denies anonymous access to map feature collections", async () => { const payloadConfig = await config; const roadsConfig = payloadConfig.collections.find( (collection) => collection.slug === "map-roads", ); expect(roadsConfig).toBeDefined(); const readAccess = roadsConfig!.access?.read as unknown as (args: { req: { payload: Payload; user: null }; }) => Promise; const createAccess = roadsConfig!.access?.create as unknown as (args: { req: { payload: Payload; user: null }; }) => Promise; expect(await readAccess({ req: { payload, user: null } })).toBe(false); expect(await createAccess({ req: { payload, user: null } })).toBe(false); }); it("grants map feature writes to holders of maps:create", async () => { const { hasPermission } = await import("@/utils/access-control/hasPermission"); expect(await hasPermission(payload, mapper, "maps:create")).toBe(true); expect(await hasPermission(payload, mapper, "maps:delete")).toBe(false); const payloadConfig = await config; const roadsConfig = payloadConfig.collections.find( (collection) => collection.slug === "map-roads", ); const createAccess = roadsConfig!.access?.create as unknown as (args: { req: { payload: Payload; user: User }; }) => Promise; expect(await createAccess({ req: { payload, user: mapper } })).toBe(true); }); it("edits an existing road when maps:update is granted and guards cross-map edits", async () => { authSpy.mockResolvedValue({ user: mapper }); const road = await makeRoad(mapA.id, [ [500, 500], [1000, 800], ]); const result = await saveMapRoad({ id: road.id, mapId: mapA.id, name: `${RUN} renamed road`, surface: "dirt", speedMultiplier: 2, points: [ [500, 500], [1200, 900], ], }); expect(result.success).toBe(true); const updated = await payload.findByID({ collection: "map-roads", id: road.id, depth: 0, overrideAccess: true, }); expect(updated.name).toBe(`${RUN} renamed road`); expect(updated.surface).toBe("dirt"); expect(updated.speedMultiplier).toBe(2); expect(updated.points).toEqual([ [500, 500], [1200, 900], ]); const crossMap = await saveMapRoad({ id: road.id, mapId: mapB.id, name: "wrong map", surface: "trail", speedMultiplier: 1, points: [ [0, 0], [10, 10], ], }); expect(crossMap.success).toBe(false); expect(crossMap.error).toContain("different map"); }); it("rejects roads with invalid stroke colors and persists valid styling", async () => { authSpy.mockResolvedValue({ user: mapper }); const badColor = await saveMapRoad({ mapId: mapA.id, name: `${RUN} bad color`, surface: "paved", speedMultiplier: 1, strokeColor: "red", points: [ [0, 0], [10, 10], ], }); expect(badColor.success).toBe(false); expect(badColor.error).toContain("hex color"); const styled = await saveMapRoad({ mapId: mapA.id, name: `${RUN} styled road`, description: `${RUN} description`, surface: "dirt", speedMultiplier: 1.5, strokeColor: "#cbd5e1", strokeWeight: 4, strokeStyle: "dashed", labelVisible: false, points: [ [3000, 3000], [4000, 3000], ], }); expect(styled.success).toBe(true); const createdRoad = await payload.findByID({ collection: "map-roads", id: (styled.data as { id: number }).id, depth: 0, overrideAccess: true, }); expect(createdRoad.description).toBe(`${RUN} description`); expect(createdRoad.strokeColor).toBe("#cbd5e1"); expect(createdRoad.strokeWeight).toBe(4); expect(createdRoad.strokeStyle).toBe("dashed"); expect(createdRoad.labelVisible).toBe(false); const restyled = await saveMapRoad({ id: (styled.data as { id: number }).id, mapId: mapA.id, name: `${RUN} styled road`, description: null, surface: "dirt", speedMultiplier: 1.5, strokeColor: null, strokeWeight: null, strokeStyle: null, points: [ [3000, 3000], [4000, 3000], ], }); expect(restyled.success).toBe(true); const cleared = await payload.findByID({ collection: "map-roads", id: (styled.data as { id: number }).id, depth: 0, overrideAccess: true, }); expect(cleared.description).toBeNull(); expect(cleared.strokeColor).toBeNull(); expect(cleared.strokeWeight).toBeNull(); expect(cleared.strokeStyle).toBeNull(); }); it("rejects zones with invalid colors and persists zone styling", async () => { authSpy.mockResolvedValue({ user: mapper }); const badOpacity = await saveMapZone({ mapId: mapA.id, name: `${RUN} bad fill`, type: "water", fillColor: "not-a-color", points: [ [6000, 6000], [7000, 6000], [7000, 7000], [6000, 7000], ], }); expect(badOpacity.success).toBe(false); expect(badOpacity.error).toContain("hex color"); const styled = await saveMapZone({ mapId: mapA.id, name: `${RUN} styled zone`, description: `${RUN} zone description`, type: "water", fillColor: "#38bdf8", strokeColor: "#0ea5e9", fillOpacity: 0.4, strokeWeight: 2, points: [ [6000, 6000], [7000, 6000], [7000, 7000], [6000, 7000], ], }); expect(styled.success).toBe(true); const createdZone = await payload.findByID({ collection: "map-zones", id: (styled.data as { id: number }).id, depth: 0, overrideAccess: true, }); expect(createdZone.description).toBe(`${RUN} zone description`); expect(createdZone.fillColor).toBe("#38bdf8"); expect(createdZone.strokeColor).toBe("#0ea5e9"); expect(createdZone.fillOpacity).toBe(0.4); expect(createdZone.strokeWeight).toBe(2); }); it("persists node descriptions", async () => { authSpy.mockResolvedValue({ user: mapper }); const described = await saveMapNode({ mapId: mapA.id, name: `${RUN} described node`, description: `${RUN} node description`, resourceIds: [steel.id], position: [7500, 7500], richness: 80, }); expect(described.success).toBe(true); const createdNode = await payload.findByID({ collection: "resource-nodes", id: (described.data as { id: number }).id, depth: 0, overrideAccess: true, }); expect(createdNode.description).toBe(`${RUN} node description`); expect(createdNode.richness).toBe(80); }); }); });