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 { calculateDistance } from "@/lib/distance";
|
||||||
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
||||||
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
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
|
// Fetch vehicle
|
||||||
const vehicle = (await payload
|
const vehicle = (await payload
|
||||||
.findByID({
|
.findByID({
|
||||||
|
|
@ -146,6 +134,69 @@ export async function createShipment(params: {
|
||||||
return { success: false, error: "Vehicle type not found." };
|
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
|
// Calculate fuel cost
|
||||||
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
||||||
const fuelCost = calculateFuelCost(distance, consumptionRate);
|
const fuelCost = calculateFuelCost(distance, consumptionRate);
|
||||||
|
|
@ -283,9 +334,7 @@ export async function createShipment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate transit time
|
// Calculate transit time
|
||||||
const speed = getVehicleEffectiveSpeed(
|
const speed = getVehicleEffectiveSpeed(vehicleType);
|
||||||
vehicleType as unknown as Parameters<typeof getVehicleEffectiveSpeed>[0],
|
|
||||||
);
|
|
||||||
const transitTimeMs = calculateTransitTime(distance, speed);
|
const transitTimeMs = calculateTransitTime(distance, speed);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
|
@ -332,6 +381,8 @@ export async function createShipment(params: {
|
||||||
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
|
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
|
||||||
origin: originId,
|
origin: originId,
|
||||||
destination: destinationId,
|
destination: destinationId,
|
||||||
|
map: originMapId,
|
||||||
|
routePath,
|
||||||
transportVehicle: vehicleId,
|
transportVehicle: vehicleId,
|
||||||
distance: Math.round(distance * 100) / 100,
|
distance: Math.round(distance * 100) / 100,
|
||||||
cargo: cargo.map((c) => ({
|
cargo: cargo.map((c) => ({
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,21 @@ export const Shipments: CollectionConfig = {
|
||||||
relationTo: "game-structures",
|
relationTo: "game-structures",
|
||||||
required: true,
|
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
|
// Transport
|
||||||
{
|
{
|
||||||
name: "transportVehicle",
|
name: "transportVehicle",
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,21 @@ export const Vehicles: CollectionConfig = {
|
||||||
label: "Logistics & Grid Storage",
|
label: "Logistics & Grid Storage",
|
||||||
type: "group",
|
type: "group",
|
||||||
fields: [
|
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",
|
label: "Storage Grid Dimensions",
|
||||||
type: "group",
|
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.
|
* 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.)
|
* @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.)
|
||||||
*/
|
*/
|
||||||
export function calculateTransitTime(
|
export function calculateTransitTime(
|
||||||
distance: number,
|
distance: number,
|
||||||
vehicleSpeed: number,
|
vehicleSpeed: number,
|
||||||
): number {
|
): number {
|
||||||
const hours = distance / vehicleSpeed;
|
const km = distance / 1000;
|
||||||
|
const hours = km / vehicleSpeed;
|
||||||
return hours * 60 * 60 * 1000;
|
return hours * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the total fuel cost for a shipment.
|
* 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)
|
* @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate)
|
||||||
*/
|
*/
|
||||||
export function calculateFuelCost(
|
export function calculateFuelCost(
|
||||||
|
|
@ -50,22 +51,34 @@ export function calculateTicksForDuration(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the effective speed of a vehicle, preferring road speed.
|
* Gets the effective speed of a vehicle for its transport mode: ground
|
||||||
* Falls back to off-road, air, or sea speed.
|
* 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: {
|
export function getVehicleEffectiveSpeed(vehicle: {
|
||||||
|
transportMode?: "ground" | "air" | "sea" | null;
|
||||||
combat?: {
|
combat?: {
|
||||||
maxSpeedOnRoad?: number | null;
|
maxSpeedOnRoad?: number | null;
|
||||||
maxSpeedOffRoad?: number | null;
|
maxSpeedOffRoad?: number | null;
|
||||||
maxAirspeed?: number | null;
|
maxAirspeed?: number | null;
|
||||||
maxKnots?: number | null;
|
maxKnots?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
} & {
|
||||||
|
maxSpeedOnRoad?: number | null;
|
||||||
|
maxSpeedOffRoad?: number | null;
|
||||||
|
maxAirspeed?: number | null;
|
||||||
|
maxKnots?: number | null;
|
||||||
}): number {
|
}): number {
|
||||||
return (
|
const combat = vehicle.combat ?? vehicle;
|
||||||
vehicle.combat?.maxSpeedOnRoad ??
|
const mode = vehicle.transportMode ?? "ground";
|
||||||
vehicle.combat?.maxSpeedOffRoad ??
|
const byMode: Record<string, (number | null | undefined)[]> = {
|
||||||
vehicle.combat?.maxAirspeed ??
|
ground: [combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxAirspeed, combat.maxKnots],
|
||||||
vehicle.combat?.maxKnots ??
|
air: [combat.maxAirspeed, combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxKnots],
|
||||||
50 // default fallback speed
|
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