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>
230 lines
13 KiB
Markdown
230 lines
13 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 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
|
|
`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.
|