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

248 lines
14 KiB
Markdown

# 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:
```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 | `<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.