1
0
Fork 0
polaris-task-force/tests/int/map-system.int.spec.ts
Z8MB1E c64c9da0da feat(map): road-network routing for shipments with transport modes
Add pure routing, placement, point, and style libs plus map-system tests. Shipments gain a map relationship and routePath snapshot; ground vehicles route over the road network via Dijkstra while air and sea travel straight-line. Vehicle types declare a ground/air/sea transportMode and transit math works in meters.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:01 -04:00

1403 lines
42 KiB
TypeScript

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 {
metersPerPx,
polygonCentroid,
roadLabelPoints,
splinePolyline,
validateHexColor,
} from "@/lib/map/style";
import {
checkResourcesInRange,
checkStructuresInRange,
checkTerrain,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
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<MapDoc> {
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<Resource> {
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<Structure> {
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<Vehicle> {
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<GameStructure> {
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<GameVehicle> {
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("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("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");
});
});
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<boolean>;
const createAccess = roadsConfig!.access?.create as unknown as (args: {
req: { payload: Payload; user: null };
}) => Promise<boolean>;
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<boolean>;
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);
});
});
});