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>
1403 lines
42 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|