1
0
Fork 0
polaris-task-force/tests/int/map-system.int.spec.ts
Z8MB1E 56fb6ac26a feat(map): gate feature authoring and structure placement behind dedicated permissions
Replace the maps:create/update/delete catch-all for map features: roads, zones, and nodes each take a per-collection author permission, structure placement takes maps:place (still co-gated with the logistics qualification), and GeoJSON import requires all three author permissions. The map view page keys edit UI per feature type; admin role seed grants the new values. Tests updated for the permission split.
2026-09-30 14:50:14 -04:00

1787 lines
55 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 { 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<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", "maps:place"],
},
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",
"maps:place",
"map-roads:read",
"map-roads:author",
"map-zones:author",
"resource-nodes:author",
"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<CompoundMemberLike> & { 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<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("gates map feature authoring behind per-tool permissions", async () => {
const { hasPermission } = await import("@/utils/access-control/hasPermission");
expect(await hasPermission(payload, mapper, "map-roads:author")).toBe(true);
expect(await hasPermission(payload, mapper, "map-zones:author")).toBe(true);
expect(await hasPermission(payload, mapper, "resource-nodes:author")).toBe(true);
expect(await hasPermission(payload, mapper, "maps:place")).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 | null };
}) => Promise<boolean>;
expect(await createAccess({ req: { payload, user: mapper } })).toBe(true);
// A user holding maps:create but no author permission is denied at the
// collection layer too.
expect(await createAccess({ req: { payload, user: null } })).toBe(false);
});
it("edits an existing road when road authoring 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);
});
});
});