# 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=`, 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: ```json { "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 | `:read` + `admin::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.