Add docs/map-system.md with the mapping table and update AGENTS.md plus sub-AGENTS files for the map collections, routing, placement, and construction model. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
13 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).
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 toggle); Save.
- New zone: click at least 3 vertices; set name, description, type (water/land), and styling (fill color, edge color, fill opacity 0-1, edge width, name-label toggle); Save.
- 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.
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).
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 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.