From c64c9da0dae7dda088369ffd23a61fb9a12ba47e Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 16 Sep 2026 23:14:01 -0400 Subject: [PATCH] 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 --- .../(frontend)/logistics/shipments/actions.ts | 85 +- src/collections/logistics/Shipments.ts | 15 + src/collections/logistics/Vehicles.ts | 15 + src/lib/map/placement.ts | 195 +++ src/lib/map/points.ts | 142 ++ src/lib/map/routing.ts | 253 +++ src/lib/map/style.ts | 149 ++ src/lib/map/types.ts | 101 ++ src/lib/shipping.ts | 37 +- tests/int/map-system.int.spec.ts | 1403 +++++++++++++++++ 10 files changed, 2366 insertions(+), 29 deletions(-) create mode 100644 src/lib/map/placement.ts create mode 100644 src/lib/map/points.ts create mode 100644 src/lib/map/routing.ts create mode 100644 src/lib/map/style.ts create mode 100644 src/lib/map/types.ts create mode 100644 tests/int/map-system.int.spec.ts diff --git a/src/app/(frontend)/logistics/shipments/actions.ts b/src/app/(frontend)/logistics/shipments/actions.ts index 5ce06dc..3958c32 100644 --- a/src/app/(frontend)/logistics/shipments/actions.ts +++ b/src/app/(frontend)/logistics/shipments/actions.ts @@ -9,6 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes"; import { calculateDistance } from "@/lib/distance"; import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; +import { buildRoadGraph, findRoute } from "@/lib/map/routing"; +import { isPointArray, type MapPoint } from "@/lib/map/points"; interface ActionResult { success: boolean; @@ -95,20 +97,6 @@ export async function createShipment(params: { }; } - // Calculate distance - const distance = calculateDistance(originCoords, destCoords); - - // Check proximity threshold - const gameRules = await payload.findGlobal({ slug: "game-rules" }); - const threshold = (gameRules.proximityThreshold as number) || 10; - - if (distance <= threshold) { - return { - success: false, - error: `Structures are within proximity (${distance.toFixed(1)} ≤ ${threshold}). Use instant transfer instead.`, - }; - } - // Fetch vehicle const vehicle = (await payload .findByID({ @@ -146,6 +134,69 @@ export async function createShipment(params: { return { success: false, error: "Vehicle type not found." }; } + const originMapId = typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null); + const destMapId = + typeof destination.map === "object" ? (destination.map?.id ?? null) : (destination.map ?? null); + + if (!originMapId || !destMapId) { + return { + success: false, + error: "Both structures must be placed on a game map before shipping between them.", + }; + } + + if (originMapId !== destMapId) { + return { + success: false, + error: `Cross-map shipments are not supported: "${origin.name}" and "${destination.name}" are on different maps.`, + }; + } + + // Calculate route distance. Ground vehicles path over the road network; + // air and sea vehicles travel straight-line. + const transportMode = vehicleType.transportMode ?? "ground"; + let distance: number; + let routePath: MapPoint[] | null = null; + + if (transportMode === "ground") { + const roads = await payload.find({ + collection: "map-roads", + where: { map: { equals: originMapId } }, + limit: 5000, + depth: 0, + }); + const graph = buildRoadGraph( + roads.docs.map((road) => ({ + id: road.id, + points: (road.points ?? []) as MapPoint[], + speedMultiplier: road.speedMultiplier, + })), + ); + const route = findRoute(graph, originCoords, destCoords); + if (!route.connected) { + return { + success: false, + error: `No connected road route between "${origin.name}" and "${destination.name}". Ground vehicles need roads joining both structures; use an air or sea vehicle instead.`, + }; + } + distance = route.distanceMeters; + routePath = route.path; + } else { + distance = calculateDistance(originCoords, destCoords); + routePath = [originCoords, destCoords]; + } + + // Check proximity threshold + const gameRules = await payload.findGlobal({ slug: "game-rules" }); + const threshold = (gameRules.proximityThreshold as number) || 1000; + + if (distance <= threshold) { + return { + success: false, + error: `Structures are within proximity (${Math.round(distance)} m ≤ ${threshold} m). Use instant transfer instead.`, + }; + } + // Calculate fuel cost const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1; const fuelCost = calculateFuelCost(distance, consumptionRate); @@ -283,9 +334,7 @@ export async function createShipment(params: { } // Calculate transit time - const speed = getVehicleEffectiveSpeed( - vehicleType as unknown as Parameters[0], - ); + const speed = getVehicleEffectiveSpeed(vehicleType); const transitTimeMs = calculateTransitTime(distance, speed); const now = new Date(); @@ -332,6 +381,8 @@ export async function createShipment(params: { name: `SHP-${Date.now().toString(36).toUpperCase()}`, origin: originId, destination: destinationId, + map: originMapId, + routePath, transportVehicle: vehicleId, distance: Math.round(distance * 100) / 100, cargo: cargo.map((c) => ({ diff --git a/src/collections/logistics/Shipments.ts b/src/collections/logistics/Shipments.ts index c85eaa2..edfa2c7 100644 --- a/src/collections/logistics/Shipments.ts +++ b/src/collections/logistics/Shipments.ts @@ -41,6 +41,21 @@ export const Shipments: CollectionConfig = { relationTo: "game-structures", required: true, }, + { + name: "map", + type: "relationship", + relationTo: "maps", + admin: { + description: "The game map this shipment travels on. Cross-map shipments are not supported.", + }, + }, + { + name: "routePath", + type: "json", + admin: { + description: "Snapshot polyline of the route as [x, y] meter tuples, for map rendering.", + }, + }, // Transport { name: "transportVehicle", diff --git a/src/collections/logistics/Vehicles.ts b/src/collections/logistics/Vehicles.ts index f772a9d..851fcc5 100644 --- a/src/collections/logistics/Vehicles.ts +++ b/src/collections/logistics/Vehicles.ts @@ -241,6 +241,21 @@ export const Vehicles: CollectionConfig = { label: "Logistics & Grid Storage", type: "group", fields: [ + { + name: "transportMode", + type: "select", + required: true, + defaultValue: "ground", + options: [ + { label: "Ground", value: "ground" }, + { label: "Air", value: "air" }, + { label: "Sea", value: "sea" }, + ], + admin: { + description: + "Ground vehicles route over the road network and refuse shipments with no connected route. Air and sea vehicles travel straight-line.", + }, + }, { label: "Storage Grid Dimensions", type: "group", diff --git a/src/lib/map/placement.ts b/src/lib/map/placement.ts new file mode 100644 index 0000000..4784e08 --- /dev/null +++ b/src/lib/map/placement.ts @@ -0,0 +1,195 @@ +/** + * Structure placement validation: terrain zones, resourcesInRange, + * structuresInRange. + * + * Pure module: no Payload import, safe on client and server. The server-side + * action fetches zones/nodes/structures and passes them in; the same functions + * power the client-side pre-validation in the placement dialog. + */ + +import { distanceSquared, pointInPolygon, type MapPoint } from "./points"; + +export { pointDistance } from "./points"; + +export type BlueprintTerrain = "land" | "water"; + +export interface ZoneInput { + id: number; + name?: string | null; + type: "water" | "land"; + /** Resource ids contained in this zone; a site inside satisfies their requirements. */ + resources?: number[]; + points: MapPoint[]; +} + +export interface NodeInput { + id: number; + /** Resource ids this node yields (a node may hold several). */ + resources: number[]; + position: MapPoint; +} + +export interface StructureInput { + id: number; + type: number; + coordinates: MapPoint; +} + +export interface RangeRequirement { + resource?: number | { id: number } | null; + structure?: number | { id: number } | null; + range?: number | null; +} + +export interface TerrainCheck { + allowed: boolean; + error?: string; +} + +/** + * Terrain rule: default terrain is buildable land; drawn water polygons block + * land-blueprint placement; water blueprints must sit inside a water zone. A + * map with no zones accepts everything. + */ +export function checkTerrain( + zones: readonly ZoneInput[], + blueprintTerrain: BlueprintTerrain, + point: MapPoint, +): TerrainCheck { + const waterZones = zones.filter((zone) => zone.type === "water"); + const containingWater = waterZones.find((zone) => pointInPolygon(point, zone.points)); + + if (blueprintTerrain === "land") { + if (containingWater) { + return { + allowed: false, + error: `This structure cannot be built in water (inside zone "${containingWater.name ?? `#${containingWater.id}`}").`, + }; + } + return { allowed: true }; + } + + if (!containingWater) { + return { + allowed: false, + error: + "This structure must be placed in water, but the site is not inside a drawn water zone.", + }; + } + return { allowed: true }; +} + +export interface RangeCheck { + allowed: boolean; + error?: string; +} + +function requirementId( + value: RangeRequirement["resource"] | RangeRequirement["structure"], +): number | null { + if (typeof value === "number") return value; + return value?.id ?? null; +} + +/** + * resourcesInRange: every blueprint requirement needs at least one matching + * resource node within range (meters) of the site, OR a containing zone that + * carries the resource (area deposits like oil fields: inside = satisfied). + */ +export function checkResourcesInRange( + nodes: readonly NodeInput[], + requirements: readonly RangeRequirement[], + point: MapPoint, + zones: readonly ZoneInput[] = [], +): RangeCheck { + for (const requirement of requirements) { + const resourceId = requirementId(requirement.resource); + if (resourceId === null) continue; + const range = requirement.range ?? 0; + const maxSq = range * range; + const nodeMatch = nodes.some( + (node) => + node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq, + ); + const zoneMatch = zones.some( + (zone) => + (zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points), + ); + if (!nodeMatch && !zoneMatch) { + return { + allowed: false, + error: `Placement requires a matching resource node within ${range} m of the site, or a zone containing the resource at the site.`, + }; + } + } + return { allowed: true }; +} + +/** + * structuresInRange: every blueprint requirement needs at least one existing + * structure of the given blueprint type within range (meters) of the site. + */ +export function checkStructuresInRange( + structures: readonly StructureInput[], + requirements: readonly RangeRequirement[], + point: MapPoint, +): RangeCheck { + for (const requirement of requirements) { + const structureId = requirementId(requirement.structure); + if (structureId === null) continue; + const range = requirement.range ?? 0; + const maxSq = range * range; + const inRange = structures.some( + (structure) => + structure.type === structureId && distanceSquared(structure.coordinates, point) <= maxSq, + ); + if (!inRange) { + return { + allowed: false, + error: `Placement requires a structure of the required type within ${range} m of the site.`, + }; + } + } + return { allowed: true }; +} + +export interface PlacementValidationInput { + zones: readonly ZoneInput[]; + nodes: readonly NodeInput[]; + structures: readonly StructureInput[]; + blueprintTerrain: BlueprintTerrain; + resourcesInRange?: readonly RangeRequirement[]; + structuresInRange?: readonly RangeRequirement[]; + point: MapPoint; +} + +export interface PlacementValidationResult { + allowed: boolean; + error?: string; +} + +export function validatePlacement(input: PlacementValidationInput): PlacementValidationResult { + const terrain = checkTerrain(input.zones, input.blueprintTerrain, input.point); + if (!terrain.allowed) return terrain; + + if (input.resourcesInRange && input.resourcesInRange.length > 0) { + const resources = checkResourcesInRange( + input.nodes, + input.resourcesInRange, + input.point, + input.zones, + ); + if (!resources.allowed) return resources; + } + + if (input.structuresInRange && input.structuresInRange.length > 0) { + const structures = checkStructuresInRange( + input.structures, + input.structuresInRange, + input.point, + ); + if (!structures.allowed) return structures; + } + + return { allowed: true }; +} diff --git a/src/lib/map/points.ts b/src/lib/map/points.ts new file mode 100644 index 0000000..a3f40c6 --- /dev/null +++ b/src/lib/map/points.ts @@ -0,0 +1,142 @@ +/** + * Shared point/line/polygon helpers for map features. + * + * All map feature geometry is authored in real meters on a per-map Cartesian + * grid (origin top-left, x east, y south, matching Arma's world coordinates). + * Stored as JSON arrays of [x, y] tuples. + * + * Pure module: no Payload import, safe on client and server. + */ + +export type MapPoint = [number, number]; + +/** + * Checks whether an unknown value is an array of [x, y] tuples with finite + * numbers. Optionally enforces a minimum number of points. + */ +export function isPointArray(value: unknown, minPoints = 1): value is MapPoint[] { + if (!Array.isArray(value) || value.length < minPoints) return false; + return value.every( + (point) => + Array.isArray(point) && + point.length === 2 && + typeof point[0] === "number" && + Number.isFinite(point[0]) && + typeof point[1] === "number" && + Number.isFinite(point[1]), + ); +} + +/** Field validator for polyline geometry (roads): at least 2 points. */ +export function validatePolyline(value: unknown): true | string { + if (value === undefined || value === null) return "A road requires at least two points."; + if (!isPointArray(value, 2)) return "Points must be an array of [x, y] meter tuples."; + return true; +} + +/** Field validator for polygon geometry (zones): at least 3 points (unclosed ring). */ +export function validatePolygon(value: unknown): true | string { + if (value === undefined || value === null) return "A zone requires at least three points."; + if (!isPointArray(value, 3)) return "Points must be an array of [x, y] meter tuples."; + return true; +} + +/** Field validator for a single [x, y] position. */ +export function validatePosition(value: unknown): true | string { + if (!Array.isArray(value) || value.length !== 2) return "Position must be an [x, y] tuple."; + return isPointArray([value]) ? true : "Position must contain two finite numbers."; +} + +/** Squared distance between two points (avoids sqrt in hot loops). */ +export function distanceSquared(a: MapPoint, b: MapPoint): number { + return (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2; +} + +/** Euclidean distance in meters between two points. */ +export function pointDistance(a: MapPoint, b: MapPoint): number { + return Math.sqrt(distanceSquared(a, b)); +} + +export interface InterpolatedPosition { + point: MapPoint; + bearing: number; +} + +/** + * Position along a polyline at fraction t in [0, 1] (by geometric length), + * plus the bearing (degrees, clockwise from north) of the segment traveled. + * Returns the first point when t <= 0 and the last when t >= 1. + */ +export function interpolateAlong(points: MapPoint[], t: number): InterpolatedPosition | null { + if (points.length === 0) return null; + if (points.length === 1 || t <= 0) { + return { point: points[0], bearing: 0 }; + } + if (t >= 1) { + const last = points[points.length - 1]; + const prev = points[points.length - 2]; + return { point: last, bearing: bearingDeg(prev, last) }; + } + + const total = polylineLength(points); + if (total <= 0) return { point: points[0], bearing: 0 }; + + let traveled = total * t; + for (let i = 1; i < points.length; i++) { + const segment = pointDistance(points[i - 1], points[i]); + if (segment <= 0) continue; + if (traveled <= segment || i === points.length - 1) { + const f = Math.min(1, traveled / segment); + const point: MapPoint = [ + points[i - 1][0] + (points[i][0] - points[i - 1][0]) * f, + points[i - 1][1] + (points[i][1] - points[i - 1][1]) * f, + ]; + return { point, bearing: bearingDeg(points[i - 1], points[i]) }; + } + traveled -= segment; + } + return { point: points[points.length - 1], bearing: 0 }; +} + +/** Bearing in degrees (0 = north/+y up means here -y on the map grid), clockwise. */ +export function bearingDeg(from: MapPoint, to: MapPoint): number { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + if (dx === 0 && dy === 0) return 0; + const angle = (Math.atan2(dx, -dy) * 180) / Math.PI; + return (angle + 360) % 360; +} + +/** + * Ray-casting point-in-polygon test. The ring may be open (first vertex not + * repeated at the end); the closing edge is implied. + */ +export function pointInPolygon(point: MapPoint, polygon: MapPoint[]): boolean { + if (!Array.isArray(polygon) || polygon.length < 3) return false; + const [px, py] = point; + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const intersects = + yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi; + if (intersects) inside = !inside; + } + return inside; +} + +/** + * Geometric length in meters of a polyline. + * Accepts an open polyline (roads) or a closed ring (zones, closing edge included). + */ +export function polylineLength(points: MapPoint[], closed = false): number { + if (points.length < 2) return 0; + let total = 0; + for (let i = 1; i < points.length; i++) { + total += pointDistance(points[i - 1], points[i]); + } + if (closed && points.length > 2) { + total += pointDistance(points[points.length - 1], points[0]); + } + return total; +} diff --git a/src/lib/map/routing.ts b/src/lib/map/routing.ts new file mode 100644 index 0000000..a8ff1b0 --- /dev/null +++ b/src/lib/map/routing.ts @@ -0,0 +1,253 @@ +/** + * Road-network routing for ground shipments. + * + * Builds an undirected weighted graph from a map's road polylines and runs + * Dijkstra between the snapped origin and destination. Nodes are keyed on a + * 1 m grid, so two roads connect wherever they share a vertex (within the + * epsilon) - author road junctions by reusing identical endpoint coordinates + * (JOSM-style shared vertices). Polyline crossings without a shared vertex do + * NOT connect. + * + * Edge weight = segment length / speedMultiplier, so preferred (faster) roads + * win the shortest path. The reported route distance is always the geometric + * length in meters; fuel is charged on that, not on the weighted cost. + * + * Pure module: no Payload import, safe on client and server. + */ + +import { distanceSquared, pointDistance, polylineLength, type MapPoint } from "./points"; + +/** Junction tolerance in meters: endpoints within this distance share a node. */ +export const ROAD_SNAP_EPSILON_METERS = 1; + +/** Maximum distance a structure may sit from the nearest road vertex and still route. */ +export const DEFAULT_SNAP_RADIUS_METERS = 1000; + +export interface RoadInput { + id: number; + points: MapPoint[]; + speedMultiplier: number; +} + +export interface GraphNode { + key: string; + position: MapPoint; + neighbors: Map; +} + +export interface RoadGraph { + nodes: Map; + /** Roughly the number of distinct vertices across all roads (for tests/diagnostics). */ + nodeCount: number; +} + +function nodeKey(position: MapPoint): string { + return `${Math.round(position[0] / ROAD_SNAP_EPSILON_METERS)}:${Math.round( + position[1] / ROAD_SNAP_EPSILON_METERS, + )}`; +} + +/** + * Builds the road graph. Every vertex of every road becomes a node (keyed on + * the epsilon grid so shared vertices merge); consecutive vertices are joined + * by a weighted edge. Duplicate edges (two roads overlapping a segment) keep + * the fastest weight. + */ +export function buildRoadGraph(roads: readonly RoadInput[]): RoadGraph { + const nodes = new Map(); + + const ensureNode = (position: MapPoint): GraphNode => { + const key = nodeKey(position); + let node = nodes.get(key); + if (!node) { + node = { key, position, neighbors: new Map() }; + nodes.set(key, node); + } + return node; + }; + + for (const road of roads) { + if (!road.points || road.points.length < 2) continue; + const multiplier = road.speedMultiplier && road.speedMultiplier > 0 ? road.speedMultiplier : 1; + + for (let i = 1; i < road.points.length; i++) { + const from = ensureNode(road.points[i - 1]); + const to = ensureNode(road.points[i]); + const length = pointDistance(from.position, to.position); + if (length <= 0) continue; // duplicate consecutive vertex + + const weight = length / multiplier; + + const forward = from.neighbors.get(to.key); + if (!forward || weight < forward.weight) { + from.neighbors.set(to.key, { to: to.key, weight, length }); + } + const backward = to.neighbors.get(from.key); + if (!backward || weight < backward.weight) { + to.neighbors.set(from.key, { to: from.key, weight, length }); + } + } + } + + return { nodes, nodeCount: nodes.size }; +} + +/** + * Finds the nearest graph node to a point, within the snap radius. + * Returns null when the point is too far from the network. + */ +export function snapToNode( + graph: RoadGraph, + point: MapPoint, + snapRadiusMeters = DEFAULT_SNAP_RADIUS_METERS, +): GraphNode | null { + let best: GraphNode | null = null; + let bestDistanceSq = Infinity; + const maxSq = snapRadiusMeters * snapRadiusMeters; + for (const node of graph.nodes.values()) { + const d = distanceSquared(node.position, point); + if (d < bestDistanceSq && d <= maxSq) { + bestDistanceSq = d; + best = node; + } + } + return best; +} + +export type RoutingResult = + | { + connected: true; + /** Polyline of the route from origin to destination, in meters on the map grid. */ + path: MapPoint[]; + /** Geometric route length in meters. */ + distanceMeters: number; + /** Sum of weighted edge costs along the chosen path (diagnostics). */ + weightedCost: number; + } + | { + connected: false; + path: null; + distanceMeters: null; + weightedCost: null; + }; + +/** Minimal binary min-heap keyed on numeric priority. */ +class MinHeap { + private items: { priority: number; value: string }[] = []; + + get size(): number { + return this.items.length; + } + + push(priority: number, value: string): void { + this.items.push({ priority, value }); + let i = this.items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.items[parent].priority <= this.items[i].priority) break; + [this.items[parent], this.items[i]] = [this.items[i], this.items[parent]]; + i = parent; + } + } + + pop(): { priority: number; value: string } | undefined { + if (this.items.length === 0) return undefined; + const top = this.items[0]; + const last = this.items.pop()!; + if (this.items.length > 0) { + this.items[0] = last; + let i = 0; + for (;;) { + const left = i * 2 + 1; + const right = left + 1; + let smallest = i; + if (left < this.items.length && this.items[left].priority < this.items[smallest].priority) { + smallest = left; + } + if ( + right < this.items.length && + this.items[right].priority < this.items[smallest].priority + ) { + smallest = right; + } + if (smallest === i) break; + [this.items[smallest], this.items[i]] = [this.items[i], this.items[smallest]]; + i = smallest; + } + } + return top; + } +} + +/** + * Dijkstra shortest path between the nodes nearest to origin and destination. + * Both ends must snap to a node within the snap radius and lie in the same + * connected component, otherwise the route is refused. + */ +export function findRoute( + graph: RoadGraph, + origin: MapPoint, + destination: MapPoint, + options?: { snapRadiusMeters?: number }, +): RoutingResult { + const snapRadius = options?.snapRadiusMeters ?? DEFAULT_SNAP_RADIUS_METERS; + if (graph.nodes.size === 0) return { connected: false, path: null, distanceMeters: null, weightedCost: null }; + + const start = snapToNode(graph, origin, snapRadius); + const goal = snapToNode(graph, destination, snapRadius); + if (!start || !goal) { + return { connected: false, path: null, distanceMeters: null, weightedCost: null }; + } + if (start.key === goal.key) { + return { + connected: true, + path: [start.position, goal.position], + distanceMeters: pointDistance(start.position, goal.position), + weightedCost: 0, + }; + } + + const dist = new Map([[start.key, 0]]); + const prev = new Map(); + const settled = new Set(); + const heap = new MinHeap(); + heap.push(0, start.key); + + while (heap.size > 0) { + const current = heap.pop()!; + if (settled.has(current.value)) continue; + settled.add(current.value); + + if (current.value === goal.key) break; + + const node = graph.nodes.get(current.value)!; + for (const edge of node.neighbors.values()) { + if (settled.has(edge.to)) continue; + const candidate = dist.get(node.key)! + edge.weight; + if (candidate < (dist.get(edge.to) ?? Infinity)) { + dist.set(edge.to, candidate); + prev.set(edge.to, node.key); + heap.push(candidate, edge.to); + } + } + } + + if (!settled.has(goal.key)) { + return { connected: false, path: null, distanceMeters: null, weightedCost: null }; + } + + // Reconstruct the path origin -> destination. + const path: MapPoint[] = []; + let cursor: string | undefined = goal.key; + while (cursor) { + path.unshift(graph.nodes.get(cursor)!.position); + cursor = prev.get(cursor); + } + // Snap tails: the origin/destination structures are not exactly on the + // network, prepend/append the straight stubs so the rendered route touches them. + path.unshift(origin); + path.push(destination); + + const distanceMeters = polylineLength(path); + return { connected: true, path, distanceMeters, weightedCost: dist.get(goal.key)! }; +} diff --git a/src/lib/map/style.ts b/src/lib/map/style.ts new file mode 100644 index 0000000..0198652 --- /dev/null +++ b/src/lib/map/style.ts @@ -0,0 +1,149 @@ +/** + * Pure styling/label helpers for the map feature layers. + * No Payload import: safe on client and server. + */ + +import { pointDistance, polylineLength, type MapPoint } from "./points"; + +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; + +/** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */ +export function validateHexColor(value: unknown): true | string { + if (value === undefined || value === null || value === "") return true; + return typeof value === "string" && HEX_COLOR.test(value) + ? true + : "Use a hex color like #38bdf8, or leave empty for the default."; +} + +/** Meters per screen pixel at the given zoom for a world scaled to 256 px at zoom 0. */ +export function metersPerPx(zoom: number, worldSize: number): number { + return worldSize / (256 * Math.pow(2, zoom)); +} + +/** Dash array for a stroke style name. */ +export function dashArrayFor(style: string | null | undefined): string | undefined { + if (style === "dashed") return "8 8"; + if (style === "dotted") return "2 6"; + return undefined; +} + +/** + * Centroid of a polygon ring (area-weighted; falls back to the vertex average + * for degenerate rings). + */ +export function polygonCentroid(points: MapPoint[]): MapPoint { + if (points.length === 0) return [0, 0]; + let area = 0; + let cx = 0; + let cy = 0; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const [xi, yi] = points[i]; + const [xj, yj] = points[j]; + const cross = xi * yj - xj * yi; + area += cross; + cx += (xj + xi) * cross; + cy += (yj + yi) * cross; + } + area /= 2; + if (Math.abs(area) < 1e-6) { + const sumX = points.reduce((total, point) => total + point[0], 0); + const sumY = points.reduce((total, point) => total + point[1], 0); + return [sumX / points.length, sumY / points.length]; + } + return [cx / (6 * area), cy / (6 * area)]; +} + +/** + * Densifies a polyline with a Catmull-Rom spline (passing through every + * vertex) so rendered lines curve smoothly through bends instead of kinking + * at each vertex. Unit-agnostic: works in map meters or screen pixels alike. + */ +export function splinePolyline(points: MapPoint[], subdivisions = 16): MapPoint[] { + if (points.length < 3 || subdivisions < 1) return points; + const at = (index: number): MapPoint => points[Math.max(0, Math.min(points.length - 1, index))]; + const out: MapPoint[] = [points[0]]; + for (let i = 0; i < points.length - 1; i++) { + const p0 = at(i - 1); + const p1 = at(i); + const p2 = at(i + 1); + const p3 = at(i + 2); + for (let s = 1; s <= subdivisions; s++) { + const t = s / subdivisions; + const t2 = t * t; + const t3 = t2 * t; + out.push([ + 0.5 * + (2 * p1[0] + + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), + 0.5 * + (2 * p1[1] + + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3), + ]); + } + } + return out; +} + +export interface RoadLabelPlacement { + point: MapPoint; + /** Arc length along the input polyline at this label. */ + distance: number; + /** True when the segment direction would render text upside down or reading downward (use a reversed path so vertical text reads bottom-to-top). */ + flipped: boolean; +} + +/** + * Label placements along a road polyline: one label roughly every + * `intervalMeters`, centered on the segment it sits on and flagged when the + * direction would render text upside down, capped at `maxLabels`. + */ +export function roadLabelPoints( + points: MapPoint[], + intervalMeters: number, + maxLabels: number, +): RoadLabelPlacement[] { + const total = polylineLength(points); + if (points.length < 2 || total <= 0 || intervalMeters <= 0) return []; + + const labels: RoadLabelPlacement[] = []; + let nextAt = intervalMeters / 2; + let traveled = 0; + + for (let i = 1; i < points.length && labels.length < maxLabels; i++) { + const from = points[i - 1]; + const to = points[i]; + const segment = pointDistance(from, to); + if (segment <= 0) continue; + const angle = segmentAngleDeg(from, to); + + while (nextAt <= traveled + segment && labels.length < maxLabels) { + const f = (nextAt - traveled) / segment; + labels.push({ + point: [from[0] + (to[0] - from[0]) * f, from[1] + (to[1] - from[1]) * f], + distance: nextAt, + flipped: angle > 90 || angle < -90 || angle === 90, + }); + nextAt += intervalMeters; + } + traveled += segment; + } + + return labels; +} + +/** + * Screen-space direction of a segment in degrees (atan2(dy, dx)). Map y runs + * south like the screen's y-axis, so this is the on-screen direction. Angles + * outside [-90, 90) would render text upside down or (exactly vertical) + * reading downward. + */ +function segmentAngleDeg(from: MapPoint, to: MapPoint): number { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + if (dx === 0 && dy === 0) return 0; + return (Math.atan2(dy, dx) * 180) / Math.PI; +} diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts new file mode 100644 index 0000000..f360ffc --- /dev/null +++ b/src/lib/map/types.ts @@ -0,0 +1,101 @@ +import type { MapPoint } from "./points"; + +export interface MapConfig { + id: number; + name: string; + worldSizeWidth: number; + worldSizeHeight: number; + basemapMode: "image" | "tiles"; + basemapImageUrl: string | null; + tileUrlTemplate: string | null; + minZoom: number; + maxZoom: number; + defaultZoom: number; + defaultCenter: MapPoint; +} + +export interface RangeRequirementFeature { + resource: number | null; + structure: number | null; + range: number | null; +} + +export interface StructureFeature { + id: number; + name: string; + point: MapPoint; + constructionStatus: "awaiting_materials" | "building" | "complete" | null; + constructionStartedAt: string | null; + constructionCompletesAt: string | null; + constructionDurationMinutes: number | null; + storageUsedKg: number; + storageCapacityKg: number | null; + labelVisible: boolean | null; + labelPriority: number | null; + blueprintId: number | null; + blueprintName: string | null; + blueprintTerrain: "land" | "water" | null; + materials: { resource: number; resourceName: string; amount: number | null }[]; + resourcesInRange: RangeRequirementFeature[]; + structuresInRange: RangeRequirementFeature[]; + factionName: string | null; +} + +export interface NodeFeature { + id: number; + name: string; + point: MapPoint; + description: string | null; + resources: { id: number; name: string }[]; + richness: number | null; +} + +export interface RoadFeature { + id: number; + name: string | null; + description: string | null; + surface: "paved" | "dirt" | "trail"; + speedMultiplier: number; + strokeColor: string | null; + strokeWeight: number | null; + strokeStyle: "solid" | "dashed" | "dotted" | null; + labelVisible: boolean | null; + points: MapPoint[]; +} + +export interface ZoneFeature { + id: number; + name: string | null; + description: string | null; + type: "water" | "land"; + resources: { id: number; name: string }[]; + fillColor: string | null; + strokeColor: string | null; + fillOpacity: number | null; + strokeWeight: number | null; + labelVisible: boolean | null; + points: MapPoint[]; +} + +export interface ShipmentFeature { + id: number; + name: string | null; + status: string; + routePath: MapPoint[] | null; + originName: string | null; + originPoint: MapPoint | null; + destinationName: string | null; + destinationPoint: MapPoint | null; + dispatchedAt: string | null; + estimatedArrival: string | null; + vehicleName: string | null; +} + +export interface MapFeaturesResponse { + map: MapConfig; + structures: StructureFeature[]; + nodes: NodeFeature[]; + roads: RoadFeature[]; + zones: ZoneFeature[]; + shipments: ShipmentFeature[]; +} diff --git a/src/lib/shipping.ts b/src/lib/shipping.ts index 064d5dc..2872942 100644 --- a/src/lib/shipping.ts +++ b/src/lib/shipping.ts @@ -1,19 +1,20 @@ /** * Calculates the transit time in milliseconds for a shipment. - * @param distance - Euclidean distance in map units + * @param distance - Route length in meters (straight-line or road route) * @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.) */ export function calculateTransitTime( distance: number, vehicleSpeed: number, ): number { - const hours = distance / vehicleSpeed; + const km = distance / 1000; + const hours = km / vehicleSpeed; return hours * 60 * 60 * 1000; } /** * Calculates the total fuel cost for a shipment. - * @param distance - Euclidean distance in map units + * @param distance - Route length in meters * @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate) */ export function calculateFuelCost( @@ -50,22 +51,34 @@ export function calculateTicksForDuration( } /** - * Gets the effective speed of a vehicle, preferring road speed. - * Falls back to off-road, air, or sea speed. + * Gets the effective speed of a vehicle for its transport mode: ground + * prefers road speed then off-road, air uses airspeed, sea uses knots. + * Falls back through the remaining speeds and finally a 50 km/h default. */ export function getVehicleEffectiveSpeed(vehicle: { + transportMode?: "ground" | "air" | "sea" | null; combat?: { maxSpeedOnRoad?: number | null; maxSpeedOffRoad?: number | null; maxAirspeed?: number | null; maxKnots?: number | null; } | null; +} & { + maxSpeedOnRoad?: number | null; + maxSpeedOffRoad?: number | null; + maxAirspeed?: number | null; + maxKnots?: number | null; }): number { - return ( - vehicle.combat?.maxSpeedOnRoad ?? - vehicle.combat?.maxSpeedOffRoad ?? - vehicle.combat?.maxAirspeed ?? - vehicle.combat?.maxKnots ?? - 50 // default fallback speed - ); + const combat = vehicle.combat ?? vehicle; + const mode = vehicle.transportMode ?? "ground"; + const byMode: Record = { + ground: [combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxAirspeed, combat.maxKnots], + air: [combat.maxAirspeed, combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxKnots], + sea: [combat.maxKnots, combat.maxSpeedOffRoad, combat.maxSpeedOnRoad, combat.maxAirspeed], + }; + const speeds = byMode[mode] ?? byMode.ground; + for (const speed of speeds) { + if (speed != null && speed > 0) return speed; + } + return 50; // default fallback speed } diff --git a/tests/int/map-system.int.spec.ts b/tests/int/map-system.int.spec.ts new file mode 100644 index 0000000..c3ad443 --- /dev/null +++ b/tests/int/map-system.int.spec.ts @@ -0,0 +1,1403 @@ +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 { + return (await payload.create({ + collection: "maps", + data: { name, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, + overrideAccess: true, + depth: 0, + })) as unknown as MapDoc; +} + +async function makeResource(name: string): Promise { + return (await payload.create({ + collection: "resources", + data: { + name, + codeName: `${RUN}-${name.toLowerCase()}`, + approvalStatus: "in_progress", + type: "physical", + baseValue: 1, + rarity: "common", + unitOfMeasure: "kg", + massPerUnit: 1, + gridWidth: 1, + gridHeight: 1, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Resource; +} + +async function makeStructureBlueprint(data: { + name: string; + materials: { resource: number; amount: number }[]; + constructionDurationMinutes: number; + resourcesInRange?: { resource: number; range: number }[]; +}): Promise { + return (await payload.create({ + collection: "structures", + data: { + name: data.name, + codeName: data.name.toLowerCase().replace(/\s+/g, "_"), + approvalStatus: "in_progress", + description: LEXICAL_EMPTY as unknown as Structure["description"], + category: "logistics", + materials: data.materials, + constructionDurationMinutes: data.constructionDurationMinutes, + terrainType: "land", + maxHealth: 100, + ...(data.resourcesInRange ? { resourcesInRange: data.resourcesInRange } : {}), + }, + overrideAccess: true, + depth: 0, + })) as unknown as Structure; +} + +async function makeVehicleBlueprint(data: { + name: string; + transportMode: "ground" | "air" | "sea"; + fuelTypeId: number; +}): Promise { + return (await payload.create({ + collection: "vehicles", + data: { + name: data.name, + approvalStatus: "in_progress", + transportMode: data.transportMode, + fuel: { fuelType: data.fuelTypeId, fuelCapacity: 100000, fuelConsumptionRate: 0.01 }, + ...(data.transportMode === "ground" + ? { maxSpeedOnRoad: 60 } + : data.transportMode === "air" + ? { maxAirspeed: 120 } + : { maxKnots: 30 }), + }, + overrideAccess: true, + depth: 0, + })) as unknown as Vehicle; +} + +async function makeGameStructure(data: { + name: string; + blueprintId: number; + mapId: number; + point: [number, number]; + constructionStatus?: "awaiting_materials" | "building" | "complete"; +}): Promise { + return (await payload.create({ + collection: "game-structures", + data: { + name: data.name, + type: data.blueprintId, + map: data.mapId, + coordinates: data.point, + ...(data.constructionStatus ? { constructionStatus: data.constructionStatus } : {}), + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameStructure; +} + +async function makeGameVehicle(data: { + name: string; + blueprint: Vehicle; + atId: number; +}): Promise { + return (await payload.create({ + collection: "game-vehicles", + data: { + name: data.name, + type: data.blueprint.id, + deployedAt: data.atId, + status: "idle", + currentFuel: 50000, + currentHealth: 100, + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameVehicle; +} + +async function makeRoad(mapId: number, points: [number, number][], speedMultiplier = 1) { + const road = await payload.create({ + collection: "map-roads", + data: { map: mapId, surface: "paved", speedMultiplier, points }, + overrideAccess: true, + depth: 0, + }); + createdIds.roads.push(road.id); + return road; +} + +describe("Game Map System", () => { + let mapA: MapDoc; + let mapB: MapDoc; + let steel: Resource; + let diesel: Resource; + let depotBlueprint: Structure; + let rangeBlueprint: Structure; + let truckBlueprint: Vehicle; + let aircraftBlueprint: Vehicle; + let originA: GameStructure; + let destA: GameStructure; + let destB: GameStructure; + let logisticsUser: User; + let plainUser: User; + let mapper: User; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + authSpy = vi.spyOn(payload, "auth"); + + mapA = await makeMap(`${RUN} Altis`); + mapB = await makeMap(`${RUN} Stratis`); + createdIds.maps.push(mapA.id, mapB.id); + + steel = await makeResource(`${RUN} Steel`); + diesel = await makeResource(`${RUN} Diesel`); + createdIds.resources.push(steel.id, diesel.id); + + depotBlueprint = await makeStructureBlueprint({ + name: `${RUN} Depot`, + materials: [{ resource: steel.id, amount: 5 }], + constructionDurationMinutes: 30, + }); + rangeBlueprint = await makeStructureBlueprint({ + name: `${RUN} Mine Head`, + materials: [{ resource: steel.id, amount: 1 }], + constructionDurationMinutes: 30, + resourcesInRange: [{ resource: steel.id, range: 1000 }], + }); + createdIds.structureBlueprints.push(depotBlueprint.id, rangeBlueprint.id); + + truckBlueprint = await makeVehicleBlueprint({ + name: `${RUN} Truck`, + transportMode: "ground", + fuelTypeId: diesel.id, + }); + aircraftBlueprint = await makeVehicleBlueprint({ + name: `${RUN} Helicopter`, + transportMode: "air", + fuelTypeId: diesel.id, + }); + createdIds.vehicleBlueprints.push(truckBlueprint.id, aircraftBlueprint.id); + + originA = await makeGameStructure({ + name: `${RUN} Origin`, + blueprintId: depotBlueprint.id, + mapId: mapA.id, + point: [1000, 1000], + }); + destA = await makeGameStructure({ + name: `${RUN} Yard`, + blueprintId: depotBlueprint.id, + mapId: mapA.id, + point: [6000, 6000], + }); + destB = await makeGameStructure({ + name: `${RUN} Islet`, + blueprintId: depotBlueprint.id, + mapId: mapB.id, + point: [500, 500], + }); + createdIds.gameStructures.push(originA.id, destA.id, destB.id); + + const role = await payload.create({ + collection: "roles", + data: { + name: `${RUN} logistics`, + slug: `${RUN}-logistics`, + permissions: ["shipments:create", "structures:read"], + }, + overrideAccess: true, + depth: 0, + }); + createdIds.roles.push(role.id); + + logisticsUser = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-log`, + discordUsername: `${RUN}-log`, + displayName: `${RUN} Logistics`, + steamId: `${RUN}-log-steam`, + password: `${RUN}-log-pass`, + roleDocs: [role.id], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + plainUser = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-plain`, + discordUsername: `${RUN}-plain`, + displayName: `${RUN} Plain`, + steamId: `${RUN}-plain-steam`, + password: `${RUN}-plain-pass`, + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + createdIds.users.push(logisticsUser.id, plainUser.id); + + const mapperRole = await payload.create({ + collection: "roles", + data: { + name: `${RUN} mapper`, + slug: `${RUN}-mapper`, + permissions: ["maps:create", "maps:update", "map-roads:read", "admin:map-roads:manage"], + }, + overrideAccess: true, + depth: 0, + }); + createdIds.roles.push(mapperRole.id); + mapper = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-mapper`, + discordUsername: `${RUN}-mapper`, + displayName: `${RUN} Mapper`, + steamId: `${RUN}-mapper-steam`, + password: `${RUN}-mapper-pass`, + roleDocs: [mapperRole.id], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + createdIds.users.push(mapper.id); + }); + + afterAll(async () => { + authSpy.mockRestore(); + + // Per-doc deletes (bulk delete by where is unreliable on this adapter) and + // pattern predicates, so partial beforeAll failures cannot leave orphans. + const sweep = async (collection: CollectionSlug, where: Where) => { + const found = await payload + .find({ collection, where, limit: 1000, depth: 0, overrideAccess: true }) + .catch(() => null); + for (const doc of found?.docs ?? []) { + await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {}); + } + }; + + const runLike = { name: { like: `${RUN}%` } }; + await sweep("shipments", { map: { in: createdIds.maps } }); + await sweep("game-vehicles", runLike); + await sweep("map-roads", { map: { in: createdIds.maps } }); + await sweep("map-zones", { map: { in: createdIds.maps } }); + await sweep("resource-nodes", { map: { in: createdIds.maps } }); + await sweep("game-structures", runLike); + await sweep("structures", runLike); + await sweep("vehicles", runLike); + await sweep("resources", runLike); + await sweep("maps", runLike); + await sweep("roles", runLike); + await sweep("qualifications", runLike); + for (const id of createdIds.users) { + await sweep("bank-accounts", { ownerUser: { equals: id } }); + await sweep("profiles", { user: { equals: id } }); + } + await sweep("users", { username: { like: `${RUN}%` } }); + + await payload + .delete({ + collection: "game-event-logs", + where: { + and: [ + { + or: [ + { type: { equals: EventTypes.StructurePlaced } }, + { type: { equals: EventTypes.StructureConstructionComplete } }, + ], + }, + { + or: createdIds.gameStructures.map((id) => ({ targetId: { equals: id } })), + }, + ], + }, + overrideAccess: true, + }) + .catch(() => {}); + }); + + describe("map styling helpers (pure)", () => { + it("validates hex colors and allows empty", () => { + expect(validateHexColor("#38bdf8")).toBe(true); + expect(validateHexColor("#38BDF8")).toBe(true); + expect(validateHexColor("")).toBe(true); + expect(validateHexColor(undefined)).toBe(true); + expect(validateHexColor("38bdf8")).not.toBe(true); + expect(validateHexColor("#38b")).not.toBe(true); + expect(validateHexColor("#38bdf88")).not.toBe(true); + expect(validateHexColor("red")).not.toBe(true); + }); + + it("converts meters to screen pixels per zoom level", () => { + expect(metersPerPx(0, 8192)).toBe(32); + expect(metersPerPx(5, 8192)).toBe(1); + expect(metersPerPx(6, 8192)).toBeCloseTo(0.5, 5); + }); + + it("places road labels at fixed intervals and flags upside-down directions", () => { + const labels = roadLabelPoints( + [ + [0, 0], + [1000, 0], + [1000, 1000], + ], + 600, + 10, + ); + expect(labels).toHaveLength(3); + expect(labels[0].distance).toBeCloseTo(300, 5); + expect(labels[1].distance).toBeCloseTo(900, 5); + expect(labels[2].distance).toBeCloseTo(1500, 5); + expect(labels[0].point[0]).toBeCloseTo(300, 5); + expect(labels[0].point[1]).toBeCloseTo(0, 5); + expect(labels.map((label) => label.flipped)).toEqual([false, false, true]); + + const reversed = roadLabelPoints( + [ + [2000, 0], + [1000, 0], + ], + 600, + 10, + ); + expect(reversed).toHaveLength(2); + expect(reversed[0].distance).toBeCloseTo(300, 5); + expect(reversed[1].distance).toBeCloseTo(900, 5); + expect(reversed.map((label) => label.flipped)).toEqual([true, true]); + }); + + it("caps road labels at the configured maximum", () => { + const labels = roadLabelPoints( + [ + [0, 0], + [10000, 0], + ], + 100, + 5, + ); + expect(labels.length).toBe(5); + }); + + it("densifies polylines with a spline that passes through every vertex", () => { + const raw: [number, number][] = [ + [0, 0], + [1000, 0], + [1000, 1000], + [0, 1000], + ]; + const splined = splinePolyline(raw, 8); + expect(splined.length).toBe((raw.length - 1) * 8 + 1); + expect(splined[0][0]).toBeCloseTo(0, 5); + expect(splined[0][1]).toBeCloseTo(0, 5); + expect(splined[splined.length - 1][0]).toBeCloseTo(0, 5); + expect(splined[splined.length - 1][1]).toBeCloseTo(1000, 5); + for (let i = 0; i < raw.length; i++) { + const vertex = splined[i * 8]; + expect(vertex[0]).toBeCloseTo(raw[i][0], 5); + expect(vertex[1]).toBeCloseTo(raw[i][1], 5); + } + const short = splinePolyline(raw.slice(0, 2)); + expect(short).toEqual(raw.slice(0, 2)); + }); + + it("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; + const createAccess = roadsConfig!.access?.create as unknown as (args: { + req: { payload: Payload; user: null }; + }) => Promise; + expect(await readAccess({ req: { payload, user: null } })).toBe(false); + expect(await createAccess({ req: { payload, user: null } })).toBe(false); + }); + + it("grants map feature writes to holders of maps:create", async () => { + const { hasPermission } = await import("@/utils/access-control/hasPermission"); + expect(await hasPermission(payload, mapper, "maps:create")).toBe(true); + expect(await hasPermission(payload, mapper, "maps:delete")).toBe(false); + + const payloadConfig = await config; + const roadsConfig = payloadConfig.collections.find( + (collection) => collection.slug === "map-roads", + ); + const createAccess = roadsConfig!.access?.create as unknown as (args: { + req: { payload: Payload; user: User }; + }) => Promise; + expect(await createAccess({ req: { payload, user: mapper } })).toBe(true); + }); + + it("edits an existing road when maps:update is granted and guards cross-map edits", async () => { + authSpy.mockResolvedValue({ user: mapper }); + const road = await makeRoad(mapA.id, [ + [500, 500], + [1000, 800], + ]); + + const result = await saveMapRoad({ + id: road.id, + mapId: mapA.id, + name: `${RUN} renamed road`, + surface: "dirt", + speedMultiplier: 2, + points: [ + [500, 500], + [1200, 900], + ], + }); + expect(result.success).toBe(true); + + const updated = await payload.findByID({ + collection: "map-roads", + id: road.id, + depth: 0, + overrideAccess: true, + }); + expect(updated.name).toBe(`${RUN} renamed road`); + expect(updated.surface).toBe("dirt"); + expect(updated.speedMultiplier).toBe(2); + expect(updated.points).toEqual([ + [500, 500], + [1200, 900], + ]); + + const crossMap = await saveMapRoad({ + id: road.id, + mapId: mapB.id, + name: "wrong map", + surface: "trail", + speedMultiplier: 1, + points: [ + [0, 0], + [10, 10], + ], + }); + expect(crossMap.success).toBe(false); + expect(crossMap.error).toContain("different map"); + }); + + it("rejects roads with invalid stroke colors and persists valid styling", async () => { + authSpy.mockResolvedValue({ user: mapper }); + + const badColor = await saveMapRoad({ + mapId: mapA.id, + name: `${RUN} bad color`, + surface: "paved", + speedMultiplier: 1, + strokeColor: "red", + points: [ + [0, 0], + [10, 10], + ], + }); + expect(badColor.success).toBe(false); + expect(badColor.error).toContain("hex color"); + + const styled = await saveMapRoad({ + mapId: mapA.id, + name: `${RUN} styled road`, + description: `${RUN} description`, + surface: "dirt", + speedMultiplier: 1.5, + strokeColor: "#cbd5e1", + strokeWeight: 4, + strokeStyle: "dashed", + labelVisible: false, + points: [ + [3000, 3000], + [4000, 3000], + ], + }); + expect(styled.success).toBe(true); + + const createdRoad = await payload.findByID({ + collection: "map-roads", + id: (styled.data as { id: number }).id, + depth: 0, + overrideAccess: true, + }); + expect(createdRoad.description).toBe(`${RUN} description`); + expect(createdRoad.strokeColor).toBe("#cbd5e1"); + expect(createdRoad.strokeWeight).toBe(4); + expect(createdRoad.strokeStyle).toBe("dashed"); + expect(createdRoad.labelVisible).toBe(false); + + const restyled = await saveMapRoad({ + id: (styled.data as { id: number }).id, + mapId: mapA.id, + name: `${RUN} styled road`, + description: null, + surface: "dirt", + speedMultiplier: 1.5, + strokeColor: null, + strokeWeight: null, + strokeStyle: null, + points: [ + [3000, 3000], + [4000, 3000], + ], + }); + expect(restyled.success).toBe(true); + + const cleared = await payload.findByID({ + collection: "map-roads", + id: (styled.data as { id: number }).id, + depth: 0, + overrideAccess: true, + }); + expect(cleared.description).toBeNull(); + expect(cleared.strokeColor).toBeNull(); + expect(cleared.strokeWeight).toBeNull(); + expect(cleared.strokeStyle).toBeNull(); + }); + + it("rejects zones with invalid colors and persists zone styling", async () => { + authSpy.mockResolvedValue({ user: mapper }); + + const badOpacity = await saveMapZone({ + mapId: mapA.id, + name: `${RUN} bad fill`, + type: "water", + fillColor: "not-a-color", + points: [ + [6000, 6000], + [7000, 6000], + [7000, 7000], + [6000, 7000], + ], + }); + expect(badOpacity.success).toBe(false); + expect(badOpacity.error).toContain("hex color"); + + const styled = await saveMapZone({ + mapId: mapA.id, + name: `${RUN} styled zone`, + description: `${RUN} zone description`, + type: "water", + fillColor: "#38bdf8", + strokeColor: "#0ea5e9", + fillOpacity: 0.4, + strokeWeight: 2, + points: [ + [6000, 6000], + [7000, 6000], + [7000, 7000], + [6000, 7000], + ], + }); + expect(styled.success).toBe(true); + + const createdZone = await payload.findByID({ + collection: "map-zones", + id: (styled.data as { id: number }).id, + depth: 0, + overrideAccess: true, + }); + expect(createdZone.description).toBe(`${RUN} zone description`); + expect(createdZone.fillColor).toBe("#38bdf8"); + expect(createdZone.strokeColor).toBe("#0ea5e9"); + expect(createdZone.fillOpacity).toBe(0.4); + expect(createdZone.strokeWeight).toBe(2); + }); + + it("persists node descriptions", async () => { + authSpy.mockResolvedValue({ user: mapper }); + const described = await saveMapNode({ + mapId: mapA.id, + name: `${RUN} described node`, + description: `${RUN} node description`, + resourceIds: [steel.id], + position: [7500, 7500], + richness: 80, + }); + expect(described.success).toBe(true); + const createdNode = await payload.findByID({ + collection: "resource-nodes", + id: (described.data as { id: number }).id, + depth: 0, + overrideAccess: true, + }); + expect(createdNode.description).toBe(`${RUN} node description`); + expect(createdNode.richness).toBe(80); + }); + }); +});