1
0
Fork 0
polaris-task-force/docs/map-system.md
Z8MB1E cc0a7facd0 feat(map): political zone types, road label tri-state, authoring snapping lib
- map-zones type gains territory and country border (informational political
  boundaries that never gate placement, default fills amber 0.1 / violet 0.06)
- map-roads labelVisible is tri-state: unset means auto, where paved roads
  always label and dirt/trail only label at 4 m/px or finer (roadLabelShown)
- migration clears the old implicit label default on non-paved roads
- new pure snapping lib: findSnap (vertices own their neighborhood, edges must
  be twice as close), closestPointOnSegment, segmentMidpoints
- tests for label angles, tri-state rule, snapping, political zones
2026-09-17 16:27:55 -04:00

14 KiB

Game Map System

Leaflet-based interactive world map at /map with per-map road routing, resource nodes, GM terrain zones, and structure placement. This document covers the data model, coordinate conventions, gameplay rules, authoring workflows, and the GeoJSON import format.

Collections

Collection Slug Purpose Access
Maps maps Per-map config: world size, basemode, view defaults Config: maps:*
Map Roads map-roads Road polylines used by ground shipment routing Read: logged-in; write: maps:*
Map Zones map-zones Terrain polygons (water blocks land placement) Read: logged-in; write: maps:*
Resource Nodes resource-nodes Deposits validated by blueprint ranges Read: logged-in; write: maps:*
Game Structures game-structures Gained map, constructionStatus, constructionStartedAt/constructionCompletesAt unchanged
Shipments shipments Gained map, routePath (rendered polyline snapshot) unchanged
Vehicles (blueprint) vehicles Gained transportMode (ground/air/sea) unchanged

The map page (/map) serves feature data to any logged-in user through GET /api/map-features?mapId=<id>, while the collections themselves stay gated in the admin panel. Editing map features is possible in two places:

  1. The map UI's "Edit features" mode (requires maps:* permissions), or
  2. The Payload admin panel (World group).

Coordinate conventions

  • All map feature geometry (map-roads.points, map-zones.points, resource-nodes.position) is stored as JSON arrays of [x, y] tuples in real meters on the map grid. Origin is the top-left corner, x runs east, y runs south (matching Arma 3 world coordinates).
  • game-structures.coordinates keeps its existing PostGIS point column and is read as an [x, y] tuple of map meters.
  • Per-map world size (worldSizeWidth/worldSizeHeight, e.g. 8192 for Altis) bounds every coordinate. GameRules.proximityThreshold is meters (default 1000).
  • The map UI flips the y axis for Leaflet's CRS.Simple: Leaflet latlng [-y, x].

Routing (ground shipments)

createShipment (src/app/(frontend)/logistics/shipments/actions.ts) resolves the route per vehicle transportMode:

  • ground: Dijkstra over the map's road graph (src/lib/map/routing.ts). The graph is built from all road polylines on the map; roads connect where they share a vertex within a 1 m epsilon, so author junctions by reusing identical endpoint coordinates. Origin and destination structures snap to the nearest graph vertex within DEFAULT_SNAP_RADIUS_METERS (1000 m). If either end cannot snap, or the two components are disconnected, the shipment is refused with a clear error. Edge weight is length / speedMultiplier, so speedMultiplier steers which route wins; the shipment's stored distance is always the geometric route length in meters (fuel is charged on that).
  • air / sea: straight-line distance, routePath is the two endpoints.

Cross-map shipments are refused (origin and destination must carry the same map). Shipment speed math converts meters to km once in src/lib/shipping.ts (calculateTransitTime); fuel stays distance_m * fuelConsumptionRate. Active shipments render interpolated along routePath on the map between dispatchedAt and estimatedArrival, refreshed by the game tick SSE stream.

Placement and construction

placeStructure (src/app/(frontend)/logistics/map/actions.ts) is gated by hasLogisticsQualification (admins/developers pass) and enforces server-side:

  1. Map exists and is active, coordinates inside world bounds.
  2. Terrain: land blueprints are rejected inside drawn water polygons; water blueprints require a water polygon. A map with no zones accepts everything (default terrain is buildable land).
  3. resourcesInRange: every blueprint requirement needs a matching resource node within range (meters) on the same map.
  4. structuresInRange: same check against existing game structures.

Created sites start as constructionStatus: "awaiting_materials". Construction runs on timestamps, not tick counts (src/lib/map/construction.ts):

  • maybeStartConstruction is called at delivery time: from completeShipment (shipment arrival), the structures addResource / transferResource server actions, and placeStructure (blueprints without materials start immediately). When every blueprint material is present in the site's storage (grid + void) it consumes them (consumeStorage), sets constructionStartedAt = now and constructionCompletesAt = now + the blueprint's constructionDurationMinutes, and flips the site to building.
  • the game tick sweep (processConstructionTick) only completes: sites whose constructionCompletesAt has passed flip to complete and emit structure:construction-complete (idempotent).

structure:placed is emitted at placement time. Blueprint requiredTech stays display-only in the placement dialog (no research system exists).

Authoring

Map UI edit mode

