feat(map): road-network routing for shipments with transport modes
Add pure routing, placement, point, and style libs plus map-system tests. Shipments gain a map relationship and routePath snapshot; ground vehicles route over the road network via Dijkstra while air and sea travel straight-line. Vehicle types declare a ground/air/sea transportMode and transit math works in meters. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
5f06039697
commit
c64c9da0da
10 changed files with 2366 additions and 29 deletions
|
|
@ -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<typeof getVehicleEffectiveSpeed>[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) => ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
195
src/lib/map/placement.ts
Normal file
195
src/lib/map/placement.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
142
src/lib/map/points.ts
Normal file
142
src/lib/map/points.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
253
src/lib/map/routing.ts
Normal file
253
src/lib/map/routing.ts
Normal file
|
|
@ -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<string, { to: string; weight: number; length: number }>;
|
||||
}
|
||||
|
||||
export interface RoadGraph {
|
||||
nodes: Map<string, GraphNode>;
|
||||
/** 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<string, GraphNode>();
|
||||
|
||||
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<string, number>([[start.key, 0]]);
|
||||
const prev = new Map<string, string>();
|
||||
const settled = new Set<string>();
|
||||
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)! };
|
||||
}
|
||||
149
src/lib/map/style.ts
Normal file
149
src/lib/map/style.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
101
src/lib/map/types.ts
Normal file
101
src/lib/map/types.ts
Normal file
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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<string, (number | null | undefined)[]> = {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
1403
tests/int/map-system.int.spec.ts
Normal file
1403
tests/int/map-system.int.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue