- AGENTS.md documents world map eligibility, node icons, currency emblem wiring, division access and the dev dashboard gate - docs/map-system.md covers the picker intel fields and marker nodes
16 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:
- The map UI's "Edit features" mode (requires
maps:*permissions), or - 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.coordinateskeeps 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.proximityThresholdis 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 withinDEFAULT_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 islength / speedMultiplier, sospeedMultipliersteers which route wins; the shipment's storeddistanceis always the geometric route length in meters (fuel is charged on that). - air / sea: straight-line distance,
routePathis 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:
- Map exists and is active, coordinates inside world bounds.
- 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).
resourcesInRange: every blueprint requirement needs a matching resource node within range (meters) on the same map.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):
maybeStartConstructionis called at delivery time: fromcompleteShipment(shipment arrival), the structuresaddResource/transferResourceserver actions, andplaceStructure(blueprints without materials start immediately). When every blueprint material is present in the site's storage (grid + void) it consumes them (consumeStorage), setsconstructionStartedAt = nowandconstructionCompletesAt = now +the blueprint'sconstructionDurationMinutes, and flips the site tobuilding.- the game tick sweep (
processConstructionTick) only completes: sites whoseconstructionCompletesAthas passed flip tocompleteand emitstructure:construction-complete(idempotent). - crew speed: blueprints with
constructionCrewRequired> 0 refuse to start with no labor (no_crew) and build at the staffed fraction of full speed (floor 0.25); labor-classifications carrybuildEfficiencyper worker;setLaborHeadcountre-prices the remaining work on a building site viaapplyCrewRateChange(src/lib/base/constructionSpeed.ts): the finished fraction is preserved and the deadline pulls in or pushes out.
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/other), and styling (fill color, edge color, fill opacity 0-1, edge width, name-label toggle); Save. Territory and Country Border are informational political boundaries and Other is for generic areas like named compounds (they never gate placement; only water does); defaults are amber 0.1, violet 0.06, and slate 0.08 respectively.
- 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
resourcesInRangerequirement 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
textPathtext 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 (pavedlight gray,dirttan,traildimmer; water#38bdf80.25 fill, land#4ade800.08 fill). Invalid hex is rejected at save time (both the authoring UI actions and the import route). - Compounds: structures may set
parentStructure(a compound hub) at placement (dialog picker; same map, no nesting). Operational children pool storage into the hub:/api/map-featuresfolds child storage totals into the hub feature, hides child map labels, and reportscompoundChildren; the hub ribbon and popup footer show "Compound: N buildings". Aggregation math:src/lib/map/compounds.ts. - Adjacency auras: blueprints may declare an "Adjacency Aura"
(production multiplier or capacity bonus over a meter range) that projects
onto operational neighbors only; the production tick stacks the aura with
module effects (
src/lib/base/adjacency.ts).
World Map picker
/map renders a grid of eligible map cards (basemap thumbnail, name, campaign,
world size); clicking one opens /map/[id]. Eligibility (src/lib/map/screen.ts):
worldMap flag on the map, active, and not tied to a custom (side) campaign.
Optional intel fields on maps (description, general weather, real-world
lat/long, ease of access, faction control) show on the picker cards when set.
The in-view map switcher was removed; mission-only maps never appear here but
remain attachable to missions.
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), icon (marker set key, default marker) |
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 viaImageOverlay.basemapMode: tiles: settileUrlTemplate(XYZ, e.g.https://tiles.example.com/{z}/{x}/{y}.png) rendered viaTileLayer. 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'sminZoomto the lowest level your pyramid actually serves).minZoom/maxZoom/defaultZoomanddefaultCenterX/defaultCenterY(meters) tune the initial view.isActivehides maps from the page.