"Edit features" on /map (requires maps:*) supports:

  • New road: click vertices along the route, at least 2; set name, description, surface (paved/dirt/trail), speed multiplier, and styling (stroke color hex, width 1-10, dash style, name-label select: Auto/Shown/ Hidden, where Auto labels paved roads always and dirt/trail roads only at close zoom, 4 m/px or finer); Save.
  • New zone: click at least 3 vertices; set name, description, type (water/land/territory/border), and styling (fill color, edge color, fill opacity 0-1, edge width, name-label toggle); Save. Territory and Country Border are informational political boundaries (they never gate placement; only water does) and default to amber 0.1 / violet 0.06 fill.
  • New node: click once to set the position; set name, description, resource, richness; Save.
  • Existing roads/zones/nodes show Edit + Delete buttons in their popup while in edit mode. Edit loads the feature's geometry and attributes into the authoring toolbar as a draft: click the map to add/reposition points, adjust the fields, then "Save changes" (requires maps:update). Renaming, retyping, restyling, and description edits work the same way; the feature's map cannot be changed.
  • Vertex editing: while a road/zone/node draft is active, every draft vertex renders as a draggable square handle: drag to move it (drag-end snaps), right-click to delete it (roads keep 2+ points, zones 3+), and the small cyan square at the middle of each segment inserts a vertex there when clicked.
  • Snapping: while authoring, clicks and dragged vertices snap to existing geometry within 12 screen px: feature vertices (roads/zones/nodes/ structures, including the draft's own other vertices for closing polygons) and edges (nearest point on any road segment or zone side, closing segment included) so a road can run exactly along a zone border or two zones can share an edge without gaps or overlap. Vertices beat edges near corners (an edge must be at least twice as close as the nearest vertex to win); a moving square preview shows where the click will land (emerald = vertex, cyan = edge).

Labels and styling on the live map

  • Descriptions: every road, zone, and node can carry a description; it shows in the hover tooltip (second, non-uppercase line) and in the popup.
  • Resources: nodes can yield multiple resources (the authoring toolbar lists every resource as a toggle; tooltips and popups show them all), and zones can carry resources as areas (oil fields and the like): a zone with resources is listed in its tooltip and popup, and a blueprint's resourcesInRange requirement is satisfied when the site sits INSIDE such a zone, even with no node nearby.
  • Zone labels: zones with a name and "Show Name Label" enabled render the name at the polygon's area-weighted centroid (mono uppercase on a subtle dark chip with a halo).
  • Road labels: named roads with "Show Name Label" enabled render their name as curved SVG textPath text that follows a Catmull-Rom smoothed version of the road's bends, centered on the line, with a dark casing outline for contrast. Labels appear only once the road is at least 120 screen px long, roughly every 400 screen px, capped at 12 per road, recomputed on zoom. Text on segments that would render upside down, or exactly vertical reading downward, is flipped to a reversed path: text always reads left-to-right, and vertical labels read bottom-to-top.
  • Structure labels: structures with "Show Name Label" enabled (Game Structures admin, default on) get a persistent name label next to their pin. When structures cluster closer than 56 screen px at the current zoom, only one label wins: highest labelPriority (0-100, admin-settable), tie-broken by stored mass, then id. Zooming in spreads the cluster and reveals the losing labels. Structures with the label off keep hover-only tooltips. Persistent labels sit on a subtle dark chip for contrast.
  • Road/zone styling: stroke color (#rrggbb), width, and dash style (solid/dashed/dotted) are editable per road; fill color, edge color, fill opacity, and edge width per zone. Empty values fall back to per-type/surface defaults (paved light gray, dirt tan, trail dimmer; water #38bdf8 0.25 fill, land #4ade80 0.08 fill). Invalid hex is rejected at save time (both the authoring UI actions and the import route).

GeoJSON import

POST /api/map-import (admin/developer, maps:create) accepts a JSON body:

{
  "mapId": 1,
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [100, 200],
          [400, 200]
        ]
      },
      "properties": { "name": "Highway 1", "surface": "paved", "speedMultiplier": 1.5 }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [0, 0],
            [0, 500],
            [500, 500],
            [500, 0],
            [0, 0]
          ]
        ]
      },
      "properties": { "name": "Bay", "type": "water" }
    },
    {
      "type": "Feature",
      "geometry": { "type": "Point", "coordinates": [1200, 3400] },
      "properties": { "name": "North copper", "resource": 12, "richness": 80 }
    }
  ]
}

Geometry mapping:

GeoJSON geometry Collection Properties
LineString map-roads name, description (optional), surface (paved|dirt|trail, default paved), speedMultiplier (default 1), strokeColor (#rrggbb), strokeWeight, strokeStyle (solid default, dashed, dotted)
Polygon map-zones name, description (optional), type (water default, or land); only the outer ring is used; fillColor, strokeColor (#rrggbb), fillOpacity (0-1), strokeWeight
Point resource-nodes name, description (optional), resource (resource ID) or resourceName (exact match), richness (default 100)

GeoJSON positions are interpreted directly as map meters: [x, y] = [east, south] on the map grid (no axis flip on import; the map UI applies its flip only for display). The response reports per-type created counts plus a list of per-feature errors; valid features are imported even when others fail.

Permissions

Action Requirement
View the map + features Any logged-in user
Place structures hasLogisticsQualification (logistics qualification, admin/developer pass)
Configure maps, roads, zones, nodes maps:create / maps:update / maps:delete
Admin panel visibility of map feature collections <slug>:read + admin:<slug>:manage

Basemap configuration

Per map, in the Payload admin panel (World: Maps):

  • basemapMode: image: upload a top-down map image (Media); it is stretched across the full world bounds via ImageOverlay.
  • basemapMode: tiles: set tileUrlTemplate (XYZ, e.g. https://tiles.example.com/{z}/{x}/{y}.png) rendered via TileLayer. Tile pyramids follow the standard XYZ convention: zoom 0 is one 256 px tile covering the whole map, and each zoom level doubles the resolution (set the map's minZoom to the lowest level your pyramid actually serves).
  • minZoom/maxZoom/defaultZoom and defaultCenterX/defaultCenterY (meters) tune the initial view. isActive hides maps from the page.