Compare commits
12 commits
074fdb02bf
...
9ca85fce9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ca85fce9b | |||
| b5925b7633 | |||
| 8cab545b9d | |||
| a770c62d6f | |||
| ad2fbb167a | |||
| fcf220b961 | |||
| 5be390012a | |||
| c64c9da0da | |||
| 5f06039697 | |||
| 28816b7084 | |||
| 24d707a68a | |||
| dce6f8f2ee |
73 changed files with 121202 additions and 173 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -194,6 +194,20 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
|
|||
- **UI**: `src/components/frontend/baseManagement/` (`StaffRoster`, `HireStaffDialog`, `EmploymentCard`, `UpgradeCard`) mounted on the structure detail page (`logistics/structures/[id]`).
|
||||
- **Events**: `staff:hired` / `staff:terminated` / `staff:headcount-set` / `staff:salary-paid` / `staff:salary-unpaid` / `structure:maintenance-paid` / `structure:maintenance-unpaid` / `structure:upgraded` / `structure:upkeep-short`. Event target: `npc-staffing`.
|
||||
|
||||
## Game Map System
|
||||
|
||||
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
|
||||
|
||||
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible, `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land, fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
|
||||
- **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees).
|
||||
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
|
||||
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
|
||||
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
|
||||
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`).
|
||||
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts); `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
|
||||
- **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters.
|
||||
- **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`.
|
||||
|
||||
## Personnel (NPC directory)
|
||||
|
||||
- `/personnel/contacts` — NPC directory over `game-npcs` (`src/components/frontend/personnel/`: `NpcDirectory`, `NpcCard`); `/personnel/contacts/[id]` — NPC profile (faction, in-game vs generated badges, vendor listings, `NpcProfile`).
|
||||
|
|
@ -380,7 +394,7 @@ Where it fits: large feature/hero cards, section headers, showcase surfaces. Whe
|
|||
|
||||
Whenever creating a new component or refactoring an existing component's styling, **ask the user** whether they want to try applying the tactical dossier style to it. Concretely:
|
||||
|
||||
- **Yes**: demo similar styling on that component first (same approach as the campaign cards: build it, let the user eyeball it, iterate on tuning).
|
||||
- **Yes**: demo similar styling on that component first (same approach as the campaign cards: build it, let the user eyeball it, iterate on tuning).
|
||||
- **No, or no answer**: stick with the existing styling conventions (shadcn + the site's dark theme conventions). Do not apply tactical styling unasked, and do not nag: ask once, then proceed either way.
|
||||
|
||||
## Environment
|
||||
|
|
|
|||
13
bun.lock
13
bun.lock
|
|
@ -39,6 +39,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"discord.js": "^14.27.0",
|
||||
"dotenv": "17.2.3",
|
||||
"graphql": "^16.14.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lexical": "0.41.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.2.11",
|
||||
|
|
@ -59,6 +61,7 @@
|
|||
"radix-ui": "^1.6.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-directive": "4.0.0",
|
||||
|
|
@ -737,6 +740,8 @@
|
|||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
|
||||
|
||||
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
|
||||
|
||||
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
|
||||
|
|
@ -985,12 +990,16 @@
|
|||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||
|
||||
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
|
@ -1823,6 +1832,8 @@
|
|||
|
||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "0.3.23" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||
|
||||
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="],
|
||||
|
|
@ -2211,6 +2222,8 @@
|
|||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-mentions": ["react-mentions@4.4.10", "", { "dependencies": { "@babel/runtime": "7.4.5", "invariant": "^2.2.4", "prop-types": "^15.5.8", "substyle": "^9.1.0" }, "peerDependencies": { "react": ">=16.8.3", "react-dom": ">=16.8.3" } }, "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA=="],
|
||||
|
|
|
|||
230
docs/map-system.md
Normal file
230
docs/map-system.md
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# 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.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.39",
|
||||
"version": "0.2.40",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
@ -63,6 +63,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -72,6 +73,7 @@
|
|||
"discord.js": "^14.27.0",
|
||||
"dotenv": "17.2.3",
|
||||
"graphql": "^16.14.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lexical": "0.41.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.2.11",
|
||||
|
|
@ -83,6 +85,7 @@
|
|||
"radix-ui": "^1.6.7",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-directive": "4.0.0",
|
||||
|
|
|
|||
|
|
@ -445,7 +445,12 @@ async function resolveSideOpMap(
|
|||
if (existing) return existing.id;
|
||||
const created = await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: customName },
|
||||
data: {
|
||||
name: customName,
|
||||
worldSizeWidth: 8192,
|
||||
worldSizeHeight: 8192,
|
||||
basemapMode: "image",
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
return created.id;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Metadata } from "next";
|
|||
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { canAccessAdminPanel } from "@/utils/access-control/hasPermission";
|
||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
||||
|
|
@ -77,6 +78,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
|
||||
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||
|
||||
let isAdmin = false;
|
||||
|
||||
if (user) {
|
||||
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
|
||||
await Promise.all([
|
||||
|
|
@ -121,6 +124,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||
|
||||
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
||||
|
||||
isAdmin = await canAccessAdminPanel(payload, user);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -141,7 +146,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
rank: currentRank,
|
||||
}}
|
||||
xpLevel={xpLevel}
|
||||
adminUrl={payloadConfig.routes.admin}
|
||||
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
||||
hasIntelligence={isIntelligence}
|
||||
hasLogistics={isLogistics}
|
||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
||||
|
|
|
|||
498
src/app/(frontend)/logistics/map/actions.ts
Normal file
498
src/app/(frontend)/logistics/map/actions.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { GameStructure } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import {
|
||||
validatePlacement,
|
||||
type BlueprintTerrain,
|
||||
type NodeInput,
|
||||
type StructureInput,
|
||||
type ZoneInput,
|
||||
} from "@/lib/map/placement";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
|
||||
import { validateHexColor } from "@/lib/map/style";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({
|
||||
headers: hdrs,
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function placeStructure(params: {
|
||||
mapId: number;
|
||||
blueprintId: number;
|
||||
name: string;
|
||||
factionId?: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
if (!(await hasLogisticsQualification(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Logistics qualification required to place structures.",
|
||||
};
|
||||
}
|
||||
|
||||
const { mapId, blueprintId, name, x, y } = params;
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return { success: false, error: "Valid coordinates are required." };
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { success: false, error: "A display name is required." };
|
||||
}
|
||||
|
||||
const map = await payload
|
||||
.findByID({ collection: "maps", id: mapId, depth: 0 })
|
||||
.catch(() => null);
|
||||
if (!map) {
|
||||
return { success: false, error: "Map not found." };
|
||||
}
|
||||
if (!map.isActive) {
|
||||
return { success: false, error: "This map is not active." };
|
||||
}
|
||||
|
||||
const worldW = map.worldSizeWidth ?? 8192;
|
||||
const worldH = map.worldSizeHeight ?? 8192;
|
||||
if (x < 0 || x > worldW || y < 0 || y > worldH) {
|
||||
return { success: false, error: "Site is outside the map bounds." };
|
||||
}
|
||||
|
||||
const blueprint = await payload
|
||||
.findByID({ collection: "structures", id: blueprintId, depth: 1 })
|
||||
.catch(() => null);
|
||||
if (!blueprint) {
|
||||
return { success: false, error: "Structure blueprint not found." };
|
||||
}
|
||||
|
||||
if (params.factionId != null) {
|
||||
const faction = await payload
|
||||
.findByID({ collection: "factions", id: params.factionId, depth: 0 })
|
||||
.catch(() => null);
|
||||
if (!faction) {
|
||||
return { success: false, error: "Faction not found." };
|
||||
}
|
||||
}
|
||||
|
||||
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "map-zones",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "resource-nodes",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "game-structures",
|
||||
where: { and: [{ map: { equals: mapId } }, { coordinates: { exists: true } }] },
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const zones: ZoneInput[] = zonesRes.docs.map((zone) => ({
|
||||
id: zone.id,
|
||||
name: zone.name ?? null,
|
||||
type: zone.type,
|
||||
resources: ((zone.resources ?? []) as number[]) ?? [],
|
||||
points: (zone.points ?? []) as MapPoint[],
|
||||
}));
|
||||
const nodes: NodeInput[] = nodesRes.docs.map((node) => ({
|
||||
id: node.id,
|
||||
resources: ((node.resources ?? []) as number[]) ?? [],
|
||||
position: (node.position ?? [0, 0]) as MapPoint,
|
||||
}));
|
||||
const structures: StructureInput[] = structuresRes.docs
|
||||
.map((doc) => {
|
||||
const structure = doc as unknown as GameStructure;
|
||||
return {
|
||||
id: structure.id,
|
||||
type: typeof structure.type === "object" ? structure.type.id : structure.type,
|
||||
coordinates: structure.coordinates as MapPoint | null,
|
||||
};
|
||||
})
|
||||
.filter((structure): structure is StructureInput & { coordinates: MapPoint } =>
|
||||
isPointArray(structure.coordinates, 1),
|
||||
);
|
||||
|
||||
const point: MapPoint = [x, y];
|
||||
const terrain = (blueprint.terrainType ?? "land") as BlueprintTerrain;
|
||||
const validation = validatePlacement({
|
||||
zones,
|
||||
nodes,
|
||||
structures,
|
||||
blueprintTerrain: terrain,
|
||||
resourcesInRange: (blueprint.resourcesInRange ?? []).map((requirement) => ({
|
||||
resource:
|
||||
typeof requirement.resource === "object"
|
||||
? requirement.resource?.id
|
||||
: requirement.resource,
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
structuresInRange: (blueprint.structuresInRange ?? []).map((requirement) => ({
|
||||
structure:
|
||||
typeof requirement.structure === "object"
|
||||
? requirement.structure?.id
|
||||
: requirement.structure,
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
point,
|
||||
});
|
||||
|
||||
if (!validation.allowed) {
|
||||
return { success: false, error: validation.error ?? "Site is not valid for placement." };
|
||||
}
|
||||
|
||||
const structure = await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: name.trim(),
|
||||
type: blueprintId,
|
||||
faction: params.factionId ?? null,
|
||||
map: mapId,
|
||||
coordinates: point,
|
||||
constructionStatus: "awaiting_materials",
|
||||
},
|
||||
});
|
||||
|
||||
// Blueprints without materials start building immediately (the helper
|
||||
// no-ops while any material is still missing).
|
||||
try {
|
||||
await maybeStartConstruction(payload, structure.id);
|
||||
} catch {
|
||||
// The site starts on the next delivery instead.
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StructurePlaced,
|
||||
message: `Construction site "${name.trim()}" (${blueprint.name}) placed at ${Math.round(x)}, ${Math.round(y)} on ${map.name}`,
|
||||
actor: user.id,
|
||||
structure: structure.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: structure.id,
|
||||
data: {
|
||||
mapId,
|
||||
blueprintId,
|
||||
blueprintName: blueprint.name,
|
||||
point,
|
||||
constructionDurationMinutes: blueprint.constructionDurationMinutes,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: structure };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export interface MapRoadInput {
|
||||
id?: number | null;
|
||||
mapId: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
surface: "paved" | "dirt" | "trail";
|
||||
speedMultiplier: number;
|
||||
strokeColor?: string | null;
|
||||
strokeWeight?: number | null;
|
||||
strokeStyle?: "solid" | "dashed" | "dotted" | null;
|
||||
labelVisible?: boolean | null;
|
||||
points: MapPoint[];
|
||||
}
|
||||
|
||||
export interface MapZoneInput {
|
||||
id?: number | null;
|
||||
mapId: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
type: "water" | "land";
|
||||
resourceIds?: number[];
|
||||
fillColor?: string | null;
|
||||
strokeColor?: string | null;
|
||||
fillOpacity?: number | null;
|
||||
strokeWeight?: number | null;
|
||||
labelVisible?: boolean | null;
|
||||
points: MapPoint[];
|
||||
}
|
||||
|
||||
export interface MapNodeInput {
|
||||
id?: number | null;
|
||||
mapId: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
resourceIds: number[];
|
||||
position: MapPoint;
|
||||
richness?: number | null;
|
||||
}
|
||||
|
||||
async function requireMapPermission(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
user: { id: number | string },
|
||||
permission: "maps:create" | "maps:update" | "maps:delete",
|
||||
): Promise<boolean> {
|
||||
return hasPermission(payload, user, permission);
|
||||
}
|
||||
|
||||
function relationshipMapId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value && typeof value === "object" && "id" in value) {
|
||||
return (value as { id: number }).id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type MapFeatureCollection = "map-roads" | "map-zones" | "resource-nodes";
|
||||
|
||||
/** When editing, the feature must exist and belong to the map being edited. */
|
||||
async function existingFeatureOnMap(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
collection: MapFeatureCollection,
|
||||
id: number,
|
||||
mapId: number,
|
||||
label: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const existing = await payload.findByID({ collection, id, depth: 0 }).catch(() => null);
|
||||
if (!existing) return { ok: false, error: `${label} not found.` };
|
||||
if (relationshipMapId(existing.map) !== mapId) {
|
||||
return { ok: false, error: `This ${label.toLowerCase()} belongs to a different map.` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveMapRoad(input: MapRoadInput): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const editing = input.id != null;
|
||||
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
|
||||
return { success: false, error: "Map configuration permission required." };
|
||||
}
|
||||
if (!isPointArray(input.points, 2)) {
|
||||
return { success: false, error: "A road requires at least two points." };
|
||||
}
|
||||
if (input.speedMultiplier <= 0) {
|
||||
return { success: false, error: "Speed multiplier must be greater than zero." };
|
||||
}
|
||||
const colorCheck = validateHexColor(input.strokeColor);
|
||||
if (colorCheck !== true) return { success: false, error: colorCheck };
|
||||
const map = await payload
|
||||
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
|
||||
.catch(() => null);
|
||||
if (!map) return { success: false, error: "Map not found." };
|
||||
|
||||
const roadData = {
|
||||
name: input.name?.trim() || null,
|
||||
description: input.description?.trim() || null,
|
||||
surface: input.surface,
|
||||
speedMultiplier: input.speedMultiplier,
|
||||
strokeColor: input.strokeColor?.trim() || null,
|
||||
strokeWeight: input.strokeWeight ?? null,
|
||||
strokeStyle: input.strokeStyle ?? null,
|
||||
labelVisible: input.labelVisible ?? null,
|
||||
points: input.points,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
const existing = await existingFeatureOnMap(
|
||||
payload,
|
||||
"map-roads",
|
||||
input.id!,
|
||||
input.mapId,
|
||||
"Road",
|
||||
);
|
||||
if (!existing.ok) return { success: false, error: existing.error };
|
||||
const road = await payload.update({
|
||||
collection: "map-roads",
|
||||
id: input.id!,
|
||||
data: roadData,
|
||||
});
|
||||
return { success: true, data: road };
|
||||
}
|
||||
|
||||
const road = await payload.create({
|
||||
collection: "map-roads",
|
||||
data: { ...roadData, map: input.mapId },
|
||||
});
|
||||
return { success: true, data: road };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMapZone(input: MapZoneInput): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const editing = input.id != null;
|
||||
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
|
||||
return { success: false, error: "Map configuration permission required." };
|
||||
}
|
||||
if (!isPointArray(input.points, 3)) {
|
||||
return { success: false, error: "A zone requires at least three points." };
|
||||
}
|
||||
for (const color of [input.fillColor, input.strokeColor]) {
|
||||
const colorCheck = validateHexColor(color);
|
||||
if (colorCheck !== true) return { success: false, error: colorCheck };
|
||||
}
|
||||
const map = await payload
|
||||
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
|
||||
.catch(() => null);
|
||||
if (!map) return { success: false, error: "Map not found." };
|
||||
|
||||
const zoneData = {
|
||||
name: input.name?.trim() || null,
|
||||
description: input.description?.trim() || null,
|
||||
type: input.type,
|
||||
resources: input.resourceIds ?? [],
|
||||
fillColor: input.fillColor?.trim() || null,
|
||||
strokeColor: input.strokeColor?.trim() || null,
|
||||
fillOpacity: input.fillOpacity ?? null,
|
||||
strokeWeight: input.strokeWeight ?? null,
|
||||
labelVisible: input.labelVisible ?? null,
|
||||
points: input.points,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
const existing = await existingFeatureOnMap(
|
||||
payload,
|
||||
"map-zones",
|
||||
input.id!,
|
||||
input.mapId,
|
||||
"Zone",
|
||||
);
|
||||
if (!existing.ok) return { success: false, error: existing.error };
|
||||
const zone = await payload.update({
|
||||
collection: "map-zones",
|
||||
id: input.id!,
|
||||
data: zoneData,
|
||||
});
|
||||
return { success: true, data: zone };
|
||||
}
|
||||
|
||||
const zone = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: { ...zoneData, map: input.mapId },
|
||||
});
|
||||
return { success: true, data: zone };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const editing = input.id != null;
|
||||
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
|
||||
return { success: false, error: "Map configuration permission required." };
|
||||
}
|
||||
if (validatePosition(input.position) !== true) {
|
||||
return { success: false, error: "A node requires a single [x, y] position." };
|
||||
}
|
||||
const resourceIds = [...new Set(input.resourceIds)];
|
||||
if (resourceIds.length === 0) {
|
||||
return { success: false, error: "A node requires at least one resource." };
|
||||
}
|
||||
const resources = await payload.find({
|
||||
collection: "resources",
|
||||
where: { id: { in: resourceIds } },
|
||||
depth: 0,
|
||||
limit: resourceIds.length,
|
||||
});
|
||||
if (resources.docs.length !== resourceIds.length) {
|
||||
return { success: false, error: "One or more resources not found." };
|
||||
}
|
||||
|
||||
const nodeData = {
|
||||
name: input.name.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
resources: resourceIds,
|
||||
position: input.position,
|
||||
richness: input.richness ?? 100,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
const existing = await existingFeatureOnMap(
|
||||
payload,
|
||||
"resource-nodes",
|
||||
input.id!,
|
||||
input.mapId,
|
||||
"Node",
|
||||
);
|
||||
if (!existing.ok) return { success: false, error: existing.error };
|
||||
const node = await payload.update({
|
||||
collection: "resource-nodes",
|
||||
id: input.id!,
|
||||
data: nodeData,
|
||||
});
|
||||
return { success: true, data: node };
|
||||
}
|
||||
|
||||
const node = await payload.create({
|
||||
collection: "resource-nodes",
|
||||
data: { ...nodeData, map: input.mapId },
|
||||
});
|
||||
return { success: true, data: node };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMapFeature(
|
||||
kind: "road" | "zone" | "node",
|
||||
id: number,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await requireMapPermission(payload, user, "maps:delete"))) {
|
||||
return { success: false, error: "Map configuration permission required." };
|
||||
}
|
||||
const collection =
|
||||
kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes";
|
||||
await payload.delete({ collection, id, overrideAccess: false });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
|||
import { calculateDistance } from "@/lib/distance";
|
||||
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -95,20 +97,6 @@ export async function createShipment(params: {
|
|||
};
|
||||
}
|
||||
|
||||
// Calculate distance
|
||||
const distance = calculateDistance(originCoords, destCoords);
|
||||
|
||||
// Check proximity threshold
|
||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
||||
const threshold = (gameRules.proximityThreshold as number) || 10;
|
||||
|
||||
if (distance <= threshold) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Structures are within proximity (${distance.toFixed(1)} ≤ ${threshold}). Use instant transfer instead.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch vehicle
|
||||
const vehicle = (await payload
|
||||
.findByID({
|
||||
|
|
@ -146,6 +134,69 @@ export async function createShipment(params: {
|
|||
return { success: false, error: "Vehicle type not found." };
|
||||
}
|
||||
|
||||
const originMapId = typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null);
|
||||
const destMapId =
|
||||
typeof destination.map === "object" ? (destination.map?.id ?? null) : (destination.map ?? null);
|
||||
|
||||
if (!originMapId || !destMapId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Both structures must be placed on a game map before shipping between them.",
|
||||
};
|
||||
}
|
||||
|
||||
if (originMapId !== destMapId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cross-map shipments are not supported: "${origin.name}" and "${destination.name}" are on different maps.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate route distance. Ground vehicles path over the road network;
|
||||
// air and sea vehicles travel straight-line.
|
||||
const transportMode = vehicleType.transportMode ?? "ground";
|
||||
let distance: number;
|
||||
let routePath: MapPoint[] | null = null;
|
||||
|
||||
if (transportMode === "ground") {
|
||||
const roads = await payload.find({
|
||||
collection: "map-roads",
|
||||
where: { map: { equals: originMapId } },
|
||||
limit: 5000,
|
||||
depth: 0,
|
||||
});
|
||||
const graph = buildRoadGraph(
|
||||
roads.docs.map((road) => ({
|
||||
id: road.id,
|
||||
points: (road.points ?? []) as MapPoint[],
|
||||
speedMultiplier: road.speedMultiplier,
|
||||
})),
|
||||
);
|
||||
const route = findRoute(graph, originCoords, destCoords);
|
||||
if (!route.connected) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No connected road route between "${origin.name}" and "${destination.name}". Ground vehicles need roads joining both structures; use an air or sea vehicle instead.`,
|
||||
};
|
||||
}
|
||||
distance = route.distanceMeters;
|
||||
routePath = route.path;
|
||||
} else {
|
||||
distance = calculateDistance(originCoords, destCoords);
|
||||
routePath = [originCoords, destCoords];
|
||||
}
|
||||
|
||||
// Check proximity threshold
|
||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
||||
const threshold = (gameRules.proximityThreshold as number) || 1000;
|
||||
|
||||
if (distance <= threshold) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Structures are within proximity (${Math.round(distance)} m ≤ ${threshold} m). Use instant transfer instead.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate fuel cost
|
||||
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
||||
const fuelCost = calculateFuelCost(distance, consumptionRate);
|
||||
|
|
@ -283,9 +334,7 @@ export async function createShipment(params: {
|
|||
}
|
||||
|
||||
// Calculate transit time
|
||||
const speed = getVehicleEffectiveSpeed(
|
||||
vehicleType as unknown as Parameters<typeof getVehicleEffectiveSpeed>[0],
|
||||
);
|
||||
const speed = getVehicleEffectiveSpeed(vehicleType);
|
||||
const transitTimeMs = calculateTransitTime(distance, speed);
|
||||
const now = new Date();
|
||||
|
||||
|
|
@ -332,6 +381,8 @@ export async function createShipment(params: {
|
|||
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
|
||||
origin: originId,
|
||||
destination: destinationId,
|
||||
map: originMapId,
|
||||
routePath,
|
||||
transportVehicle: vehicleId,
|
||||
distance: Math.round(distance * 100) / 100,
|
||||
cargo: cargo.map((c) => ({
|
||||
|
|
|
|||
297
src/app/(frontend)/logistics/structures/[id]/detail-data.ts
Normal file
297
src/app/(frontend)/logistics/structures/[id]/detail-data.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { getPayload, type Payload } from "payload";
|
||||
import type { Faction, GameStructure, Structure } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
calculateStoredMass,
|
||||
calculateUtilization,
|
||||
getGridSlots,
|
||||
} from "@/lib/logistics";
|
||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export interface StructureDetailData {
|
||||
structure: DetailStructure;
|
||||
blueprint: Structure;
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
derived: {
|
||||
currentMass: number;
|
||||
utilization: number | null;
|
||||
gridSlots: number | null;
|
||||
totalValue: number;
|
||||
constructionMaterials: { name: string; required: number; delivered: number }[];
|
||||
};
|
||||
upgrade: {
|
||||
targetName: string;
|
||||
materials: { name: string; needed: number; have: number }[];
|
||||
} | null;
|
||||
staffing: {
|
||||
structureStaffing: StructureStaffingOption;
|
||||
staff: {
|
||||
id: number;
|
||||
npcName: string | null;
|
||||
positionType: string;
|
||||
positionTitle: string;
|
||||
salary: number;
|
||||
headcount: number;
|
||||
laborClassificationId: number | null;
|
||||
hiredAt: string;
|
||||
}[];
|
||||
classifications: LaborClassificationOption[];
|
||||
npcs: { id: number; name: string }[];
|
||||
};
|
||||
hiring: { enabled: boolean; disabledMessage?: string };
|
||||
gates: { canManage: boolean; canManageStorage: boolean; canTransferStorage: boolean };
|
||||
currencyConfig: ReturnType<typeof currencyConfigFromGameRules>;
|
||||
}
|
||||
|
||||
export type DetailStructure = GameStructure & {
|
||||
type: Structure;
|
||||
faction?: Faction | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the structure detail page renders, as one serializable object.
|
||||
* Mirrors the queries in logistics/structures/[id]/page.tsx (kept separate on
|
||||
* purpose: the repo duplicates helpers per file rather than sharing them).
|
||||
*/
|
||||
export async function fetchStructureDetail(
|
||||
payload: Payload,
|
||||
structureId: number,
|
||||
user: { id: number | string } | null | undefined,
|
||||
): Promise<StructureDetailData | null> {
|
||||
const structureRes = await payload
|
||||
.findByID({
|
||||
collection: "game-structures",
|
||||
id: structureId,
|
||||
depth: 2,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!structureRes) return null;
|
||||
|
||||
const structure = structureRes as unknown as DetailStructure;
|
||||
const structureType = structure.type;
|
||||
const storedResources = structure.storedResources ?? [];
|
||||
const currentMass = calculateStoredMass(storedResources);
|
||||
const utilization = calculateUtilization(structureType, storedResources);
|
||||
const gridSlots = getGridSlots(structureType);
|
||||
const description = structureType.description
|
||||
? convertLexicalToPlaintext({ data: structureType.description })
|
||||
: null;
|
||||
|
||||
const image =
|
||||
typeof structureType.image === "object" && structureType.image !== null
|
||||
? structureType.image
|
||||
: null;
|
||||
const imageUrl = image?.url ?? null;
|
||||
|
||||
const upgradeTarget = structureType.upgradesInto;
|
||||
const upgradeTargetId =
|
||||
typeof upgradeTarget === "object" && upgradeTarget !== null ? upgradeTarget.id : upgradeTarget;
|
||||
let upgrade: StructureDetailData["upgrade"] = null;
|
||||
|
||||
if (typeof upgradeTargetId === "number") {
|
||||
const targetRes = await payload
|
||||
.findByID({ collection: "structures", id: upgradeTargetId, depth: 1 })
|
||||
.catch(() => null);
|
||||
|
||||
if (targetRes) {
|
||||
const target = targetRes as unknown as Structure;
|
||||
const resourceIds = target.materials
|
||||
.map((material) =>
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.id
|
||||
: material.resource,
|
||||
)
|
||||
.filter((resourceId): resourceId is number => typeof resourceId === "number");
|
||||
const resourceRes =
|
||||
resourceIds.length > 0
|
||||
? await payload.find({
|
||||
collection: "resources",
|
||||
where: { id: { in: resourceIds } },
|
||||
limit: resourceIds.length,
|
||||
depth: 0,
|
||||
})
|
||||
: null;
|
||||
const resourceNames = new Map(
|
||||
(resourceRes?.docs ?? []).map((resource) => [resource.id, resource.name]),
|
||||
);
|
||||
const inventory = [...storedResources, ...(structure.voidStorage ?? [])];
|
||||
|
||||
upgrade = {
|
||||
targetName: target.name,
|
||||
materials: target.materials.map((material) => {
|
||||
const resourceId =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.id
|
||||
: material.resource;
|
||||
const resourceName =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.name
|
||||
: (resourceNames.get(resourceId) ?? `Resource #${resourceId}`);
|
||||
const have = inventory.reduce((total, entry) => {
|
||||
const entryResourceId =
|
||||
typeof entry.resource === "object" && entry.resource !== null
|
||||
? entry.resource.id
|
||||
: entry.resource;
|
||||
return entryResourceId === resourceId ? total + entry.amount : total;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
name: resourceName,
|
||||
needed: Math.ceil((material.amount ?? 0) * (structureType.upgradeCostMultiplier ?? 1)),
|
||||
have,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const staffRes = await payload.find({
|
||||
collection: "npc-staffing",
|
||||
where: {
|
||||
and: [{ structure: { equals: structure.id } }, { status: { equals: "active" } }],
|
||||
},
|
||||
limit: 50,
|
||||
depth: 1,
|
||||
});
|
||||
const staff = staffRes.docs.map((entry) => {
|
||||
const npc = entry.npc;
|
||||
const npcName =
|
||||
typeof npc === "object" && npc !== null
|
||||
? npc.name
|
||||
: typeof npc === "number"
|
||||
? `NPC #${npc}`
|
||||
: null;
|
||||
const laborClassificationId =
|
||||
typeof entry.laborClassification === "object" && entry.laborClassification !== null
|
||||
? entry.laborClassification.id
|
||||
: typeof entry.laborClassification === "number"
|
||||
? entry.laborClassification
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
npcName,
|
||||
positionType: entry.positionType,
|
||||
positionTitle: entry.positionTitle ?? "",
|
||||
salary: entry.salary ?? 0,
|
||||
headcount: entry.headcount ?? 1,
|
||||
laborClassificationId,
|
||||
hiredAt: new Date(entry.hiredAt ?? entry.createdAt).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const canManage = await hasLogisticsQualification(payload, user);
|
||||
const canManageStorage = await isSuperuser(payload, user);
|
||||
const canTransferStorage = canManageStorage || canManage;
|
||||
|
||||
const classificationsRes = await payload.find({
|
||||
collection: "labor-classifications",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
});
|
||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
key: doc.key,
|
||||
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
||||
availableHeadcount: doc.availableHeadcount ?? 0,
|
||||
}));
|
||||
|
||||
const rules = await payload.findGlobal({
|
||||
slug: "game-rules",
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const currencyConfig = currencyConfigFromGameRules(rules);
|
||||
|
||||
const structureStaffing: StructureStaffingOption = {
|
||||
id: structure.id,
|
||||
name: structure.name,
|
||||
maintenanceCost: structure.maintenanceCost ?? 0,
|
||||
laborRequirements: (structure.laborRequirements ?? []).map((requirement) => {
|
||||
const classificationId =
|
||||
typeof requirement.classification === "object"
|
||||
? requirement.classification.id
|
||||
: requirement.classification;
|
||||
return {
|
||||
classificationId,
|
||||
requiredHeadcount: requirement.requiredHeadcount,
|
||||
maxHeadcount: requirement.maxHeadcount ?? 0,
|
||||
enforcedSalary: requirement.enforcedSalary ?? null,
|
||||
currentHeadcount: staff
|
||||
.filter((member) => member.laborClassificationId === classificationId)
|
||||
.reduce((total, member) => total + member.headcount, 0),
|
||||
};
|
||||
}),
|
||||
staffPositions: (structure.staffPositions ?? []).map((position) => ({
|
||||
positionType: position.positionType,
|
||||
max: position.max ?? null,
|
||||
enforcedSalary: position.enforcedSalary ?? null,
|
||||
currentHeadcount: staff.filter(
|
||||
(member) => member.npcName !== null && member.positionType === position.positionType,
|
||||
).length,
|
||||
})),
|
||||
};
|
||||
|
||||
const npcsRes = await payload.find({
|
||||
collection: "game-npcs",
|
||||
limit: 200,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const totalValue = storedResources.reduce((sum, entry) => {
|
||||
const resource = entry.resource as { baseValue?: number | null } | number | null;
|
||||
if (!resource || typeof resource === "number") return sum;
|
||||
return sum + (resource.baseValue ?? 0) * entry.amount;
|
||||
}, 0);
|
||||
|
||||
const inventory = [...storedResources, ...(structure.voidStorage ?? [])];
|
||||
const constructionMaterials = structureType.materials.map((material) => {
|
||||
const resourceId =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.id
|
||||
: material.resource;
|
||||
const resourceName =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.name
|
||||
: `Resource #${resourceId}`;
|
||||
const delivered = inventory.reduce((total, entry) => {
|
||||
const entryResourceId =
|
||||
typeof entry.resource === "object" && entry.resource !== null
|
||||
? entry.resource.id
|
||||
: entry.resource;
|
||||
return entryResourceId === resourceId ? total + entry.amount : total;
|
||||
}, 0);
|
||||
return {
|
||||
name: resourceName,
|
||||
required: Math.ceil(material.amount ?? 0),
|
||||
delivered,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
structure,
|
||||
blueprint: structureType,
|
||||
description,
|
||||
imageUrl,
|
||||
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials },
|
||||
upgrade,
|
||||
staffing: {
|
||||
structureStaffing,
|
||||
staff,
|
||||
classifications,
|
||||
npcs: npcsRes.docs.map((npc) => ({ id: npc.id, name: npc.name })),
|
||||
},
|
||||
hiring: {
|
||||
enabled: Boolean(rules.staffHiringEnabled),
|
||||
disabledMessage: rules.staffHiringDisabledMessage ?? undefined,
|
||||
},
|
||||
gates: { canManage, canManageStorage, canTransferStorage },
|
||||
currencyConfig,
|
||||
};
|
||||
}
|
||||
42
src/app/(frontend)/logistics/structures/[id]/detail.ts
Normal file
42
src/app/(frontend)/logistics/structures/[id]/detail.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use server";
|
||||
|
||||
import { getPayload, type Payload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { fetchStructureDetail, type StructureDetailData } from "./detail-data";
|
||||
|
||||
interface ActionResult<T> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate(): Promise<{ payload: Payload; user: { id: number | string } | null }> {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({
|
||||
headers: hdrs,
|
||||
canSetHeaders: false,
|
||||
});
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function getStructureDetail(
|
||||
structureId: number,
|
||||
): Promise<ActionResult<StructureDetailData>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
const data = await fetchStructureDetail(payload, structureId, user);
|
||||
if (!data) {
|
||||
return { success: false, error: "Structure not found." };
|
||||
}
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
|||
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
||||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||
import {
|
||||
calculateStoredMass,
|
||||
calculateUtilization,
|
||||
|
|
@ -248,6 +249,30 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
return sum + (resource.baseValue ?? 0) * entry.amount;
|
||||
}, 0);
|
||||
|
||||
const inventory = [...storedResources, ...(structure.voidStorage ?? [])];
|
||||
const constructionMaterials = structureType.materials.map((material) => {
|
||||
const resourceId =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.id
|
||||
: material.resource;
|
||||
const resourceName =
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.name
|
||||
: `Resource #${resourceId}`;
|
||||
const delivered = inventory.reduce((total, entry) => {
|
||||
const entryResourceId =
|
||||
typeof entry.resource === "object" && entry.resource !== null
|
||||
? entry.resource.id
|
||||
: entry.resource;
|
||||
return entryResourceId === resourceId ? total + entry.amount : total;
|
||||
}, 0);
|
||||
return {
|
||||
name: resourceName,
|
||||
required: Math.ceil(material.amount ?? 0),
|
||||
delivered,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||
<header
|
||||
|
|
@ -411,6 +436,14 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
|
||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
||||
|
||||
<ConstructionPanel
|
||||
status={structure.constructionStatus ?? "complete"}
|
||||
startedAt={structure.constructionStartedAt ?? null}
|
||||
completesAt={structure.constructionCompletesAt ?? null}
|
||||
durationMinutes={structureType.constructionDurationMinutes ?? null}
|
||||
materials={constructionMaterials}
|
||||
/>
|
||||
|
||||
<StaffRoster
|
||||
structure={structureStaffing}
|
||||
staff={staff}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
|
|||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -278,6 +279,13 @@ async function addResourceInternal(
|
|||
data: { resourceId, amount, resourceName: resource.name },
|
||||
});
|
||||
|
||||
// Delivery-time construction start (fire-and-forget: the deposit already succeeded).
|
||||
try {
|
||||
await maybeStartConstruction(payload, structureId);
|
||||
} catch {
|
||||
// The site retries on the next deposit.
|
||||
}
|
||||
|
||||
return { success: true, data: updated as unknown as GameStructure };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
|
@ -445,14 +453,14 @@ export async function transferResource(
|
|||
|
||||
if (fromCoords && toCoords) {
|
||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
||||
const threshold = (gameRules.proximityThreshold as number) || 10;
|
||||
const threshold = (gameRules.proximityThreshold as number) || 1000;
|
||||
const { calculateDistance } = await import("@/lib/distance");
|
||||
const distance = calculateDistance(fromCoords, toCoords);
|
||||
|
||||
if (distance > threshold) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Structures are too far apart for instant transfer (${distance.toFixed(1)} units, max ${threshold}). Use the Shipments system to transport resources over long distances.`,
|
||||
error: `Structures are too far apart for instant transfer (${Math.round(distance)} m apart, max ${threshold} m). Use the Shipments system to transport resources over long distances.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/app/(frontend)/map/MapLoader.tsx
Normal file
20
src/app/(frontend)/map/MapLoader.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { MapClientProps } from "@/components/frontend/map/MapClient";
|
||||
|
||||
const MapClient = dynamic(
|
||||
() => import("@/components/frontend/map/MapClient").then((mod) => mod.MapClient),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading map...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function MapLoader(props: MapClientProps) {
|
||||
return <MapClient {...props} />;
|
||||
}
|
||||
50
src/app/(frontend)/map/page.tsx
Normal file
50
src/app/(frontend)/map/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers } from "next/headers";
|
||||
import type { Metadata } from "next";
|
||||
import { Map as MapIcon } from "lucide-react";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { MapLoader } from "./MapLoader";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "World Map: Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function MapPage() {
|
||||
const payload = await getPayload({ config: await config });
|
||||
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
||||
|
||||
const [mapsRes, canEdit, canPlace] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "maps",
|
||||
where: { isActive: { equals: true } },
|
||||
sort: "name",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
|
||||
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
|
||||
]);
|
||||
|
||||
const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name }));
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[240px] flex-1 flex-col">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapIcon className="size-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">World Map</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
||||
<span>PTF Unit Command // Tactical Grid</span>
|
||||
<span>
|
||||
{maps.length} Active Grid{maps.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<MapLoader maps={maps} canEdit={canEdit} canPlace={canPlace} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3208,3 +3208,224 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* World map: dossier marker family (structure / node / shipment pins). */
|
||||
|
||||
/* World map: give the Leaflet container its own stacking context so its panes
|
||||
(z-index 200-1000, applied in the root stacking context otherwise) stop
|
||||
painting over Radix dropdown portals (body-level, z-50) on the map page. */
|
||||
.leaflet-container {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Map placement/authoring modes: crosshair cursor while a click mode is
|
||||
active (MapContainer className is mount-only, so this lives on CSS). */
|
||||
.map-host[data-mode="crosshair"] .leaflet-container {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* World map: structure / construction-site markers (status-colored pin with an
|
||||
inline SVG icon and a progress bar for sites under construction). The same
|
||||
pin base is shared by the node and shipment badges. */
|
||||
.map-structure-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.map-structure-pin {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 2px;
|
||||
background: rgb(8 10 14 / 90%);
|
||||
border: 1.5px solid rgb(255 255 255 / 25%);
|
||||
box-shadow:
|
||||
0 0 0 1.5px rgb(0 0 0 / 65%),
|
||||
0 1px 4px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.map-structure-pin.map-pin-sm {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
.map-structure-progress {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
bottom: -6px;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
background: rgb(255 255 255 / 30%);
|
||||
}
|
||||
|
||||
.map-structure-progress > div {
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* World map: dossier-style Leaflet chrome. Tooltips and popups are Leaflet DOM
|
||||
(not shadcn), so they get the tactical card treatment here: dark glass,
|
||||
hairline borders, squared corners, mono uppercase labels. */
|
||||
.leaflet-container .map-tooltip-dossier.leaflet-tooltip {
|
||||
background: rgb(0 0 0 / 82%);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 65%);
|
||||
color: rgb(255 255 255 / 85%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
padding: 4px 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-top::before {
|
||||
border-top-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-bottom::before {
|
||||
border-bottom-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-left::before {
|
||||
border-left-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-right::before {
|
||||
border-right-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup-content-wrapper {
|
||||
background: rgb(8 10 14 / 92%);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
border-radius: 0;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 65%);
|
||||
color: rgb(255 255 255 / 90%);
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup-content {
|
||||
margin: 10px 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-popup-tip {
|
||||
background: rgb(8 10 14 / 92%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
color: rgb(255 255 255 / 50%);
|
||||
}
|
||||
|
||||
.leaflet-container a.leaflet-popup-close-button:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dossier popups (structure/shipment cards): zero wrapper padding so the
|
||||
classification strip and readout footer run edge to edge. */
|
||||
.leaflet-container .map-popup-dossier .leaflet-popup-content {
|
||||
margin: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* World map: persistent map labels (structures, zones, roads) in the dossier
|
||||
style: mono uppercase, tracked, dark halo instead of a box. */
|
||||
.leaflet-container .map-label.leaflet-tooltip {
|
||||
background: rgb(8 10 14 / 62%);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 1px;
|
||||
padding: 1px 5px;
|
||||
color: rgb(255 255 255 / 92%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
text-shadow:
|
||||
0 0 3px rgb(0 0 0 / 95%),
|
||||
0 0 6px rgb(0 0 0 / 85%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaflet-container .map-label.leaflet-tooltip::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-zone-label-icon,
|
||||
.map-road-label-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.map-zone-label {
|
||||
color: rgb(255 255 255 / 90%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
/* Size to the text itself: inside the 0x0 divIcon container a block div
|
||||
resolves to 0 width, which makes the percentage centering transform a
|
||||
no-op and leaves the label hanging off the road. */
|
||||
width: max-content;
|
||||
background: rgb(8 10 14 / 62%);
|
||||
padding: 1px 5px;
|
||||
border-radius: 1px;
|
||||
text-shadow:
|
||||
0 0 3px rgb(0 0 0 / 95%),
|
||||
0 0 7px rgb(0 0 0 / 90%),
|
||||
0 0 2px rgb(0 0 0 / 90%);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.map-road-label-svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.map-road-label-path {
|
||||
fill: none;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.map-road-label-text {
|
||||
fill: rgb(255 255 255 / 94%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
/* Dark casing behind the glyphs (painted under the fill) for contrast on
|
||||
any terrain. */
|
||||
stroke: rgb(0 0 0 / 82%);
|
||||
stroke-width: 4px;
|
||||
paint-order: stroke;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.leaflet-container .map-zone-label-icon,
|
||||
.leaflet-container .map-road-label-icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Radix floating panels (Select/DropdownMenu/Popover/Tooltip) portal to <body>
|
||||
and would otherwise paint below the map page's high-z overlays (authoring
|
||||
toolbar z-[1000], structure drawer z-[1200]). Pin every popper wrapper above
|
||||
them. */
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1300 !important;
|
||||
}
|
||||
|
|
|
|||
260
src/app/api/map-features/route.ts
Normal file
260
src/app/api/map-features/route.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
|
||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* PostGIS point fields read back as a FLAT [x, y] tuple (not an array of
|
||||
* tuples like the JSON feature geometry).
|
||||
*/
|
||||
function pointOf(coordinates: unknown): MapPoint | null {
|
||||
if (!Array.isArray(coordinates) || coordinates.length !== 2) return null;
|
||||
const x = coordinates[0];
|
||||
const y = coordinates[1];
|
||||
if (
|
||||
typeof x !== "number" ||
|
||||
typeof y !== "number" ||
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function relationshipId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value && typeof value === "object" && "id" in value) return (value as { id: number }).id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* View-surface feature feed for the map page (settled decision 8): any
|
||||
* logged-in user may read map features here, while maps/road/zone/node
|
||||
* configuration stays maps:*-gated in the Payload admin panel.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const payload = await getPayload({ config: await config });
|
||||
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const mapId = Number(req.nextUrl.searchParams.get("mapId"));
|
||||
if (!Number.isInteger(mapId) || mapId <= 0) {
|
||||
return NextResponse.json({ error: "mapId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const map = await payload
|
||||
.findByID({ collection: "maps", id: mapId, depth: 1, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
if (!map) {
|
||||
return NextResponse.json({ error: "Map not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const mapDoc = map as unknown as MapDoc & {
|
||||
basemapImage: { url?: string | null } | number | null;
|
||||
};
|
||||
const worldW = Number(mapDoc.worldSizeWidth ?? 8192);
|
||||
const worldH = Number(mapDoc.worldSizeHeight ?? 8192);
|
||||
|
||||
const [structuresRes, nodesRes, roadsRes, zonesRes, shipmentsRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "game-structures",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 2,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "resource-nodes",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 1,
|
||||
limit: 5000,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "map-roads",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "map-zones",
|
||||
where: { map: { equals: mapId } },
|
||||
depth: 1,
|
||||
limit: 5000,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "shipments",
|
||||
where: {
|
||||
and: [{ map: { equals: mapId } }, { status: { in: ["dispatched", "in_transit"] } }],
|
||||
},
|
||||
depth: 2,
|
||||
limit: 200,
|
||||
sort: "-dispatchedAt",
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const structures: StructureFeature[] = [];
|
||||
for (const doc of structuresRes.docs) {
|
||||
const structure = doc as unknown as GameStructure & { type: { id: number; name: string } };
|
||||
const point = pointOf(structure.coordinates);
|
||||
if (!point) continue;
|
||||
const blueprint = typeof structure.type === "object" ? structure.type : null;
|
||||
const faction =
|
||||
structure.faction && typeof structure.faction === "object" ? structure.faction : null;
|
||||
|
||||
const storageEntries = [
|
||||
...(structure.storedResources ?? []),
|
||||
...((structure as GameStructure).voidStorage ?? []),
|
||||
];
|
||||
let storageUsedKg = 0;
|
||||
for (const entry of storageEntries) {
|
||||
const resource = typeof entry.resource === "object" ? entry.resource : null;
|
||||
storageUsedKg += (resource?.massPerUnit ?? 0) * entry.amount;
|
||||
}
|
||||
|
||||
structures.push({
|
||||
id: structure.id,
|
||||
name: structure.name,
|
||||
point,
|
||||
constructionStatus: structure.constructionStatus ?? "complete",
|
||||
constructionStartedAt: structure.constructionStartedAt ?? null,
|
||||
constructionCompletesAt: structure.constructionCompletesAt ?? null,
|
||||
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
|
||||
storageUsedKg,
|
||||
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
|
||||
labelVisible: structure.labelVisible ?? null,
|
||||
labelPriority: structure.labelPriority ?? null,
|
||||
blueprintId: blueprint?.id ?? null,
|
||||
blueprintName: blueprint?.name ?? null,
|
||||
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
|
||||
materials: (blueprint?.materials ?? []).map((material) => ({
|
||||
resource: relationshipId(material.resource) ?? 0,
|
||||
resourceName:
|
||||
material.resource && typeof material.resource === "object"
|
||||
? material.resource.name
|
||||
: "Resource",
|
||||
amount: material.amount ?? 0,
|
||||
})),
|
||||
resourcesInRange: (blueprint?.resourcesInRange ?? []).map((requirement) => ({
|
||||
resource: relationshipId(requirement.resource),
|
||||
structure: null,
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
structuresInRange: (blueprint?.structuresInRange ?? []).map((requirement) => ({
|
||||
resource: null,
|
||||
structure: relationshipId(requirement.structure),
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
factionName: faction && "name" in faction ? String(faction.name) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const relEntries = (value: unknown): { id: number; name: string }[] =>
|
||||
(Array.isArray(value) ? value : []).map((entry) =>
|
||||
typeof entry === "object" && entry !== null && "id" in entry
|
||||
? { id: entry.id as number, name: String((entry as { name?: string }).name ?? "Resource") }
|
||||
: { id: entry as number, name: "Resource" },
|
||||
);
|
||||
|
||||
const nodes = nodesRes.docs.map((node) => ({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
point: pointOf(node.position) ?? [0, 0],
|
||||
description: node.description ?? null,
|
||||
resources: relEntries(node.resources),
|
||||
richness: node.richness ?? null,
|
||||
}));
|
||||
|
||||
const roads = roadsRes.docs.map((road) => ({
|
||||
id: road.id,
|
||||
name: road.name ?? null,
|
||||
description: road.description ?? null,
|
||||
surface: road.surface,
|
||||
speedMultiplier: road.speedMultiplier,
|
||||
strokeColor: road.strokeColor ?? null,
|
||||
strokeWeight: road.strokeWeight ?? null,
|
||||
strokeStyle: road.strokeStyle ?? null,
|
||||
labelVisible: road.labelVisible ?? null,
|
||||
points: isPointArray(road.points, 2) ? (road.points as MapPoint[]) : [],
|
||||
}));
|
||||
|
||||
const zones = zonesRes.docs.map((zone) => ({
|
||||
id: zone.id,
|
||||
name: zone.name ?? null,
|
||||
description: zone.description ?? null,
|
||||
type: zone.type,
|
||||
resources: relEntries(zone.resources),
|
||||
fillColor: zone.fillColor ?? null,
|
||||
strokeColor: zone.strokeColor ?? null,
|
||||
fillOpacity: zone.fillOpacity ?? null,
|
||||
strokeWeight: zone.strokeWeight ?? null,
|
||||
labelVisible: zone.labelVisible ?? null,
|
||||
points: isPointArray(zone.points, 3) ? (zone.points as MapPoint[]) : [],
|
||||
}));
|
||||
|
||||
const shipments = shipmentsRes.docs.map((doc) => {
|
||||
const shipment = doc as unknown as Shipment & {
|
||||
origin: GameStructure;
|
||||
destination: GameStructure;
|
||||
transportVehicle: { name: string } | number | null;
|
||||
};
|
||||
const vehicle =
|
||||
shipment.transportVehicle && typeof shipment.transportVehicle === "object"
|
||||
? shipment.transportVehicle
|
||||
: null;
|
||||
const vehicleType =
|
||||
vehicle && typeof (vehicle as { type?: unknown }).type === "object"
|
||||
? ((vehicle as { type: { name?: string } }).type.name ?? null)
|
||||
: null;
|
||||
return {
|
||||
id: shipment.id,
|
||||
name: shipment.name ?? null,
|
||||
status: shipment.status,
|
||||
routePath: isPointArray(shipment.routePath, 2) ? (shipment.routePath as MapPoint[]) : null,
|
||||
originName: shipment.origin?.name ?? null,
|
||||
originPoint: pointOf(shipment.origin?.coordinates),
|
||||
destinationName: shipment.destination?.name ?? null,
|
||||
destinationPoint: pointOf(shipment.destination?.coordinates),
|
||||
dispatchedAt: shipment.dispatchedAt ?? null,
|
||||
estimatedArrival: shipment.estimatedArrival ?? null,
|
||||
vehicleName: vehicleType,
|
||||
};
|
||||
});
|
||||
|
||||
const response: MapFeaturesResponse = {
|
||||
map: {
|
||||
id: mapDoc.id,
|
||||
name: mapDoc.name,
|
||||
worldSizeWidth: worldW,
|
||||
worldSizeHeight: worldH,
|
||||
basemapMode: mapDoc.basemapMode,
|
||||
basemapImageUrl:
|
||||
mapDoc.basemapImage && typeof mapDoc.basemapImage === "object"
|
||||
? (mapDoc.basemapImage.url ?? null)
|
||||
: null,
|
||||
tileUrlTemplate: mapDoc.tileUrlTemplate ?? null,
|
||||
minZoom: mapDoc.minZoom ?? 0,
|
||||
maxZoom: mapDoc.maxZoom ?? 12,
|
||||
defaultZoom: mapDoc.defaultZoom ?? 2,
|
||||
defaultCenter: [mapDoc.defaultCenterX ?? worldW / 2, mapDoc.defaultCenterY ?? worldH / 2],
|
||||
},
|
||||
structures,
|
||||
nodes,
|
||||
roads,
|
||||
zones,
|
||||
shipments,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
243
src/app/api/map-import/route.ts
Normal file
243
src/app/api/map-import/route.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||
import { validateHexColor } from "@/lib/map/style";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface GeoJsonPosition {
|
||||
0: number;
|
||||
1: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface GeoJsonFeature {
|
||||
type?: string;
|
||||
geometry?: {
|
||||
type?: string;
|
||||
coordinates?: unknown;
|
||||
} | null;
|
||||
properties?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface GeoJsonCollection {
|
||||
type?: string;
|
||||
features?: GeoJsonFeature[];
|
||||
}
|
||||
|
||||
const ROAD_SURFACES = new Set(["paved", "dirt", "trail"]);
|
||||
|
||||
function toMapPoint(position: unknown): MapPoint | null {
|
||||
const p = position as GeoJsonPosition | undefined;
|
||||
if (!p || !Array.isArray(p) || p.length < 2) return null;
|
||||
const x = Number(p[0]);
|
||||
const y = Number(p[1]);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function propString(props: Record<string, unknown>, key: string): string | null {
|
||||
const value = props[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function propNumber(props: Record<string, unknown>, key: string): number | null {
|
||||
const value = props[key];
|
||||
const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function propHexColor(props: Record<string, unknown>, key: string): string | null {
|
||||
const value = propString(props, key);
|
||||
return validateHexColor(value) === true ? value : null;
|
||||
}
|
||||
|
||||
function propStrokeStyle(
|
||||
props: Record<string, unknown>,
|
||||
key: string,
|
||||
): "solid" | "dashed" | "dotted" | null {
|
||||
const value = propString(props, key);
|
||||
return value === "dashed" || value === "dotted" || value === "solid" ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON FeatureCollection import for road/zone/node authoring (JOSM-style
|
||||
* workflows). See docs/map-system.md for the full mapping table.
|
||||
*
|
||||
* Mapping:
|
||||
* - LineString -> map-roads (props: name, description, surface, speedMultiplier,
|
||||
* strokeColor #rrggbb, strokeWeight, strokeStyle solid|dashed|dotted)
|
||||
* - Polygon -> map-zones (props: name, description, type "water"|"land"; outer ring only;
|
||||
* fillColor, strokeColor #rrggbb, fillOpacity, strokeWeight)
|
||||
* - Point -> resource-nodes (props: name, resource [resource ID] or
|
||||
* resources [array of IDs], resourceName/resourceNames, richness)
|
||||
*
|
||||
* GeoJSON positions are interpreted directly as map meters: [x, y] =
|
||||
* [east, south] on the map grid (same convention as the authoring UI).
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const payload = await getPayload({ config: await config });
|
||||
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!(await hasPermission(payload, user, "maps:create"))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: (GeoJsonCollection & { mapId?: number }) | null = null;
|
||||
try {
|
||||
body = (await req.json()) as GeoJsonCollection & { mapId?: number };
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const mapId = Number(body?.mapId);
|
||||
if (!Number.isInteger(mapId) || mapId <= 0) {
|
||||
return NextResponse.json({ error: "mapId is required" }, { status: 400 });
|
||||
}
|
||||
if (body?.type !== "FeatureCollection" || !Array.isArray(body.features)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Body must be a GeoJSON FeatureCollection with a mapId field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const map = await payload
|
||||
.findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
if (!map) {
|
||||
return NextResponse.json({ error: "Map not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const created = { roads: 0, zones: 0, nodes: 0 };
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let index = 0; index < body.features.length; index++) {
|
||||
const feature = body.features[index];
|
||||
const label = feature.properties?.name ?? `feature ${index}`;
|
||||
const props = feature.properties ?? {};
|
||||
|
||||
try {
|
||||
const geometryType = feature.geometry?.type;
|
||||
const rawCoordinates = feature.geometry?.coordinates;
|
||||
|
||||
if (geometryType === "LineString" && isPointArray(rawCoordinates, 2)) {
|
||||
const surface = propString(props, "surface");
|
||||
await payload.create({
|
||||
collection: "map-roads",
|
||||
data: {
|
||||
map: mapId,
|
||||
name: propString(props, "name"),
|
||||
description: propString(props, "description"),
|
||||
surface:
|
||||
surface && ROAD_SURFACES.has(surface)
|
||||
? (surface as "paved" | "dirt" | "trail")
|
||||
: "paved",
|
||||
speedMultiplier: propNumber(props, "speedMultiplier") ?? 1,
|
||||
strokeColor: propHexColor(props, "strokeColor"),
|
||||
strokeWeight: propNumber(props, "strokeWeight"),
|
||||
strokeStyle: propStrokeStyle(props, "strokeStyle"),
|
||||
points: (rawCoordinates as GeoJsonPosition[]).map(toMapPoint) as MapPoint[],
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
created.roads++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (geometryType === "Polygon" && Array.isArray(rawCoordinates)) {
|
||||
const ring = (rawCoordinates as unknown[][])[0];
|
||||
if (isPointArray(ring, 3)) {
|
||||
const zoneType = propString(props, "type");
|
||||
const zoneResourceNames = (propString(props, "resources") ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
let zoneResourceIds: number[] = [];
|
||||
if (zoneResourceNames.length > 0) {
|
||||
const found = await payload.find({
|
||||
collection: "resources",
|
||||
where: { name: { in: zoneResourceNames } },
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
});
|
||||
zoneResourceIds = found.docs.map((doc) => doc.id);
|
||||
}
|
||||
await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
map: mapId,
|
||||
name: propString(props, "name"),
|
||||
description: propString(props, "description"),
|
||||
type: zoneType === "land" ? "land" : "water",
|
||||
resources: zoneResourceIds,
|
||||
fillColor: propHexColor(props, "fillColor"),
|
||||
strokeColor: propHexColor(props, "strokeColor"),
|
||||
fillOpacity: propNumber(props, "fillOpacity"),
|
||||
strokeWeight: propNumber(props, "strokeWeight"),
|
||||
points: (ring as GeoJsonPosition[]).map(toMapPoint) as MapPoint[],
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
created.zones++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryType === "Point" && Array.isArray(rawCoordinates)) {
|
||||
const position = toMapPoint(rawCoordinates);
|
||||
if (!position) throw new Error("invalid Point coordinates");
|
||||
const resourceIds: number[] = [];
|
||||
const resourceNames: string[] = [];
|
||||
if (Array.isArray(props["resources"])) {
|
||||
for (const entry of props["resources"]) {
|
||||
if (typeof entry === "number") resourceIds.push(entry);
|
||||
else if (typeof entry === "string") resourceNames.push(entry);
|
||||
}
|
||||
}
|
||||
const singleId = propNumber(props, "resource");
|
||||
if (singleId != null) resourceIds.push(singleId);
|
||||
const singleName = propString(props, "resourceName");
|
||||
if (singleName) resourceNames.push(singleName);
|
||||
if (resourceNames.length > 0) {
|
||||
const found = await payload.find({
|
||||
collection: "resources",
|
||||
where: { name: { in: resourceNames } },
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
});
|
||||
resourceIds.push(...found.docs.map((doc) => doc.id));
|
||||
}
|
||||
const uniqueResourceIds = [...new Set(resourceIds)];
|
||||
if (uniqueResourceIds.length === 0)
|
||||
throw new Error("missing resource (ID) or resourceName property");
|
||||
const name = propString(props, "name") ?? `Node ${position[0]}, ${position[1]}`;
|
||||
await payload.create({
|
||||
collection: "resource-nodes",
|
||||
data: {
|
||||
map: mapId,
|
||||
name,
|
||||
resources: uniqueResourceIds,
|
||||
position,
|
||||
richness: propNumber(props, "richness") ?? 100,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
created.nodes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(`${label}: unsupported or malformed geometry "${String(geometryType)}"`);
|
||||
} catch (error) {
|
||||
errors.push(`${label}: ${error instanceof Error ? error.message : "import failed"}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: errors.length === 0, created, errors });
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ collections/
|
|||
# GameVehicles, GameNpcs, GameHardResources, GameEventLogs,
|
||||
# NpcStaffing, LaborClassifications (+ shared staffingFields.ts factory)
|
||||
server/ 5 files # MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
|
||||
world/ 2 files # Maps, NarrativeEvents (React Flow editor)
|
||||
world/ 5 files # Maps (basemap config), MapRoads/MapZones/ResourceNodes (map features), NarrativeEvents
|
||||
projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like)
|
||||
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
|
||||
wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@ export const GameRules: GlobalConfig = {
|
|||
{
|
||||
name: "proximityThreshold",
|
||||
type: "number",
|
||||
defaultValue: 10,
|
||||
defaultValue: 1000,
|
||||
admin: {
|
||||
description:
|
||||
"Distance threshold for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.",
|
||||
"Distance threshold in real meters for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -99,6 +99,45 @@ export const GameStructures: CollectionConfig = {
|
|||
description: "The faction that owns this structure.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
type: "relationship",
|
||||
relationTo: "maps",
|
||||
admin: {
|
||||
description: "The game map this structure sits on.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "constructionStatus",
|
||||
type: "select",
|
||||
label: "Construction Status",
|
||||
defaultValue: "complete",
|
||||
options: [
|
||||
{ label: "Awaiting Materials", value: "awaiting_materials" },
|
||||
{ label: "Building", value: "building" },
|
||||
{ label: "Complete", value: "complete" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Placed sites start awaiting materials. Once all blueprint materials are delivered construction starts immediately (timestamps below); the game tick flips past-deadline building sites to complete.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "constructionStartedAt",
|
||||
type: "date",
|
||||
label: "Construction Started At",
|
||||
admin: {
|
||||
description: "When construction began (materials consumed). Empty while awaiting materials.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "constructionCompletesAt",
|
||||
type: "date",
|
||||
label: "Construction Completes At",
|
||||
admin: {
|
||||
description: "The exact deadline at which the site becomes operational.",
|
||||
},
|
||||
},
|
||||
...createStaffingFields("instance"),
|
||||
{
|
||||
label: "Geolocation",
|
||||
|
|
@ -107,9 +146,34 @@ export const GameStructures: CollectionConfig = {
|
|||
{
|
||||
name: "coordinates",
|
||||
type: "point",
|
||||
validate: () => true,
|
||||
admin: {
|
||||
description: "Map meters [x, y] on the structure's game map (not geographic degrees).",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "labelVisible",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
label: "Show Name Label",
|
||||
admin: {
|
||||
description:
|
||||
"Renders the structure's name on the map. When several structures cluster together, the highest-priority label wins until you zoom in.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "labelPriority",
|
||||
type: "number",
|
||||
defaultValue: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
admin: {
|
||||
description:
|
||||
"Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storedResources",
|
||||
type: "array",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,21 @@ export const Shipments: CollectionConfig = {
|
|||
relationTo: "game-structures",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
type: "relationship",
|
||||
relationTo: "maps",
|
||||
admin: {
|
||||
description: "The game map this shipment travels on. Cross-map shipments are not supported.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "routePath",
|
||||
type: "json",
|
||||
admin: {
|
||||
description: "Snapshot polyline of the route as [x, y] meter tuples, for map rendering.",
|
||||
},
|
||||
},
|
||||
// Transport
|
||||
{
|
||||
name: "transportVehicle",
|
||||
|
|
|
|||
|
|
@ -349,20 +349,14 @@ export const Structures: CollectionConfig = {
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "constructionTime",
|
||||
name: "constructionDurationMinutes",
|
||||
type: "number",
|
||||
required: true,
|
||||
label: "Build Time (in game ticks)",
|
||||
label: "Build Time (minutes)",
|
||||
min: 1,
|
||||
admin: {
|
||||
components: {
|
||||
Description: {
|
||||
path: "@/components/static/TickDurationDescription",
|
||||
clientProps: {
|
||||
task: "complete construction of this structure",
|
||||
},
|
||||
},
|
||||
},
|
||||
description:
|
||||
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -241,6 +241,21 @@ export const Vehicles: CollectionConfig = {
|
|||
label: "Logistics & Grid Storage",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "transportMode",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "ground",
|
||||
options: [
|
||||
{ label: "Ground", value: "ground" },
|
||||
{ label: "Air", value: "air" },
|
||||
{ label: "Sea", value: "sea" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Ground vehicles route over the road network and refuse shipments with no connected route. Air and sea vehicles travel straight-line.",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Storage Grid Dimensions",
|
||||
type: "group",
|
||||
|
|
|
|||
115
src/collections/world/MapRoads.ts
Normal file
115
src/collections/world/MapRoads.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { validateHexColor } from "@/lib/map/style";
|
||||
import { validatePolyline } from "@/lib/map/points";
|
||||
|
||||
/**
|
||||
* Road network segments for a map, used by ground shipment routing.
|
||||
* Points are [x, y] tuples in real meters on the map's grid. Roads connect
|
||||
* where endpoints (or points) coincide within the routing epsilon; author
|
||||
* them so shared endpoints use identical coordinates.
|
||||
*/
|
||||
export const MapRoads: CollectionConfig = {
|
||||
slug: "map-roads",
|
||||
admin: {
|
||||
group: "World",
|
||||
useAsTitle: "name",
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: requirePermission("maps:create"),
|
||||
update: requirePermission("maps:update"),
|
||||
delete: requirePermission("maps:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
admin: {
|
||||
description: 'Road or route label (e.g. "Highway 1", "Kavala ridge trail").',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Shown in the road's tooltip and popup on the map.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
type: "relationship",
|
||||
relationTo: "maps",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "surface",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "paved",
|
||||
options: [
|
||||
{ label: "Paved", value: "paved" },
|
||||
{ label: "Dirt", value: "dirt" },
|
||||
{ label: "Trail", value: "trail" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "speedMultiplier",
|
||||
type: "number",
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
min: 0.1,
|
||||
max: 5,
|
||||
admin: {
|
||||
description:
|
||||
"Routing weight multiplier. Higher = faster/preferred (1 = neutral). Affects which route Dijkstra picks; fuel is charged on geometric length.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strokeColor",
|
||||
type: "text",
|
||||
validate: validateHexColor,
|
||||
admin: {
|
||||
description: "Line color as hex (e.g. #cbd5e1). Empty = default color for the surface.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strokeWeight",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10,
|
||||
defaultValue: 2,
|
||||
admin: {
|
||||
description: "Line thickness in pixels.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strokeStyle",
|
||||
type: "select",
|
||||
defaultValue: "solid",
|
||||
options: [
|
||||
{ label: "Solid", value: "solid" },
|
||||
{ label: "Dashed", value: "dashed" },
|
||||
{ label: "Dotted", value: "dotted" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "labelVisible",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
label: "Show Name Label",
|
||||
admin: {
|
||||
description: "Renders the road's name along the line at zoom-dependent intervals.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "points",
|
||||
type: "json",
|
||||
required: true,
|
||||
validate: validatePolyline,
|
||||
admin: {
|
||||
description: "Array of [x, y] meter tuples in map order.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
121
src/collections/world/MapZones.ts
Normal file
121
src/collections/world/MapZones.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { validateHexColor } from "@/lib/map/style";
|
||||
import { validatePolygon } from "@/lib/map/points";
|
||||
|
||||
/**
|
||||
* GM-drawn terrain zones. Land blueprints cannot be placed inside water
|
||||
* polygons; water blueprints require one. Default terrain (no zones on the
|
||||
* map) accepts everything.
|
||||
*/
|
||||
export const MapZones: CollectionConfig = {
|
||||
slug: "map-zones",
|
||||
admin: {
|
||||
group: "World",
|
||||
useAsTitle: "name",
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: requirePermission("maps:create"),
|
||||
update: requirePermission("maps:update"),
|
||||
delete: requirePermission("maps:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
admin: {
|
||||
description: 'Zone label (e.g. "Altis bay", "Flood plain").',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Shown in the zone's tooltip and popup on the map.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
type: "relationship",
|
||||
relationTo: "maps",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Water", value: "water" },
|
||||
{ label: "Land", value: "land" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fillColor",
|
||||
type: "text",
|
||||
validate: validateHexColor,
|
||||
admin: {
|
||||
description: "Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strokeColor",
|
||||
type: "text",
|
||||
validate: validateHexColor,
|
||||
admin: {
|
||||
description: "Outline color as hex. Empty = default color for the type.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fillOpacity",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
admin: {
|
||||
description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strokeWeight",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 10,
|
||||
defaultValue: 1,
|
||||
admin: {
|
||||
description: "Outline thickness in pixels. 0 hides the outline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resources",
|
||||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
hasMany: true,
|
||||
admin: {
|
||||
description:
|
||||
"Resources contained in this zone (e.g. an oil field). A zone with resources satisfies blueprint resourcesInRange requirements for any site inside it.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "labelVisible",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
label: "Show Name Label",
|
||||
admin: {
|
||||
description: "Renders the zone's name at the polygon's center.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "points",
|
||||
type: "json",
|
||||
required: true,
|
||||
validate: validatePolygon,
|
||||
admin: {
|
||||
description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -21,9 +21,116 @@ export const Maps: CollectionConfig = {
|
|||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "fileName",
|
||||
type: "text",
|
||||
unique: true,
|
||||
name: "isActive",
|
||||
type: "checkbox",
|
||||
label: "Active",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: "Inactive maps are hidden from the map page and cannot receive placements.",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "World Size (meters)",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "worldSizeWidth",
|
||||
type: "number",
|
||||
label: "Width (m)",
|
||||
required: true,
|
||||
min: 1,
|
||||
defaultValue: 8192,
|
||||
admin: {
|
||||
description: "Map width in real meters (e.g. Altis is 8192).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worldSizeHeight",
|
||||
type: "number",
|
||||
label: "Height (m)",
|
||||
required: true,
|
||||
min: 1,
|
||||
defaultValue: 8192,
|
||||
admin: {
|
||||
description: "Map height in real meters (e.g. Altis is 8192).",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Basemap",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "basemapMode",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "image",
|
||||
options: [
|
||||
{ label: "Image Upload", value: "image" },
|
||||
{ label: "XYZ Tile Layer", value: "tiles" },
|
||||
],
|
||||
admin: {
|
||||
description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "basemapImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
label: "Basemap Image",
|
||||
admin: {
|
||||
condition: (_data, siblingData) => siblingData?.basemapMode !== "tiles",
|
||||
description: "Top-down map image. Rendered stretched across the full world size.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tileUrlTemplate",
|
||||
type: "text",
|
||||
label: "Tile URL Template",
|
||||
admin: {
|
||||
condition: (_data, siblingData) => siblingData?.basemapMode === "tiles",
|
||||
description: "XYZ template, e.g. https://tiles.example.com/{z}/{x}/{y}.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minZoom",
|
||||
type: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
name: "maxZoom",
|
||||
type: "number",
|
||||
defaultValue: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Default View",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "defaultCenterX",
|
||||
type: "number",
|
||||
label: "Center X (m)",
|
||||
admin: {
|
||||
description: "Defaults to half the world width when empty.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaultCenterY",
|
||||
type: "number",
|
||||
label: "Center Y (m)",
|
||||
admin: {
|
||||
description: "Defaults to half the world height when empty.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaultZoom",
|
||||
type: "number",
|
||||
defaultValue: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
73
src/collections/world/ResourceNodes.ts
Normal file
73
src/collections/world/ResourceNodes.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { validatePosition } from "@/lib/map/points";
|
||||
|
||||
/**
|
||||
* Map resource nodes (deposits, forests, wells, ...). Display + placement
|
||||
* validation only: blueprints with resourcesInRange require a matching node
|
||||
* within range. Nodes do not produce anything.
|
||||
*/
|
||||
export const ResourceNodes: CollectionConfig = {
|
||||
slug: "resource-nodes",
|
||||
admin: {
|
||||
group: "World",
|
||||
useAsTitle: "name",
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: requirePermission("maps:create"),
|
||||
update: requirePermission("maps:update"),
|
||||
delete: requirePermission("maps:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Node label (e.g. "North copper deposit").',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Shown in the node's tooltip and popup on the map.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
type: "relationship",
|
||||
relationTo: "maps",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "resources",
|
||||
type: "relationship",
|
||||
relationTo: "resources",
|
||||
hasMany: true,
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Every resource this node yields (e.g. copper AND oil).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "position",
|
||||
type: "json",
|
||||
required: true,
|
||||
validate: validatePosition,
|
||||
admin: {
|
||||
description: "[x, y] tuple in meters on the map grid.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "richness",
|
||||
type: "number",
|
||||
defaultValue: 100,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: "Display-only abundance rating for the map UI.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
33
src/components/frontend/baseManagement/ConstructionEta.tsx
Normal file
33
src/components/frontend/baseManagement/ConstructionEta.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ConstructionEtaProps {
|
||||
completesAt: string | Date | null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live countdown to the site's exact constructionCompletesAt deadline.
|
||||
*/
|
||||
export function ConstructionEta({ completesAt }: ConstructionEtaProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const deadline = completesAt ? new Date(completesAt).getTime() : NaN;
|
||||
const remaining = Number.isFinite(deadline) ? deadline - nowMs : 0;
|
||||
|
||||
return <span>ETA {remaining <= 0 ? "any moment" : formatDuration(remaining)}</span>;
|
||||
}
|
||||
131
src/components/frontend/baseManagement/ConstructionPanel.tsx
Normal file
131
src/components/frontend/baseManagement/ConstructionPanel.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConstructionEta } from "@/components/frontend/baseManagement/ConstructionEta";
|
||||
|
||||
interface ConstructionMaterial {
|
||||
name: string;
|
||||
required: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
interface ConstructionPanelProps {
|
||||
status: "awaiting_materials" | "building" | "complete" | null;
|
||||
startedAt: string | null;
|
||||
completesAt: string | null;
|
||||
durationMinutes: number | null;
|
||||
materials: ConstructionMaterial[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||
awaiting_materials: {
|
||||
label: "Awaiting Materials",
|
||||
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
||||
},
|
||||
building: {
|
||||
label: "Building",
|
||||
className: "border-blue-500/40 bg-blue-500/10 text-blue-300",
|
||||
},
|
||||
complete: {
|
||||
label: "Operational",
|
||||
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
|
||||
},
|
||||
};
|
||||
|
||||
function progressPct(startedAt: string | null, completesAt: string | null, nowMs: number): number {
|
||||
if (!startedAt || !completesAt) return 0;
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = new Date(completesAt).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
||||
const ratio = (nowMs - start) / (end - start);
|
||||
if (!Number.isFinite(ratio)) return 0;
|
||||
return Math.min(100, Math.max(0, Math.round(ratio * 100)));
|
||||
}
|
||||
|
||||
export function ConstructionPanel({
|
||||
status,
|
||||
startedAt,
|
||||
completesAt,
|
||||
durationMinutes,
|
||||
materials,
|
||||
}: ConstructionPanelProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!status || status === "complete") return null;
|
||||
|
||||
const statusInfo = STATUS_LABELS[status] ?? STATUS_LABELS.awaiting_materials;
|
||||
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
||||
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Construction</h2>
|
||||
<span
|
||||
className={`rounded-sm border px-2 py-0.5 text-xs uppercase tracking-wide ${statusInfo.className}`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status === "building" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Build time {durationMinutes != null ? `${durationMinutes.toLocaleString()} min` : ""}
|
||||
</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
|
||||
<ConstructionEta completesAt={completesAt} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "awaiting_materials" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ship the required materials to this site. Construction starts automatically once
|
||||
everything has arrived.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{materials.map((material) => {
|
||||
const met = material.delivered >= material.required;
|
||||
return (
|
||||
<div
|
||||
key={material.name}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span>{material.name}</span>
|
||||
<span className={`tabular-nums ${met ? "text-emerald-400" : "text-amber-300"}`}>
|
||||
{material.delivered.toLocaleString()} / {material.required.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No materials required by this blueprint.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "awaiting_materials" && !allDelivered && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{materials.filter((material) => material.delivered < material.required).length} material
|
||||
type(s) still missing.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "lucide-react";
|
||||
|
||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
||||
import { NavMinigames } from "@/components/frontend/blocks/NavMinigames";
|
||||
import { NavUser } from "@/components/frontend/blocks/NavUser";
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -149,6 +149,7 @@ export function AppSidebar({
|
|||
<SidebarContent className="overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto group-data-[collapsible=icon]:overflow-hidden">
|
||||
<NavCore label="Platform" items={data.navMain} />
|
||||
<NavCore label="Map" items={data.navMap} />
|
||||
{hasIntelligence && (
|
||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||
)}
|
||||
|
|
@ -162,14 +163,19 @@ export function AppSidebar({
|
|||
{/*<NavProjects projects={data.projects} />*/}
|
||||
</div>
|
||||
<hr />
|
||||
<NavSecondary
|
||||
items={
|
||||
adminUrl
|
||||
? [...data.navSecondary, { title: "Admin", url: adminUrl, icon: Shield }]
|
||||
: data.navSecondary
|
||||
}
|
||||
className="mt-auto"
|
||||
/>
|
||||
<SidebarMenu className="px-1">
|
||||
{adminUrl && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="sm" asChild>
|
||||
<Link href={adminUrl}>
|
||||
<Shield />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<div className="pb-1"><NavMinigames /></div>
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<div className="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export function NavCore({
|
|||
}[];
|
||||
}[];
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
|
|
|
|||
83
src/components/frontend/blocks/NavMinigames.tsx
Normal file
83
src/components/frontend/blocks/NavMinigames.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import { Bird, Bomb, ChevronRight, Club, Crosshair, FileTextIcon, Gamepad2, Radio, Spade } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
const coreGames = [
|
||||
{ title: "Flight Simulator", url: "/minigames/flappy", icon: Bird },
|
||||
{ title: "Minefield Clearance", url: "/minigames/eod", icon: Bomb },
|
||||
{ title: "Radio Traffic", url: "/minigames/radio", icon: Radio },
|
||||
{ title: "Qualification Course", url: "/minigames/qualification", icon: Crosshair },
|
||||
{ title: "OPORD Recall", url: "/minigames/opord", icon: FileTextIcon },
|
||||
];
|
||||
|
||||
const cardtableGames = [
|
||||
{ title: "Poker", url: "/minigames/poker", icon: Spade },
|
||||
{ title: "Blackjack", url: "/minigames/blackjack", icon: Club },
|
||||
];
|
||||
|
||||
export function NavMinigames() {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton size="sm">
|
||||
<Gamepad2 />
|
||||
<span>Minigames</span>
|
||||
<ChevronRight className="ml-auto size-4 text-sidebar-foreground/50" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Core</DropdownMenuLabel>
|
||||
{coreGames.map((game) => (
|
||||
<DropdownMenuItem key={game.url} asChild>
|
||||
<Link href={game.url}>
|
||||
<game.icon />
|
||||
{game.title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex flex-col">
|
||||
<span>Cardtable</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">Earn chips to spend by playing Core minigames!</span>
|
||||
</DropdownMenuLabel>
|
||||
{cardtableGames.map((game) => (
|
||||
<DropdownMenuItem key={game.url} asChild>
|
||||
<Link href={game.url}>
|
||||
<game.icon />
|
||||
{game.title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,17 +2,10 @@
|
|||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bird,
|
||||
Bomb,
|
||||
ChevronsUpDown,
|
||||
Crosshair,
|
||||
FileTextIcon,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
Mic,
|
||||
Radio,
|
||||
Spade,
|
||||
Club,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
|
@ -27,9 +20,6 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
|
|
@ -139,56 +129,6 @@ export function NavUser({
|
|||
Service Record
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Gamepad2 />
|
||||
Exercises
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/flappy">
|
||||
<Bird />
|
||||
Flight Simulator
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/eod">
|
||||
<Bomb />
|
||||
Minefield Clearance
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/radio">
|
||||
<Radio />
|
||||
Radio Traffic
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/qualification">
|
||||
<Crosshair />
|
||||
Qualification Course
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/opord">
|
||||
<FileTextIcon />
|
||||
OPORD Recall
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/poker">
|
||||
<Spade />
|
||||
Poker
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/minigames/blackjack">
|
||||
<Club />
|
||||
Blackjack
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
Package,
|
||||
ScrollText,
|
||||
Search,
|
||||
Send,
|
||||
ShoppingBag,
|
||||
Swords,
|
||||
TrendingUp,
|
||||
|
|
@ -24,6 +23,10 @@ import {
|
|||
} from "lucide-react";
|
||||
|
||||
export const sidebarData = {
|
||||
// World Map hidden until the feature ships; the /map route and all of its
|
||||
// functionality stay live for direct-URL access. Restore with:
|
||||
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import).
|
||||
navMap: [],
|
||||
navHelpdesk: [
|
||||
{
|
||||
title: "Helpdesk",
|
||||
|
|
@ -157,11 +160,5 @@ export const sidebarData = {
|
|||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Feedback",
|
||||
url: "https://onyxsimple.youtrack.cloud",
|
||||
icon: Send,
|
||||
},
|
||||
],
|
||||
navSecondary: [],
|
||||
};
|
||||
|
|
|
|||
778
src/components/frontend/map/AuthoringToolbar.tsx
Normal file
778
src/components/frontend/map/AuthoringToolbar.tsx
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
deleteMapFeature,
|
||||
saveMapNode,
|
||||
saveMapRoad,
|
||||
saveMapZone,
|
||||
} from "@/app/(frontend)/logistics/map/actions";
|
||||
import type { MapPoint } from "@/lib/map/points";
|
||||
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
|
||||
|
||||
export type AuthoringKind = "road" | "zone" | "node" | null;
|
||||
|
||||
export interface EditingFeature {
|
||||
kind: Exclude<AuthoringKind, null>;
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MapAuthoring {
|
||||
kind: AuthoringKind;
|
||||
draftPoints: MapPoint[];
|
||||
canSave: boolean;
|
||||
saving: boolean;
|
||||
editing: EditingFeature | null;
|
||||
fields: {
|
||||
roadName: string;
|
||||
setRoadName: (value: string) => void;
|
||||
roadDescription: string;
|
||||
setRoadDescription: (value: string) => void;
|
||||
surface: "paved" | "dirt" | "trail";
|
||||
setSurface: (value: "paved" | "dirt" | "trail") => void;
|
||||
speedMultiplier: string;
|
||||
setSpeedMultiplier: (value: string) => void;
|
||||
strokeColor: string;
|
||||
setStrokeColor: (value: string) => void;
|
||||
strokeWeight: string;
|
||||
setStrokeWeight: (value: string) => void;
|
||||
strokeStyle: "solid" | "dashed" | "dotted";
|
||||
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
|
||||
labelVisible: boolean;
|
||||
setLabelVisible: (value: boolean) => void;
|
||||
zoneName: string;
|
||||
setZoneName: (value: string) => void;
|
||||
zoneDescription: string;
|
||||
setZoneDescription: (value: string) => void;
|
||||
zoneType: "water" | "land";
|
||||
setZoneType: (value: "water" | "land") => void;
|
||||
fillColor: string;
|
||||
setFillColor: (value: string) => void;
|
||||
zoneStrokeColor: string;
|
||||
setZoneStrokeColor: (value: string) => void;
|
||||
fillOpacity: string;
|
||||
setFillOpacity: (value: string) => void;
|
||||
zoneStrokeWeight: string;
|
||||
setZoneStrokeWeight: (value: string) => void;
|
||||
nodeName: string;
|
||||
setNodeName: (value: string) => void;
|
||||
nodeDescription: string;
|
||||
setNodeDescription: (value: string) => void;
|
||||
resourceIds: number[];
|
||||
toggleResourceId: (id: number) => void;
|
||||
zoneResourceIds: number[];
|
||||
toggleZoneResourceId: (id: number) => void;
|
||||
richness: string;
|
||||
setRichness: (value: string) => void;
|
||||
};
|
||||
setKind: (kind: AuthoringKind) => void;
|
||||
pushPoint: (point: MapPoint) => void;
|
||||
clearDraft: () => void;
|
||||
save: () => Promise<void>;
|
||||
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
|
||||
startEditingRoad: (road: RoadFeature) => void;
|
||||
startEditingZone: (zone: ZoneFeature) => void;
|
||||
startEditingNode: (node: NodeFeature) => void;
|
||||
}
|
||||
|
||||
export function useMapAuthoring(options: { mapId: number; onSaved: () => void }): MapAuthoring {
|
||||
const { mapId, onSaved } = options;
|
||||
const [kind, setKindState] = useState<AuthoringKind>(null);
|
||||
const [draftPoints, setDraftPoints] = useState<MapPoint[]>([]);
|
||||
const [roadName, setRoadName] = useState("");
|
||||
const [roadDescription, setRoadDescription] = useState("");
|
||||
const [surface, setSurface] = useState<"paved" | "dirt" | "trail">("paved");
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState("1");
|
||||
const [strokeColor, setStrokeColor] = useState("");
|
||||
const [strokeWeight, setStrokeWeight] = useState("");
|
||||
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
|
||||
const [labelVisible, setLabelVisible] = useState(true);
|
||||
const [zoneName, setZoneName] = useState("");
|
||||
const [zoneDescription, setZoneDescription] = useState("");
|
||||
const [zoneType, setZoneType] = useState<"water" | "land">("water");
|
||||
const [fillColor, setFillColor] = useState("");
|
||||
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||
const [fillOpacity, setFillOpacity] = useState("");
|
||||
const [zoneStrokeWeight, setZoneStrokeWeight] = useState("");
|
||||
const [nodeName, setNodeName] = useState("");
|
||||
const [nodeDescription, setNodeDescription] = useState("");
|
||||
const [resourceIds, setResourceIds] = useState<number[]>([]);
|
||||
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
|
||||
const [richness, setRichness] = useState("100");
|
||||
|
||||
const toggleResourceId = useCallback((id: number) => {
|
||||
setResourceIds((current) =>
|
||||
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleZoneResourceId = useCallback((id: number) => {
|
||||
setZoneResourceIds((current) =>
|
||||
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
|
||||
);
|
||||
}, []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingFeature | null>(null);
|
||||
|
||||
const pushPoint = useCallback((point: MapPoint) => {
|
||||
setDraftPoints((current) => [...current, point]);
|
||||
}, []);
|
||||
|
||||
const clearDraft = useCallback(() => setDraftPoints([]), []);
|
||||
|
||||
const setKind = useCallback((next: AuthoringKind) => {
|
||||
setKindState(next);
|
||||
setDraftPoints([]);
|
||||
setEditing(null);
|
||||
}, []);
|
||||
|
||||
const startEditingRoad = useCallback((road: RoadFeature) => {
|
||||
setKindState("road");
|
||||
setDraftPoints(road.points.map((point) => [point[0], point[1]]));
|
||||
setRoadName(road.name ?? "");
|
||||
setRoadDescription(road.description ?? "");
|
||||
setSurface(road.surface);
|
||||
setSpeedMultiplier(String(road.speedMultiplier));
|
||||
setStrokeColor(road.strokeColor ?? "");
|
||||
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
|
||||
setStrokeStyle(road.strokeStyle ?? "solid");
|
||||
setLabelVisible(road.labelVisible !== false);
|
||||
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
|
||||
}, []);
|
||||
|
||||
const startEditingZone = useCallback((zone: ZoneFeature) => {
|
||||
setKindState("zone");
|
||||
setDraftPoints(zone.points.map((point) => [point[0], point[1]]));
|
||||
setZoneName(zone.name ?? "");
|
||||
setZoneDescription(zone.description ?? "");
|
||||
setZoneType(zone.type);
|
||||
setZoneResourceIds(zone.resources.map((entry) => entry.id));
|
||||
setFillColor(zone.fillColor ?? "");
|
||||
setZoneStrokeColor(zone.strokeColor ?? "");
|
||||
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
|
||||
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
|
||||
setLabelVisible(zone.labelVisible !== false);
|
||||
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
|
||||
}, []);
|
||||
|
||||
const startEditingNode = useCallback((node: NodeFeature) => {
|
||||
setKindState("node");
|
||||
setDraftPoints([[node.point[0], node.point[1]]]);
|
||||
setNodeName(node.name);
|
||||
setNodeDescription(node.description ?? "");
|
||||
setResourceIds(node.resources.map((entry) => entry.id));
|
||||
setRichness(String(node.richness ?? 100));
|
||||
setEditing({ kind: "node", id: node.id, label: node.name });
|
||||
}, []);
|
||||
|
||||
const removeFeature = useCallback(
|
||||
async (target: "road" | "zone" | "node", id: number) => {
|
||||
const result = await deleteMapFeature(target, id);
|
||||
if (result.success) {
|
||||
if (editing && editing.id === id && editing.kind === target) setEditing(null);
|
||||
toast.success("Feature deleted.");
|
||||
onSaved();
|
||||
} else {
|
||||
toast.error(result.error ?? "Delete failed.");
|
||||
}
|
||||
},
|
||||
[editing, onSaved],
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (kind == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (kind === "road") {
|
||||
const result = await saveMapRoad({
|
||||
id: editing?.kind === "road" ? editing.id : undefined,
|
||||
mapId,
|
||||
name: roadName.trim() || null,
|
||||
description: roadDescription.trim() || null,
|
||||
surface,
|
||||
speedMultiplier: Number(speedMultiplier) || 1,
|
||||
strokeColor: strokeColor.trim() || null,
|
||||
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
|
||||
strokeStyle,
|
||||
labelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
toast.success(editing?.kind === "road" ? "Road updated." : "Road saved.");
|
||||
} else if (kind === "zone") {
|
||||
const result = await saveMapZone({
|
||||
id: editing?.kind === "zone" ? editing.id : undefined,
|
||||
mapId,
|
||||
name: zoneName.trim() || null,
|
||||
description: zoneDescription.trim() || null,
|
||||
type: zoneType,
|
||||
resourceIds: zoneResourceIds,
|
||||
fillColor: fillColor.trim() || null,
|
||||
strokeColor: zoneStrokeColor.trim() || null,
|
||||
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
|
||||
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
|
||||
labelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
toast.success(editing?.kind === "zone" ? "Zone updated." : "Zone saved.");
|
||||
} else {
|
||||
const result = await saveMapNode({
|
||||
id: editing?.kind === "node" ? editing.id : undefined,
|
||||
mapId,
|
||||
name: nodeName.trim(),
|
||||
description: nodeDescription.trim() || null,
|
||||
resourceIds,
|
||||
position: draftPoints[0],
|
||||
richness: Number(richness) || 100,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
toast.success(editing?.kind === "node" ? "Resource node updated." : "Resource node saved.");
|
||||
}
|
||||
setKindState(null);
|
||||
setDraftPoints([]);
|
||||
setEditing(null);
|
||||
setRoadName("");
|
||||
setRoadDescription("");
|
||||
setZoneName("");
|
||||
setZoneDescription("");
|
||||
setNodeName("");
|
||||
setNodeDescription("");
|
||||
setResourceIds([]);
|
||||
setZoneResourceIds([]);
|
||||
setStrokeColor("");
|
||||
setStrokeWeight("");
|
||||
setStrokeStyle("solid");
|
||||
setFillColor("");
|
||||
setZoneStrokeColor("");
|
||||
setFillOpacity("");
|
||||
setZoneStrokeWeight("");
|
||||
setLabelVisible(true);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Save failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
kind,
|
||||
editing,
|
||||
mapId,
|
||||
roadName,
|
||||
roadDescription,
|
||||
surface,
|
||||
speedMultiplier,
|
||||
strokeColor,
|
||||
strokeWeight,
|
||||
strokeStyle,
|
||||
labelVisible,
|
||||
draftPoints,
|
||||
zoneName,
|
||||
zoneDescription,
|
||||
zoneType,
|
||||
fillColor,
|
||||
zoneStrokeColor,
|
||||
fillOpacity,
|
||||
zoneStrokeWeight,
|
||||
nodeName,
|
||||
nodeDescription,
|
||||
resourceIds,
|
||||
zoneResourceIds,
|
||||
richness,
|
||||
onSaved,
|
||||
]);
|
||||
|
||||
const roadReady = kind !== "road" || draftPoints.length >= 2;
|
||||
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
|
||||
const nodeReady =
|
||||
kind !== "node" ||
|
||||
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
|
||||
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
|
||||
|
||||
return {
|
||||
kind,
|
||||
draftPoints,
|
||||
canSave,
|
||||
saving,
|
||||
editing,
|
||||
fields: {
|
||||
roadName,
|
||||
setRoadName,
|
||||
roadDescription,
|
||||
setRoadDescription,
|
||||
surface,
|
||||
setSurface,
|
||||
speedMultiplier,
|
||||
setSpeedMultiplier,
|
||||
strokeColor,
|
||||
setStrokeColor,
|
||||
strokeWeight,
|
||||
setStrokeWeight,
|
||||
strokeStyle,
|
||||
setStrokeStyle,
|
||||
labelVisible,
|
||||
setLabelVisible,
|
||||
zoneName,
|
||||
setZoneName,
|
||||
zoneDescription,
|
||||
setZoneDescription,
|
||||
zoneType,
|
||||
setZoneType,
|
||||
fillColor,
|
||||
setFillColor,
|
||||
zoneStrokeColor,
|
||||
setZoneStrokeColor,
|
||||
fillOpacity,
|
||||
setFillOpacity,
|
||||
zoneStrokeWeight,
|
||||
setZoneStrokeWeight,
|
||||
nodeName,
|
||||
setNodeName,
|
||||
nodeDescription,
|
||||
setNodeDescription,
|
||||
resourceIds,
|
||||
toggleResourceId,
|
||||
zoneResourceIds,
|
||||
toggleZoneResourceId,
|
||||
richness,
|
||||
setRichness,
|
||||
},
|
||||
setKind,
|
||||
pushPoint,
|
||||
clearDraft,
|
||||
save,
|
||||
removeFeature,
|
||||
startEditingRoad,
|
||||
startEditingZone,
|
||||
startEditingNode,
|
||||
};
|
||||
}
|
||||
|
||||
function useResourceOptions(): { id: number; name: string }[] {
|
||||
const [options, setOptions] = useState<{ id: number; name: string }[]>([]);
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const response = await fetch("/api/resources?limit=500&depth=0&sort=name", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok) {
|
||||
const body = (await response.json()) as { docs: { id: number; name: string }[] };
|
||||
setOptions(body.docs);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
return options;
|
||||
}
|
||||
|
||||
function ResourcePicker({
|
||||
label,
|
||||
selected,
|
||||
onToggle,
|
||||
resources,
|
||||
}: {
|
||||
label: string;
|
||||
selected: number[];
|
||||
onToggle: (id: number) => void;
|
||||
resources: { id: number; name: string }[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const needle = query.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? resources.filter((resource) => resource.name.toLowerCase().includes(needle))
|
||||
: resources;
|
||||
const summary =
|
||||
selected.length === 0
|
||||
? label
|
||||
: selected.length <= 2
|
||||
? resources
|
||||
.filter((resource) => selected.includes(resource.id))
|
||||
.map((resource) => resource.name)
|
||||
.join(", ") || label
|
||||
: `${selected.length} selected`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">{label}</Label>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-52 justify-between rounded-none px-2 text-xs font-normal normal-case"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<span className="text-white/40">▾</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="z-[1100] w-60 rounded-none border-white/20 bg-black/95"
|
||||
>
|
||||
<div className="p-1.5">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search resources..."
|
||||
className="h-7 rounded-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filtered.map((resource) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={resource.id}
|
||||
checked={selected.includes(resource.id)}
|
||||
onCheckedChange={() => onToggle(resource.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="text-xs"
|
||||
>
|
||||
{resource.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-white/40">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) {
|
||||
const resources = useResourceOptions();
|
||||
const { kind, fields } = authoring;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto w-[640px] max-w-[calc(100vw-10rem)] overflow-hidden rounded-none border border-white/15 bg-black/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
||||
GM Authoring // Feature Editor
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
||||
REF {String(mapId).padStart(3, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(["road", "zone", "node"] as const).map((target) => (
|
||||
<Button
|
||||
key={target}
|
||||
size="sm"
|
||||
variant={kind === target && !authoring.editing ? "secondary" : "outline"}
|
||||
className="h-8 px-2.5 text-xs capitalize"
|
||||
onClick={() =>
|
||||
authoring.setKind(kind === target && !authoring.editing ? null : target)
|
||||
}
|
||||
>
|
||||
{target === "node" ? "New node" : `New ${target}`}
|
||||
</Button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-white/60">
|
||||
{authoring.draftPoints.length} point{authoring.draftPoints.length === 1 ? "" : "s"}
|
||||
{kind === "road" && authoring.draftPoints.length < 2 && " (need 2+)"}
|
||||
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
|
||||
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={authoring.clearDraft}
|
||||
disabled={authoring.draftPoints.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void authoring.save()}
|
||||
disabled={!authoring.canSave}
|
||||
>
|
||||
{authoring.saving ? "Saving..." : authoring.editing ? "Save changes" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{authoring.editing && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-sky-500/40 bg-sky-950/50 px-2 py-1.5 text-xs text-sky-200">
|
||||
<span>
|
||||
Editing {authoring.editing.kind}:{" "}
|
||||
<span className="font-semibold">{authoring.editing.label}</span>
|
||||
{kind === "road" && " (click the map to add points to the route)"}
|
||||
{kind === "zone" && " (click the map to add vertices to the polygon)"}
|
||||
{kind === "node" && " (click the map to move the node)"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto h-6 px-2 text-xs"
|
||||
onClick={() => authoring.setKind(null)}
|
||||
>
|
||||
Cancel edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === "road" && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
||||
<Input
|
||||
value={fields.roadName}
|
||||
onChange={(event) => fields.setRoadName(event.target.value)}
|
||||
placeholder="Road name"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Surface</Label>
|
||||
<Select
|
||||
value={fields.surface}
|
||||
onValueChange={(value) => fields.setSurface(value as "paved" | "dirt" | "trail")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="paved">Paved</SelectItem>
|
||||
<SelectItem value="dirt">Dirt</SelectItem>
|
||||
<SelectItem value="trail">Trail</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Speed x</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
value={fields.speedMultiplier}
|
||||
onChange={(event) => fields.setSpeedMultiplier(event.target.value)}
|
||||
className="h-8 w-16 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Color</Label>
|
||||
<Input
|
||||
value={fields.strokeColor}
|
||||
onChange={(event) => fields.setStrokeColor(event.target.value)}
|
||||
placeholder="#cbd5e1"
|
||||
className="h-8 w-24 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Width</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={fields.strokeWeight}
|
||||
onChange={(event) => fields.setStrokeWeight(event.target.value)}
|
||||
placeholder="2"
|
||||
className="h-8 w-14 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Style</Label>
|
||||
<Select
|
||||
value={fields.strokeStyle}
|
||||
onValueChange={(value) =>
|
||||
fields.setStrokeStyle(value as "solid" | "dashed" | "dotted")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">Solid</SelectItem>
|
||||
<SelectItem value="dashed">Dashed</SelectItem>
|
||||
<SelectItem value="dotted">Dotted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
||||
<textarea
|
||||
value={fields.roadDescription}
|
||||
onChange={(event) => fields.setRoadDescription(event.target.value)}
|
||||
placeholder="Shown in the tooltip + popup"
|
||||
rows={2}
|
||||
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === "zone" && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
||||
<Input
|
||||
value={fields.zoneName}
|
||||
onChange={(event) => fields.setZoneName(event.target.value)}
|
||||
placeholder="Zone name"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Type</Label>
|
||||
<Select
|
||||
value={fields.zoneType}
|
||||
onValueChange={(value) => fields.setZoneType(value as "water" | "land")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="water">Water</SelectItem>
|
||||
<SelectItem value="land">Land</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Fill</Label>
|
||||
<Input
|
||||
value={fields.fillColor}
|
||||
onChange={(event) => fields.setFillColor(event.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="h-8 w-24 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Edge</Label>
|
||||
<Input
|
||||
value={fields.zoneStrokeColor}
|
||||
onChange={(event) => fields.setZoneStrokeColor(event.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="h-8 w-24 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Opacity</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0"
|
||||
max="1"
|
||||
value={fields.fillOpacity}
|
||||
onChange={(event) => fields.setFillOpacity(event.target.value)}
|
||||
placeholder="0.25"
|
||||
className="h-8 w-16 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Width</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={fields.zoneStrokeWeight}
|
||||
onChange={(event) => fields.setZoneStrokeWeight(event.target.value)}
|
||||
placeholder="1"
|
||||
className="h-8 w-14 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ResourcePicker
|
||||
label="Resources in zone"
|
||||
selected={fields.zoneResourceIds}
|
||||
onToggle={fields.toggleZoneResourceId}
|
||||
resources={resources}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
||||
<textarea
|
||||
value={fields.zoneDescription}
|
||||
onChange={(event) => fields.setZoneDescription(event.target.value)}
|
||||
placeholder="Shown in the tooltip + popup"
|
||||
rows={2}
|
||||
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === "node" && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
||||
<Input
|
||||
value={fields.nodeName}
|
||||
onChange={(event) => fields.setNodeName(event.target.value)}
|
||||
placeholder="Node name"
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
||||
<textarea
|
||||
value={fields.nodeDescription}
|
||||
onChange={(event) => fields.setNodeDescription(event.target.value)}
|
||||
placeholder="Shown in the tooltip + popup"
|
||||
rows={2}
|
||||
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<ResourcePicker
|
||||
label="Resources"
|
||||
selected={fields.resourceIds}
|
||||
onToggle={fields.toggleResourceId}
|
||||
resources={resources}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Richness</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={fields.richness}
|
||||
onChange={(event) => fields.setRichness(event.target.value)}
|
||||
className="h-8 w-16 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1352
src/components/frontend/map/MapClient.tsx
Normal file
1352
src/components/frontend/map/MapClient.tsx
Normal file
File diff suppressed because it is too large
Load diff
321
src/components/frontend/map/PlacementDialog.tsx
Normal file
321
src/components/frontend/map/PlacementDialog.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
|
||||
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement";
|
||||
import type { MapPoint } from "@/lib/map/points";
|
||||
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
|
||||
|
||||
interface BlueprintOption {
|
||||
id: number;
|
||||
name: string;
|
||||
terrainType: "land" | "water" | null;
|
||||
constructionDurationMinutes: number | null;
|
||||
requiredTech: string[];
|
||||
materials: { resource: number; resourceName: string; amount: number | null }[];
|
||||
resourcesInRange: { resource: number | null; range: number | null }[];
|
||||
structuresInRange: { structure: number | null; range: number | null }[];
|
||||
}
|
||||
|
||||
interface FactionOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PlacementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mapConfig: MapConfig | null;
|
||||
point: MapPoint;
|
||||
zones: ZoneFeature[];
|
||||
nodes: NodeFeature[];
|
||||
structures: StructureFeature[];
|
||||
onPlaced: () => void;
|
||||
}
|
||||
|
||||
export function PlacementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mapConfig,
|
||||
point,
|
||||
zones,
|
||||
nodes,
|
||||
structures,
|
||||
onPlaced,
|
||||
}: PlacementDialogProps) {
|
||||
const [blueprints, setBlueprints] = useState<BlueprintOption[]>([]);
|
||||
const [factions, setFactions] = useState<FactionOption[]>([]);
|
||||
const [blueprintId, setBlueprintId] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [factionId, setFactionId] = useState<string>("none");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [blueprintRes, factionRes] = await Promise.all([
|
||||
fetch("/api/structures?limit=500&depth=1&sort=name", { cache: "no-store" }),
|
||||
fetch("/api/factions?limit=200&depth=0&sort=name", { cache: "no-store" }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (blueprintRes.ok) {
|
||||
const body = (await blueprintRes.json()) as {
|
||||
docs: {
|
||||
id: number;
|
||||
name: string;
|
||||
terrainType?: string | null;
|
||||
constructionDurationMinutes?: number | null;
|
||||
requiredTech?: { name?: string }[] | number[];
|
||||
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[];
|
||||
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[];
|
||||
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[];
|
||||
}[];
|
||||
};
|
||||
setBlueprints(
|
||||
body.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
|
||||
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
|
||||
requiredTech: (doc.requiredTech ?? [])
|
||||
.filter((tech): tech is { name?: string } => typeof tech === "object" && tech !== null)
|
||||
.map((tech) => tech.name ?? "Unknown"),
|
||||
materials: (doc.materials ?? []).map((material) => ({
|
||||
resource:
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? material.resource.id
|
||||
: 0,
|
||||
resourceName:
|
||||
typeof material.resource === "object" && material.resource !== null
|
||||
? (material.resource.name ?? "Resource")
|
||||
: "Resource",
|
||||
amount: material.amount ?? 0,
|
||||
})),
|
||||
resourcesInRange: (doc.resourcesInRange ?? []).map((requirement) => ({
|
||||
resource:
|
||||
typeof requirement.resource === "object" && requirement.resource !== null
|
||||
? requirement.resource.id
|
||||
: null,
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
structuresInRange: (doc.structuresInRange ?? []).map((requirement) => ({
|
||||
structure:
|
||||
typeof requirement.structure === "object" && requirement.structure !== null
|
||||
? requirement.structure.id
|
||||
: null,
|
||||
range: requirement.range ?? null,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (factionRes.ok) {
|
||||
const body = (await factionRes.json()) as { docs: { id: number; name: string }[] };
|
||||
setFactions(body.docs);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const blueprint = useMemo(
|
||||
() => blueprints.find((option) => option.id === Number(blueprintId)) ?? null,
|
||||
[blueprints, blueprintId],
|
||||
);
|
||||
|
||||
const validationError = useMemo(() => {
|
||||
if (!mapConfig || !blueprint) return null;
|
||||
if (
|
||||
point[0] < 0 ||
|
||||
point[0] > mapConfig.worldSizeWidth ||
|
||||
point[1] < 0 ||
|
||||
point[1] > mapConfig.worldSizeHeight
|
||||
) {
|
||||
return "Site is outside the map bounds.";
|
||||
}
|
||||
const zoneInputs: ZoneInput[] = zones.map((zone) => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
type: zone.type,
|
||||
resources: zone.resources.map((entry) => entry.id),
|
||||
points: zone.points,
|
||||
}));
|
||||
const nodeInputs: NodeInput[] = nodes.map((node) => ({
|
||||
id: node.id,
|
||||
resources: node.resources.map((entry) => entry.id),
|
||||
position: node.point,
|
||||
}));
|
||||
const structureInputs: StructureInput[] = structures.map((structure) => ({
|
||||
id: structure.id,
|
||||
type: structure.blueprintId ?? 0,
|
||||
coordinates: structure.point,
|
||||
}));
|
||||
const validation = validatePlacement({
|
||||
zones: zoneInputs,
|
||||
nodes: nodeInputs,
|
||||
structures: structureInputs,
|
||||
blueprintTerrain: blueprint.terrainType ?? "land",
|
||||
resourcesInRange: blueprint.resourcesInRange,
|
||||
structuresInRange: blueprint.structuresInRange,
|
||||
point,
|
||||
});
|
||||
return validation.allowed ? null : (validation.error ?? "Site is not valid.");
|
||||
}, [mapConfig, blueprint, point, zones, nodes, structures]);
|
||||
|
||||
const reset = () => {
|
||||
setBlueprintId("");
|
||||
setName("");
|
||||
setFactionId("none");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!mapConfig || !blueprint) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await placeStructure({
|
||||
mapId: mapConfig.id,
|
||||
blueprintId: blueprint.id,
|
||||
name,
|
||||
factionId: factionId === "none" ? null : Number(factionId),
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(`Placement of "${name}" submitted. Ship materials to start construction.`);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
onPlaced();
|
||||
} else {
|
||||
toast.error(result.error ?? "Placement failed.");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = Boolean(blueprint) && name.trim().length > 0 && !validationError && !submitting;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) reset();
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Place structure</DialogTitle>
|
||||
<DialogDescription>
|
||||
Site at {Math.round(point[0])} m east, {Math.round(point[1])} m south
|
||||
{mapConfig ? ` on ${mapConfig.name}` : ""}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-blueprint">Blueprint</Label>
|
||||
<Select value={blueprintId} onValueChange={setBlueprintId}>
|
||||
<SelectTrigger id="place-blueprint" className="w-full">
|
||||
<SelectValue placeholder="Select blueprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{blueprints.map((option) => (
|
||||
<SelectItem key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-name">Display name</Label>
|
||||
<Input
|
||||
id="place-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. FOB Wolfpack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-faction">Faction (optional)</Label>
|
||||
<Select value={factionId} onValueChange={setFactionId}>
|
||||
<SelectTrigger id="place-faction" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No faction</SelectItem>
|
||||
{factions.map((faction) => (
|
||||
<SelectItem key={faction.id} value={String(faction.id)}>
|
||||
{faction.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{blueprint && (
|
||||
<div className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
|
||||
<div>
|
||||
Terrain: <span className="capitalize">{blueprint.terrainType ?? "land"}</span>
|
||||
{blueprint.constructionDurationMinutes != null && (
|
||||
<>
|
||||
{" \u00b7 "}Build time: {blueprint.constructionDurationMinutes} min
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{blueprint.materials.length > 0 && (
|
||||
<div>
|
||||
Materials:{" "}
|
||||
{blueprint.materials
|
||||
.map((material) => `${material.resourceName} x${material.amount ?? 0}`)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{blueprint.requiredTech.length > 0 && (
|
||||
<div>Requires tech (informational): {blueprint.requiredTech.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<p className="rounded-md border border-red-500/40 bg-red-950/60 p-2 text-xs text-red-200">
|
||||
{validationError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{submitting ? "Placing..." : "Place structure"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
245
src/components/frontend/map/StructureDetailDrawer.tsx
Normal file
245
src/components/frontend/map/StructureDetailDrawer.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { X, ExternalLinkIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||
import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
||||
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
||||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
|
||||
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import {
|
||||
BoxIcon,
|
||||
CoinsIcon,
|
||||
MapPinIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
|
||||
export function StructureDetailDrawer({
|
||||
structureId,
|
||||
onClose,
|
||||
}: {
|
||||
structureId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<StructureDetailData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const result = await getStructureDetail(structureId);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to load structure.");
|
||||
}
|
||||
}, [structureId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useGameTick(load);
|
||||
|
||||
const structure = data?.structure ?? null;
|
||||
const blueprint = data?.blueprint ?? null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-y-0 right-0 z-[1200] flex w-[480px] max-w-[94vw] flex-col border-l border-white/15 bg-[#080a0e]/95 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-white/15 bg-black/60 px-3 py-1.5">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
||||
Structure Dossier // Preview
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
||||
REF {String(structureId).padStart(3, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
className="rounded-none p-1 text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{!data && !error && (
|
||||
<p className="p-4 font-mono text-[10px] uppercase tracking-[0.2em] text-white/50">
|
||||
Loading dossier...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-none border border-red-500/40 bg-red-950/60 p-3 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && structure && blueprint && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.imageUrl && (
|
||||
<div className="relative overflow-hidden rounded-none border border-white/15">
|
||||
<img src={data.imageUrl} alt={structure.name} className="h-36 w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/60">
|
||||
{blueprint.name}
|
||||
{data.gates.canManage ? " // managed by you" : ""}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-white/95">{structure.name}</div>
|
||||
{data.description && (
|
||||
<p className="text-xs text-muted-foreground">{data.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 flex-1 rounded-none px-2 font-mono text-[10px] uppercase tracking-[0.2em]"
|
||||
>
|
||||
<Link href={`/logistics/structures/${structure.id}`} target="_blank">
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
Full Page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle className="flex items-center gap-2">
|
||||
<BoxIcon className="size-4 text-muted-foreground" />
|
||||
Storage Capacity
|
||||
</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<CapacityBar
|
||||
utilization={data.derived.utilization}
|
||||
currentMass={data.derived.currentMass}
|
||||
maxMass={blueprint.maxCapacityMass ?? null}
|
||||
/>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle className="flex items-center gap-2">
|
||||
<CoinsIcon className="size-4 text-muted-foreground" />
|
||||
Total Value
|
||||
</ItemTitle>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">{data.derived.totalValue.toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">base value</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{structure.coordinates && (
|
||||
<Item variant="muted" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<MapPinIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Coordinates</ItemTitle>
|
||||
<ItemDescription>
|
||||
{structure.coordinates[0].toFixed(0)}, {structure.coordinates[1].toFixed(0)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
{data.derived.gridSlots !== null && (
|
||||
<Item variant="muted" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<BoxIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Grid</ItemTitle>
|
||||
<ItemDescription>
|
||||
{blueprint.xSize}×{blueprint.ySize} ({data.derived.gridSlots} slots)
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
{blueprint.maxCapacityMass && (
|
||||
<Item variant="muted" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<BoxIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Max Capacity</ItemTitle>
|
||||
<ItemDescription>
|
||||
{blueprint.maxCapacityMass.toLocaleString()} kg (mass)
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
{blueprint.maxHealth && (
|
||||
<Item variant="muted" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<WrenchIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Max Health</ItemTitle>
|
||||
<ItemDescription>{blueprint.maxHealth.toLocaleString()}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UpgradeCard gameStructureId={structure.id} upgrade={data.upgrade} />
|
||||
|
||||
<ConstructionPanel
|
||||
status={structure.constructionStatus ?? "complete"}
|
||||
startedAt={structure.constructionStartedAt ?? null}
|
||||
completesAt={structure.constructionCompletesAt ?? null}
|
||||
durationMinutes={blueprint.constructionDurationMinutes ?? null}
|
||||
materials={data.derived.constructionMaterials}
|
||||
/>
|
||||
|
||||
<StaffRoster
|
||||
structure={data.staffing.structureStaffing}
|
||||
staff={data.staffing.staff}
|
||||
classifications={data.staffing.classifications}
|
||||
npcs={data.staffing.npcs}
|
||||
canManage={data.gates.canManage}
|
||||
hiringEnabled={data.hiring.enabled}
|
||||
hiringDisabledMessage={data.hiring.disabledMessage}
|
||||
currencyConfig={data.currencyConfig}
|
||||
/>
|
||||
|
||||
<StructureStorageView
|
||||
structure={structure}
|
||||
canMutate={data.gates.canManageStorage}
|
||||
canArrange={data.gates.canTransferStorage}
|
||||
/>
|
||||
|
||||
<StorageRules
|
||||
allowedStorage={blueprint.allowedStorage}
|
||||
prohibitedStorage={blueprint.prohibitedStorage}
|
||||
restrictToAllowed={blueprint.restrictToAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,6 +58,24 @@ function cardLabel(card: Card): string {
|
|||
return `${rank} ${suit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete card from one picker interaction: an empty selection
|
||||
* starts from the 2-of-spades baseline for the not-yet-chosen half, and any
|
||||
* already-chosen half is preserved. Returning a full card (never null) is
|
||||
* what lets a single pick land; the previous null-propagating version made
|
||||
* first picks no-ops and hole-card queuing impossible.
|
||||
*/
|
||||
export function applyCardPick(
|
||||
value: Card | null,
|
||||
part: "rank" | "suit",
|
||||
next: string,
|
||||
): Card {
|
||||
if (part === "rank") {
|
||||
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
|
||||
}
|
||||
return { rank: value?.rank ?? 2, suit: next as Suit };
|
||||
}
|
||||
|
||||
function CardPicker({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -67,13 +85,13 @@ function CardPicker({
|
|||
readonly value: Card | null;
|
||||
readonly onChange: (card: Card | null) => void;
|
||||
}) {
|
||||
const setRank = (next: string) => onChange(applyCardPick(value, "rank", next));
|
||||
const setSuit = (next: string) => onChange(applyCardPick(value, "suit", next));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
||||
<Select
|
||||
value={value ? String(value.rank) : ""}
|
||||
onValueChange={(next) => onChange(value ? { ...value, rank: Number(next) as Rank } : null)}
|
||||
>
|
||||
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue placeholder="Rank" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -85,10 +103,7 @@ function CardPicker({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={value?.suit ?? ""}
|
||||
onValueChange={(next) => onChange(value ? { ...value, suit: next as Suit } : null)}
|
||||
>
|
||||
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue placeholder="Suit" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -191,11 +206,7 @@ export function PokerDevPanel({
|
|||
<section className="flex flex-col gap-2 border border-border p-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider">Board queue</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardPicker
|
||||
label="Card"
|
||||
value={boardDraft}
|
||||
onChange={(card) => setBoardDraft(card ?? { rank: 2, suit: "s" })}
|
||||
/>
|
||||
<CardPicker label="Card" value={boardDraft} onChange={setBoardDraft} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[1300] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[1300] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[1300] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -40,7 +40,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[1300] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[1300] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
|||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"fixed z-[1300] gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ lib/
|
|||
base/ # Staffing (hire/fire/headcount), structure upgrades, storage, base tick
|
||||
evaluations/ # Evaluation levels + bot reminder plan (reminders.ts)
|
||||
locker/ # Grid logic, placement validation, loadouts, attachments/skins
|
||||
map/ # World map: routing.ts (Dijkstra road graph), placement.ts (terrain/ranges), construction.ts (tick), points.ts (pure geometry)
|
||||
logistics/ # Mission lifecycle + mission reminder logic
|
||||
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
|
||||
notifications/ # User notification helper + muteable types
|
||||
|
|
|
|||
202
src/lib/map/construction.ts
Normal file
202
src/lib/map/construction.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import type { Payload } from "payload";
|
||||
import type { GameStructure, Structure } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { consumeStorage, relationshipId, storedAmount } from "@/lib/base/storage";
|
||||
|
||||
export interface MaybeStartConstructionResult {
|
||||
started: boolean;
|
||||
/** Why the site did not start (undefined when started). */
|
||||
reason?: "not_awaiting" | "missing_materials" | "no_blueprint";
|
||||
}
|
||||
|
||||
export interface ConstructionSweepResult {
|
||||
/** Sites whose deadline passed and were flipped to complete this sweep. */
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface ConstructionLog {
|
||||
info?: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
interface ConstructionSite {
|
||||
id: number;
|
||||
name: string;
|
||||
constructionStatus: "awaiting_materials" | "building" | "complete" | null;
|
||||
constructionStartedAt: string | Date | null;
|
||||
constructionCompletesAt: string | Date | null;
|
||||
storedResources: NonNullable<GameStructure["storedResources"]> | null;
|
||||
voidStorage: NonNullable<GameStructure["voidStorage"]> | null;
|
||||
type: Structure | number | null;
|
||||
}
|
||||
|
||||
async function blueprintOf(payload: Payload, site: ConstructionSite): Promise<Structure | null> {
|
||||
if (!site.type) return null;
|
||||
if (typeof site.type === "object") return site.type;
|
||||
return payload.findByID({
|
||||
collection: "structures",
|
||||
id: site.type,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
function materialShortfalls(blueprint: Structure, site: ConstructionSite): string[] {
|
||||
const stored = site.storedResources ?? [];
|
||||
const voidStorage = site.voidStorage ?? [];
|
||||
const shortages: string[] = [];
|
||||
|
||||
for (const material of blueprint.materials ?? []) {
|
||||
const resourceId = relationshipId(material.resource);
|
||||
const needed = Math.ceil(material.amount ?? 0);
|
||||
if (resourceId === null || needed <= 0) continue;
|
||||
const available = storedAmount(stored, resourceId) + storedAmount(voidStorage, resourceId);
|
||||
if (available < needed) {
|
||||
shortages.push(`resource #${resourceId} (have ${available}, need ${needed})`);
|
||||
}
|
||||
}
|
||||
return shortages;
|
||||
}
|
||||
|
||||
function consumeMaterials(
|
||||
blueprint: Structure,
|
||||
site: ConstructionSite,
|
||||
): { storedResources: ConstructionSite["storedResources"]; voidStorage: ConstructionSite["voidStorage"] } {
|
||||
let stored = [...(site.storedResources ?? [])];
|
||||
let voidStorage = [...(site.voidStorage ?? [])];
|
||||
|
||||
for (const material of blueprint.materials ?? []) {
|
||||
const resourceId = relationshipId(material.resource);
|
||||
const needed = Math.ceil(material.amount ?? 0);
|
||||
if (resourceId === null || needed <= 0) continue;
|
||||
const fromStored = consumeStorage(stored, resourceId, needed);
|
||||
stored = fromStored.entries;
|
||||
if (fromStored.remaining > 0) {
|
||||
voidStorage = consumeStorage(voidStorage, resourceId, fromStored.remaining).entries;
|
||||
}
|
||||
}
|
||||
|
||||
return { storedResources: stored, voidStorage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive construction start (settled decision: construction runs on
|
||||
* timestamps, not tick counts). Called at delivery time: after a shipment
|
||||
* completes at the site, after a successful storage deposit, or immediately
|
||||
* after placement for blueprints that need no materials.
|
||||
*
|
||||
* If the site is awaiting_materials and every blueprint material is present
|
||||
* in the site's storage (grid + void), consumes them and flips the site to
|
||||
* building with constructionStartedAt = now and constructionCompletesAt =
|
||||
* now + the blueprint's constructionDurationMinutes. Otherwise it is a no-op.
|
||||
*/
|
||||
export async function maybeStartConstruction(
|
||||
payload: Payload,
|
||||
siteId: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<MaybeStartConstructionResult> {
|
||||
const doc = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
id: siteId,
|
||||
depth: 2,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const site = doc as unknown as ConstructionSite;
|
||||
|
||||
if (site.constructionStatus !== "awaiting_materials") {
|
||||
return { started: false, reason: "not_awaiting" };
|
||||
}
|
||||
|
||||
const blueprint = await blueprintOf(payload, site);
|
||||
if (!blueprint) {
|
||||
return { started: false, reason: "no_blueprint" };
|
||||
}
|
||||
|
||||
if (materialShortfalls(blueprint, site).length > 0) {
|
||||
return { started: false, reason: "missing_materials" };
|
||||
}
|
||||
|
||||
const { storedResources, voidStorage } = consumeMaterials(blueprint, site);
|
||||
const durationMinutes = Math.max(0, blueprint.constructionDurationMinutes ?? 0);
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: site.id,
|
||||
data: {
|
||||
storedResources,
|
||||
voidStorage,
|
||||
constructionStatus: "building",
|
||||
constructionStartedAt: now.toISOString(),
|
||||
constructionCompletesAt: new Date(now.getTime() + durationMinutes * 60 * 1000).toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweeps finished construction sites (settled decision: the game tick no
|
||||
* longer starts construction or advances progress; it only completes sites
|
||||
* whose constructionCompletesAt deadline has passed and emits the event).
|
||||
* Awaiting-to-building transitions happen exclusively in
|
||||
* maybeStartConstruction at delivery time.
|
||||
*/
|
||||
export async function processConstructionTick(
|
||||
payload: Payload,
|
||||
log?: ConstructionLog,
|
||||
now: Date = new Date(),
|
||||
): Promise<ConstructionSweepResult> {
|
||||
const result: ConstructionSweepResult = { completed: 0 };
|
||||
|
||||
const sites = await payload.find({
|
||||
collection: "game-structures",
|
||||
where: {
|
||||
and: [
|
||||
{ constructionStatus: { equals: "building" } },
|
||||
{ constructionCompletesAt: { less_than_equal: now.toISOString() } },
|
||||
],
|
||||
},
|
||||
depth: 2,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
for (const doc of sites.docs) {
|
||||
const site = doc as unknown as ConstructionSite;
|
||||
try {
|
||||
const blueprint = await blueprintOf(payload, site);
|
||||
|
||||
await payload.update({
|
||||
collection: "game-structures",
|
||||
id: site.id,
|
||||
data: { constructionStatus: "complete" },
|
||||
overrideAccess: true,
|
||||
});
|
||||
result.completed++;
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.StructureConstructionComplete,
|
||||
message: `Construction of "${site.name}" completed`,
|
||||
structure: site.id,
|
||||
targetCollection: "game-structures",
|
||||
targetId: site.id,
|
||||
data: {
|
||||
blueprintId: blueprint?.id ?? null,
|
||||
blueprintName: blueprint?.name ?? null,
|
||||
startedAt: site.constructionStartedAt,
|
||||
completesAt: site.constructionCompletesAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log?.error?.(
|
||||
`Construction sweep failed for site "${site.name}" (#${site.id}): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
195
src/lib/map/placement.ts
Normal file
195
src/lib/map/placement.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Structure placement validation: terrain zones, resourcesInRange,
|
||||
* structuresInRange.
|
||||
*
|
||||
* Pure module: no Payload import, safe on client and server. The server-side
|
||||
* action fetches zones/nodes/structures and passes them in; the same functions
|
||||
* power the client-side pre-validation in the placement dialog.
|
||||
*/
|
||||
|
||||
import { distanceSquared, pointInPolygon, type MapPoint } from "./points";
|
||||
|
||||
export { pointDistance } from "./points";
|
||||
|
||||
export type BlueprintTerrain = "land" | "water";
|
||||
|
||||
export interface ZoneInput {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
type: "water" | "land";
|
||||
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
|
||||
resources?: number[];
|
||||
points: MapPoint[];
|
||||
}
|
||||
|
||||
export interface NodeInput {
|
||||
id: number;
|
||||
/** Resource ids this node yields (a node may hold several). */
|
||||
resources: number[];
|
||||
position: MapPoint;
|
||||
}
|
||||
|
||||
export interface StructureInput {
|
||||
id: number;
|
||||
type: number;
|
||||
coordinates: MapPoint;
|
||||
}
|
||||
|
||||
export interface RangeRequirement {
|
||||
resource?: number | { id: number } | null;
|
||||
structure?: number | { id: number } | null;
|
||||
range?: number | null;
|
||||
}
|
||||
|
||||
export interface TerrainCheck {
|
||||
allowed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain rule: default terrain is buildable land; drawn water polygons block
|
||||
* land-blueprint placement; water blueprints must sit inside a water zone. A
|
||||
* map with no zones accepts everything.
|
||||
*/
|
||||
export function checkTerrain(
|
||||
zones: readonly ZoneInput[],
|
||||
blueprintTerrain: BlueprintTerrain,
|
||||
point: MapPoint,
|
||||
): TerrainCheck {
|
||||
const waterZones = zones.filter((zone) => zone.type === "water");
|
||||
const containingWater = waterZones.find((zone) => pointInPolygon(point, zone.points));
|
||||
|
||||
if (blueprintTerrain === "land") {
|
||||
if (containingWater) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `This structure cannot be built in water (inside zone "${containingWater.name ?? `#${containingWater.id}`}").`,
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (!containingWater) {
|
||||
return {
|
||||
allowed: false,
|
||||
error:
|
||||
"This structure must be placed in water, but the site is not inside a drawn water zone.",
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export interface RangeCheck {
|
||||
allowed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function requirementId(
|
||||
value: RangeRequirement["resource"] | RangeRequirement["structure"],
|
||||
): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
return value?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* resourcesInRange: every blueprint requirement needs at least one matching
|
||||
* resource node within range (meters) of the site, OR a containing zone that
|
||||
* carries the resource (area deposits like oil fields: inside = satisfied).
|
||||
*/
|
||||
export function checkResourcesInRange(
|
||||
nodes: readonly NodeInput[],
|
||||
requirements: readonly RangeRequirement[],
|
||||
point: MapPoint,
|
||||
zones: readonly ZoneInput[] = [],
|
||||
): RangeCheck {
|
||||
for (const requirement of requirements) {
|
||||
const resourceId = requirementId(requirement.resource);
|
||||
if (resourceId === null) continue;
|
||||
const range = requirement.range ?? 0;
|
||||
const maxSq = range * range;
|
||||
const nodeMatch = nodes.some(
|
||||
(node) =>
|
||||
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
|
||||
);
|
||||
const zoneMatch = zones.some(
|
||||
(zone) =>
|
||||
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
|
||||
);
|
||||
if (!nodeMatch && !zoneMatch) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `Placement requires a matching resource node within ${range} m of the site, or a zone containing the resource at the site.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* structuresInRange: every blueprint requirement needs at least one existing
|
||||
* structure of the given blueprint type within range (meters) of the site.
|
||||
*/
|
||||
export function checkStructuresInRange(
|
||||
structures: readonly StructureInput[],
|
||||
requirements: readonly RangeRequirement[],
|
||||
point: MapPoint,
|
||||
): RangeCheck {
|
||||
for (const requirement of requirements) {
|
||||
const structureId = requirementId(requirement.structure);
|
||||
if (structureId === null) continue;
|
||||
const range = requirement.range ?? 0;
|
||||
const maxSq = range * range;
|
||||
const inRange = structures.some(
|
||||
(structure) =>
|
||||
structure.type === structureId && distanceSquared(structure.coordinates, point) <= maxSq,
|
||||
);
|
||||
if (!inRange) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `Placement requires a structure of the required type within ${range} m of the site.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export interface PlacementValidationInput {
|
||||
zones: readonly ZoneInput[];
|
||||
nodes: readonly NodeInput[];
|
||||
structures: readonly StructureInput[];
|
||||
blueprintTerrain: BlueprintTerrain;
|
||||
resourcesInRange?: readonly RangeRequirement[];
|
||||
structuresInRange?: readonly RangeRequirement[];
|
||||
point: MapPoint;
|
||||
}
|
||||
|
||||
export interface PlacementValidationResult {
|
||||
allowed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function validatePlacement(input: PlacementValidationInput): PlacementValidationResult {
|
||||
const terrain = checkTerrain(input.zones, input.blueprintTerrain, input.point);
|
||||
if (!terrain.allowed) return terrain;
|
||||
|
||||
if (input.resourcesInRange && input.resourcesInRange.length > 0) {
|
||||
const resources = checkResourcesInRange(
|
||||
input.nodes,
|
||||
input.resourcesInRange,
|
||||
input.point,
|
||||
input.zones,
|
||||
);
|
||||
if (!resources.allowed) return resources;
|
||||
}
|
||||
|
||||
if (input.structuresInRange && input.structuresInRange.length > 0) {
|
||||
const structures = checkStructuresInRange(
|
||||
input.structures,
|
||||
input.structuresInRange,
|
||||
input.point,
|
||||
);
|
||||
if (!structures.allowed) return structures;
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
142
src/lib/map/points.ts
Normal file
142
src/lib/map/points.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Shared point/line/polygon helpers for map features.
|
||||
*
|
||||
* All map feature geometry is authored in real meters on a per-map Cartesian
|
||||
* grid (origin top-left, x east, y south, matching Arma's world coordinates).
|
||||
* Stored as JSON arrays of [x, y] tuples.
|
||||
*
|
||||
* Pure module: no Payload import, safe on client and server.
|
||||
*/
|
||||
|
||||
export type MapPoint = [number, number];
|
||||
|
||||
/**
|
||||
* Checks whether an unknown value is an array of [x, y] tuples with finite
|
||||
* numbers. Optionally enforces a minimum number of points.
|
||||
*/
|
||||
export function isPointArray(value: unknown, minPoints = 1): value is MapPoint[] {
|
||||
if (!Array.isArray(value) || value.length < minPoints) return false;
|
||||
return value.every(
|
||||
(point) =>
|
||||
Array.isArray(point) &&
|
||||
point.length === 2 &&
|
||||
typeof point[0] === "number" &&
|
||||
Number.isFinite(point[0]) &&
|
||||
typeof point[1] === "number" &&
|
||||
Number.isFinite(point[1]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Field validator for polyline geometry (roads): at least 2 points. */
|
||||
export function validatePolyline(value: unknown): true | string {
|
||||
if (value === undefined || value === null) return "A road requires at least two points.";
|
||||
if (!isPointArray(value, 2)) return "Points must be an array of [x, y] meter tuples.";
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Field validator for polygon geometry (zones): at least 3 points (unclosed ring). */
|
||||
export function validatePolygon(value: unknown): true | string {
|
||||
if (value === undefined || value === null) return "A zone requires at least three points.";
|
||||
if (!isPointArray(value, 3)) return "Points must be an array of [x, y] meter tuples.";
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Field validator for a single [x, y] position. */
|
||||
export function validatePosition(value: unknown): true | string {
|
||||
if (!Array.isArray(value) || value.length !== 2) return "Position must be an [x, y] tuple.";
|
||||
return isPointArray([value]) ? true : "Position must contain two finite numbers.";
|
||||
}
|
||||
|
||||
/** Squared distance between two points (avoids sqrt in hot loops). */
|
||||
export function distanceSquared(a: MapPoint, b: MapPoint): number {
|
||||
return (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2;
|
||||
}
|
||||
|
||||
/** Euclidean distance in meters between two points. */
|
||||
export function pointDistance(a: MapPoint, b: MapPoint): number {
|
||||
return Math.sqrt(distanceSquared(a, b));
|
||||
}
|
||||
|
||||
export interface InterpolatedPosition {
|
||||
point: MapPoint;
|
||||
bearing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position along a polyline at fraction t in [0, 1] (by geometric length),
|
||||
* plus the bearing (degrees, clockwise from north) of the segment traveled.
|
||||
* Returns the first point when t <= 0 and the last when t >= 1.
|
||||
*/
|
||||
export function interpolateAlong(points: MapPoint[], t: number): InterpolatedPosition | null {
|
||||
if (points.length === 0) return null;
|
||||
if (points.length === 1 || t <= 0) {
|
||||
return { point: points[0], bearing: 0 };
|
||||
}
|
||||
if (t >= 1) {
|
||||
const last = points[points.length - 1];
|
||||
const prev = points[points.length - 2];
|
||||
return { point: last, bearing: bearingDeg(prev, last) };
|
||||
}
|
||||
|
||||
const total = polylineLength(points);
|
||||
if (total <= 0) return { point: points[0], bearing: 0 };
|
||||
|
||||
let traveled = total * t;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const segment = pointDistance(points[i - 1], points[i]);
|
||||
if (segment <= 0) continue;
|
||||
if (traveled <= segment || i === points.length - 1) {
|
||||
const f = Math.min(1, traveled / segment);
|
||||
const point: MapPoint = [
|
||||
points[i - 1][0] + (points[i][0] - points[i - 1][0]) * f,
|
||||
points[i - 1][1] + (points[i][1] - points[i - 1][1]) * f,
|
||||
];
|
||||
return { point, bearing: bearingDeg(points[i - 1], points[i]) };
|
||||
}
|
||||
traveled -= segment;
|
||||
}
|
||||
return { point: points[points.length - 1], bearing: 0 };
|
||||
}
|
||||
|
||||
/** Bearing in degrees (0 = north/+y up means here -y on the map grid), clockwise. */
|
||||
export function bearingDeg(from: MapPoint, to: MapPoint): number {
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
if (dx === 0 && dy === 0) return 0;
|
||||
const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
|
||||
return (angle + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray-casting point-in-polygon test. The ring may be open (first vertex not
|
||||
* repeated at the end); the closing edge is implied.
|
||||
*/
|
||||
export function pointInPolygon(point: MapPoint, polygon: MapPoint[]): boolean {
|
||||
if (!Array.isArray(polygon) || polygon.length < 3) return false;
|
||||
const [px, py] = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
const intersects =
|
||||
yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometric length in meters of a polyline.
|
||||
* Accepts an open polyline (roads) or a closed ring (zones, closing edge included).
|
||||
*/
|
||||
export function polylineLength(points: MapPoint[], closed = false): number {
|
||||
if (points.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += pointDistance(points[i - 1], points[i]);
|
||||
}
|
||||
if (closed && points.length > 2) {
|
||||
total += pointDistance(points[points.length - 1], points[0]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
253
src/lib/map/routing.ts
Normal file
253
src/lib/map/routing.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Road-network routing for ground shipments.
|
||||
*
|
||||
* Builds an undirected weighted graph from a map's road polylines and runs
|
||||
* Dijkstra between the snapped origin and destination. Nodes are keyed on a
|
||||
* 1 m grid, so two roads connect wherever they share a vertex (within the
|
||||
* epsilon) - author road junctions by reusing identical endpoint coordinates
|
||||
* (JOSM-style shared vertices). Polyline crossings without a shared vertex do
|
||||
* NOT connect.
|
||||
*
|
||||
* Edge weight = segment length / speedMultiplier, so preferred (faster) roads
|
||||
* win the shortest path. The reported route distance is always the geometric
|
||||
* length in meters; fuel is charged on that, not on the weighted cost.
|
||||
*
|
||||
* Pure module: no Payload import, safe on client and server.
|
||||
*/
|
||||
|
||||
import { distanceSquared, pointDistance, polylineLength, type MapPoint } from "./points";
|
||||
|
||||
/** Junction tolerance in meters: endpoints within this distance share a node. */
|
||||
export const ROAD_SNAP_EPSILON_METERS = 1;
|
||||
|
||||
/** Maximum distance a structure may sit from the nearest road vertex and still route. */
|
||||
export const DEFAULT_SNAP_RADIUS_METERS = 1000;
|
||||
|
||||
export interface RoadInput {
|
||||
id: number;
|
||||
points: MapPoint[];
|
||||
speedMultiplier: number;
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
key: string;
|
||||
position: MapPoint;
|
||||
neighbors: Map<string, { to: string; weight: number; length: number }>;
|
||||
}
|
||||
|
||||
export interface RoadGraph {
|
||||
nodes: Map<string, GraphNode>;
|
||||
/** Roughly the number of distinct vertices across all roads (for tests/diagnostics). */
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
function nodeKey(position: MapPoint): string {
|
||||
return `${Math.round(position[0] / ROAD_SNAP_EPSILON_METERS)}:${Math.round(
|
||||
position[1] / ROAD_SNAP_EPSILON_METERS,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the road graph. Every vertex of every road becomes a node (keyed on
|
||||
* the epsilon grid so shared vertices merge); consecutive vertices are joined
|
||||
* by a weighted edge. Duplicate edges (two roads overlapping a segment) keep
|
||||
* the fastest weight.
|
||||
*/
|
||||
export function buildRoadGraph(roads: readonly RoadInput[]): RoadGraph {
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
|
||||
const ensureNode = (position: MapPoint): GraphNode => {
|
||||
const key = nodeKey(position);
|
||||
let node = nodes.get(key);
|
||||
if (!node) {
|
||||
node = { key, position, neighbors: new Map() };
|
||||
nodes.set(key, node);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
for (const road of roads) {
|
||||
if (!road.points || road.points.length < 2) continue;
|
||||
const multiplier = road.speedMultiplier && road.speedMultiplier > 0 ? road.speedMultiplier : 1;
|
||||
|
||||
for (let i = 1; i < road.points.length; i++) {
|
||||
const from = ensureNode(road.points[i - 1]);
|
||||
const to = ensureNode(road.points[i]);
|
||||
const length = pointDistance(from.position, to.position);
|
||||
if (length <= 0) continue; // duplicate consecutive vertex
|
||||
|
||||
const weight = length / multiplier;
|
||||
|
||||
const forward = from.neighbors.get(to.key);
|
||||
if (!forward || weight < forward.weight) {
|
||||
from.neighbors.set(to.key, { to: to.key, weight, length });
|
||||
}
|
||||
const backward = to.neighbors.get(from.key);
|
||||
if (!backward || weight < backward.weight) {
|
||||
to.neighbors.set(from.key, { to: from.key, weight, length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, nodeCount: nodes.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the nearest graph node to a point, within the snap radius.
|
||||
* Returns null when the point is too far from the network.
|
||||
*/
|
||||
export function snapToNode(
|
||||
graph: RoadGraph,
|
||||
point: MapPoint,
|
||||
snapRadiusMeters = DEFAULT_SNAP_RADIUS_METERS,
|
||||
): GraphNode | null {
|
||||
let best: GraphNode | null = null;
|
||||
let bestDistanceSq = Infinity;
|
||||
const maxSq = snapRadiusMeters * snapRadiusMeters;
|
||||
for (const node of graph.nodes.values()) {
|
||||
const d = distanceSquared(node.position, point);
|
||||
if (d < bestDistanceSq && d <= maxSq) {
|
||||
bestDistanceSq = d;
|
||||
best = node;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export type RoutingResult =
|
||||
| {
|
||||
connected: true;
|
||||
/** Polyline of the route from origin to destination, in meters on the map grid. */
|
||||
path: MapPoint[];
|
||||
/** Geometric route length in meters. */
|
||||
distanceMeters: number;
|
||||
/** Sum of weighted edge costs along the chosen path (diagnostics). */
|
||||
weightedCost: number;
|
||||
}
|
||||
| {
|
||||
connected: false;
|
||||
path: null;
|
||||
distanceMeters: null;
|
||||
weightedCost: null;
|
||||
};
|
||||
|
||||
/** Minimal binary min-heap keyed on numeric priority. */
|
||||
class MinHeap {
|
||||
private items: { priority: number; value: string }[] = [];
|
||||
|
||||
get size(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
push(priority: number, value: string): void {
|
||||
this.items.push({ priority, value });
|
||||
let i = this.items.length - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.items[parent].priority <= this.items[i].priority) break;
|
||||
[this.items[parent], this.items[i]] = [this.items[i], this.items[parent]];
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
pop(): { priority: number; value: string } | undefined {
|
||||
if (this.items.length === 0) return undefined;
|
||||
const top = this.items[0];
|
||||
const last = this.items.pop()!;
|
||||
if (this.items.length > 0) {
|
||||
this.items[0] = last;
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
const left = i * 2 + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
if (left < this.items.length && this.items[left].priority < this.items[smallest].priority) {
|
||||
smallest = left;
|
||||
}
|
||||
if (
|
||||
right < this.items.length &&
|
||||
this.items[right].priority < this.items[smallest].priority
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
if (smallest === i) break;
|
||||
[this.items[smallest], this.items[i]] = [this.items[i], this.items[smallest]];
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dijkstra shortest path between the nodes nearest to origin and destination.
|
||||
* Both ends must snap to a node within the snap radius and lie in the same
|
||||
* connected component, otherwise the route is refused.
|
||||
*/
|
||||
export function findRoute(
|
||||
graph: RoadGraph,
|
||||
origin: MapPoint,
|
||||
destination: MapPoint,
|
||||
options?: { snapRadiusMeters?: number },
|
||||
): RoutingResult {
|
||||
const snapRadius = options?.snapRadiusMeters ?? DEFAULT_SNAP_RADIUS_METERS;
|
||||
if (graph.nodes.size === 0) return { connected: false, path: null, distanceMeters: null, weightedCost: null };
|
||||
|
||||
const start = snapToNode(graph, origin, snapRadius);
|
||||
const goal = snapToNode(graph, destination, snapRadius);
|
||||
if (!start || !goal) {
|
||||
return { connected: false, path: null, distanceMeters: null, weightedCost: null };
|
||||
}
|
||||
if (start.key === goal.key) {
|
||||
return {
|
||||
connected: true,
|
||||
path: [start.position, goal.position],
|
||||
distanceMeters: pointDistance(start.position, goal.position),
|
||||
weightedCost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const dist = new Map<string, number>([[start.key, 0]]);
|
||||
const prev = new Map<string, string>();
|
||||
const settled = new Set<string>();
|
||||
const heap = new MinHeap();
|
||||
heap.push(0, start.key);
|
||||
|
||||
while (heap.size > 0) {
|
||||
const current = heap.pop()!;
|
||||
if (settled.has(current.value)) continue;
|
||||
settled.add(current.value);
|
||||
|
||||
if (current.value === goal.key) break;
|
||||
|
||||
const node = graph.nodes.get(current.value)!;
|
||||
for (const edge of node.neighbors.values()) {
|
||||
if (settled.has(edge.to)) continue;
|
||||
const candidate = dist.get(node.key)! + edge.weight;
|
||||
if (candidate < (dist.get(edge.to) ?? Infinity)) {
|
||||
dist.set(edge.to, candidate);
|
||||
prev.set(edge.to, node.key);
|
||||
heap.push(candidate, edge.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!settled.has(goal.key)) {
|
||||
return { connected: false, path: null, distanceMeters: null, weightedCost: null };
|
||||
}
|
||||
|
||||
// Reconstruct the path origin -> destination.
|
||||
const path: MapPoint[] = [];
|
||||
let cursor: string | undefined = goal.key;
|
||||
while (cursor) {
|
||||
path.unshift(graph.nodes.get(cursor)!.position);
|
||||
cursor = prev.get(cursor);
|
||||
}
|
||||
// Snap tails: the origin/destination structures are not exactly on the
|
||||
// network, prepend/append the straight stubs so the rendered route touches them.
|
||||
path.unshift(origin);
|
||||
path.push(destination);
|
||||
|
||||
const distanceMeters = polylineLength(path);
|
||||
return { connected: true, path, distanceMeters, weightedCost: dist.get(goal.key)! };
|
||||
}
|
||||
149
src/lib/map/style.ts
Normal file
149
src/lib/map/style.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Pure styling/label helpers for the map feature layers.
|
||||
* No Payload import: safe on client and server.
|
||||
*/
|
||||
|
||||
import { pointDistance, polylineLength, type MapPoint } from "./points";
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
/** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */
|
||||
export function validateHexColor(value: unknown): true | string {
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
return typeof value === "string" && HEX_COLOR.test(value)
|
||||
? true
|
||||
: "Use a hex color like #38bdf8, or leave empty for the default.";
|
||||
}
|
||||
|
||||
/** Meters per screen pixel at the given zoom for a world scaled to 256 px at zoom 0. */
|
||||
export function metersPerPx(zoom: number, worldSize: number): number {
|
||||
return worldSize / (256 * Math.pow(2, zoom));
|
||||
}
|
||||
|
||||
/** Dash array for a stroke style name. */
|
||||
export function dashArrayFor(style: string | null | undefined): string | undefined {
|
||||
if (style === "dashed") return "8 8";
|
||||
if (style === "dotted") return "2 6";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centroid of a polygon ring (area-weighted; falls back to the vertex average
|
||||
* for degenerate rings).
|
||||
*/
|
||||
export function polygonCentroid(points: MapPoint[]): MapPoint {
|
||||
if (points.length === 0) return [0, 0];
|
||||
let area = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const [xi, yi] = points[i];
|
||||
const [xj, yj] = points[j];
|
||||
const cross = xi * yj - xj * yi;
|
||||
area += cross;
|
||||
cx += (xj + xi) * cross;
|
||||
cy += (yj + yi) * cross;
|
||||
}
|
||||
area /= 2;
|
||||
if (Math.abs(area) < 1e-6) {
|
||||
const sumX = points.reduce((total, point) => total + point[0], 0);
|
||||
const sumY = points.reduce((total, point) => total + point[1], 0);
|
||||
return [sumX / points.length, sumY / points.length];
|
||||
}
|
||||
return [cx / (6 * area), cy / (6 * area)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Densifies a polyline with a Catmull-Rom spline (passing through every
|
||||
* vertex) so rendered lines curve smoothly through bends instead of kinking
|
||||
* at each vertex. Unit-agnostic: works in map meters or screen pixels alike.
|
||||
*/
|
||||
export function splinePolyline(points: MapPoint[], subdivisions = 16): MapPoint[] {
|
||||
if (points.length < 3 || subdivisions < 1) return points;
|
||||
const at = (index: number): MapPoint => points[Math.max(0, Math.min(points.length - 1, index))];
|
||||
const out: MapPoint[] = [points[0]];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = at(i - 1);
|
||||
const p1 = at(i);
|
||||
const p2 = at(i + 1);
|
||||
const p3 = at(i + 2);
|
||||
for (let s = 1; s <= subdivisions; s++) {
|
||||
const t = s / subdivisions;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
out.push([
|
||||
0.5 *
|
||||
(2 * p1[0] +
|
||||
(-p0[0] + p2[0]) * t +
|
||||
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
|
||||
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
|
||||
0.5 *
|
||||
(2 * p1[1] +
|
||||
(-p0[1] + p2[1]) * t +
|
||||
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
|
||||
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RoadLabelPlacement {
|
||||
point: MapPoint;
|
||||
/** Arc length along the input polyline at this label. */
|
||||
distance: number;
|
||||
/** True when the segment direction would render text upside down or reading downward (use a reversed path so vertical text reads bottom-to-top). */
|
||||
flipped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label placements along a road polyline: one label roughly every
|
||||
* `intervalMeters`, centered on the segment it sits on and flagged when the
|
||||
* direction would render text upside down, capped at `maxLabels`.
|
||||
*/
|
||||
export function roadLabelPoints(
|
||||
points: MapPoint[],
|
||||
intervalMeters: number,
|
||||
maxLabels: number,
|
||||
): RoadLabelPlacement[] {
|
||||
const total = polylineLength(points);
|
||||
if (points.length < 2 || total <= 0 || intervalMeters <= 0) return [];
|
||||
|
||||
const labels: RoadLabelPlacement[] = [];
|
||||
let nextAt = intervalMeters / 2;
|
||||
let traveled = 0;
|
||||
|
||||
for (let i = 1; i < points.length && labels.length < maxLabels; i++) {
|
||||
const from = points[i - 1];
|
||||
const to = points[i];
|
||||
const segment = pointDistance(from, to);
|
||||
if (segment <= 0) continue;
|
||||
const angle = segmentAngleDeg(from, to);
|
||||
|
||||
while (nextAt <= traveled + segment && labels.length < maxLabels) {
|
||||
const f = (nextAt - traveled) / segment;
|
||||
labels.push({
|
||||
point: [from[0] + (to[0] - from[0]) * f, from[1] + (to[1] - from[1]) * f],
|
||||
distance: nextAt,
|
||||
flipped: angle > 90 || angle < -90 || angle === 90,
|
||||
});
|
||||
nextAt += intervalMeters;
|
||||
}
|
||||
traveled += segment;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space direction of a segment in degrees (atan2(dy, dx)). Map y runs
|
||||
* south like the screen's y-axis, so this is the on-screen direction. Angles
|
||||
* outside [-90, 90) would render text upside down or (exactly vertical)
|
||||
* reading downward.
|
||||
*/
|
||||
function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
if (dx === 0 && dy === 0) return 0;
|
||||
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
}
|
||||
101
src/lib/map/types.ts
Normal file
101
src/lib/map/types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { MapPoint } from "./points";
|
||||
|
||||
export interface MapConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
worldSizeWidth: number;
|
||||
worldSizeHeight: number;
|
||||
basemapMode: "image" | "tiles";
|
||||
basemapImageUrl: string | null;
|
||||
tileUrlTemplate: string | null;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
defaultZoom: number;
|
||||
defaultCenter: MapPoint;
|
||||
}
|
||||
|
||||
export interface RangeRequirementFeature {
|
||||
resource: number | null;
|
||||
structure: number | null;
|
||||
range: number | null;
|
||||
}
|
||||
|
||||
export interface StructureFeature {
|
||||
id: number;
|
||||
name: string;
|
||||
point: MapPoint;
|
||||
constructionStatus: "awaiting_materials" | "building" | "complete" | null;
|
||||
constructionStartedAt: string | null;
|
||||
constructionCompletesAt: string | null;
|
||||
constructionDurationMinutes: number | null;
|
||||
storageUsedKg: number;
|
||||
storageCapacityKg: number | null;
|
||||
labelVisible: boolean | null;
|
||||
labelPriority: number | null;
|
||||
blueprintId: number | null;
|
||||
blueprintName: string | null;
|
||||
blueprintTerrain: "land" | "water" | null;
|
||||
materials: { resource: number; resourceName: string; amount: number | null }[];
|
||||
resourcesInRange: RangeRequirementFeature[];
|
||||
structuresInRange: RangeRequirementFeature[];
|
||||
factionName: string | null;
|
||||
}
|
||||
|
||||
export interface NodeFeature {
|
||||
id: number;
|
||||
name: string;
|
||||
point: MapPoint;
|
||||
description: string | null;
|
||||
resources: { id: number; name: string }[];
|
||||
richness: number | null;
|
||||
}
|
||||
|
||||
export interface RoadFeature {
|
||||
id: number;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
surface: "paved" | "dirt" | "trail";
|
||||
speedMultiplier: number;
|
||||
strokeColor: string | null;
|
||||
strokeWeight: number | null;
|
||||
strokeStyle: "solid" | "dashed" | "dotted" | null;
|
||||
labelVisible: boolean | null;
|
||||
points: MapPoint[];
|
||||
}
|
||||
|
||||
export interface ZoneFeature {
|
||||
id: number;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
type: "water" | "land";
|
||||
resources: { id: number; name: string }[];
|
||||
fillColor: string | null;
|
||||
strokeColor: string | null;
|
||||
fillOpacity: number | null;
|
||||
strokeWeight: number | null;
|
||||
labelVisible: boolean | null;
|
||||
points: MapPoint[];
|
||||
}
|
||||
|
||||
export interface ShipmentFeature {
|
||||
id: number;
|
||||
name: string | null;
|
||||
status: string;
|
||||
routePath: MapPoint[] | null;
|
||||
originName: string | null;
|
||||
originPoint: MapPoint | null;
|
||||
destinationName: string | null;
|
||||
destinationPoint: MapPoint | null;
|
||||
dispatchedAt: string | null;
|
||||
estimatedArrival: string | null;
|
||||
vehicleName: string | null;
|
||||
}
|
||||
|
||||
export interface MapFeaturesResponse {
|
||||
map: MapConfig;
|
||||
structures: StructureFeature[];
|
||||
nodes: NodeFeature[];
|
||||
roads: RoadFeature[];
|
||||
zones: ZoneFeature[];
|
||||
shipments: ShipmentFeature[];
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
/**
|
||||
* Calculates the transit time in milliseconds for a shipment.
|
||||
* @param distance - Euclidean distance in map units
|
||||
* @param distance - Route length in meters (straight-line or road route)
|
||||
* @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.)
|
||||
*/
|
||||
export function calculateTransitTime(
|
||||
distance: number,
|
||||
vehicleSpeed: number,
|
||||
): number {
|
||||
const hours = distance / vehicleSpeed;
|
||||
const km = distance / 1000;
|
||||
const hours = km / vehicleSpeed;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total fuel cost for a shipment.
|
||||
* @param distance - Euclidean distance in map units
|
||||
* @param distance - Route length in meters
|
||||
* @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate)
|
||||
*/
|
||||
export function calculateFuelCost(
|
||||
|
|
@ -50,22 +51,34 @@ export function calculateTicksForDuration(
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the effective speed of a vehicle, preferring road speed.
|
||||
* Falls back to off-road, air, or sea speed.
|
||||
* Gets the effective speed of a vehicle for its transport mode: ground
|
||||
* prefers road speed then off-road, air uses airspeed, sea uses knots.
|
||||
* Falls back through the remaining speeds and finally a 50 km/h default.
|
||||
*/
|
||||
export function getVehicleEffectiveSpeed(vehicle: {
|
||||
transportMode?: "ground" | "air" | "sea" | null;
|
||||
combat?: {
|
||||
maxSpeedOnRoad?: number | null;
|
||||
maxSpeedOffRoad?: number | null;
|
||||
maxAirspeed?: number | null;
|
||||
maxKnots?: number | null;
|
||||
} | null;
|
||||
} & {
|
||||
maxSpeedOnRoad?: number | null;
|
||||
maxSpeedOffRoad?: number | null;
|
||||
maxAirspeed?: number | null;
|
||||
maxKnots?: number | null;
|
||||
}): number {
|
||||
return (
|
||||
vehicle.combat?.maxSpeedOnRoad ??
|
||||
vehicle.combat?.maxSpeedOffRoad ??
|
||||
vehicle.combat?.maxAirspeed ??
|
||||
vehicle.combat?.maxKnots ??
|
||||
50 // default fallback speed
|
||||
);
|
||||
const combat = vehicle.combat ?? vehicle;
|
||||
const mode = vehicle.transportMode ?? "ground";
|
||||
const byMode: Record<string, (number | null | undefined)[]> = {
|
||||
ground: [combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxAirspeed, combat.maxKnots],
|
||||
air: [combat.maxAirspeed, combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxKnots],
|
||||
sea: [combat.maxKnots, combat.maxSpeedOffRoad, combat.maxSpeedOnRoad, combat.maxAirspeed],
|
||||
};
|
||||
const speeds = byMode[mode] ?? byMode.ground;
|
||||
for (const speed of speeds) {
|
||||
if (speed != null && speed > 0) return speed;
|
||||
}
|
||||
return 50; // default fallback speed
|
||||
}
|
||||
|
|
|
|||
27789
src/migrations/20260916_083158_add_game_map_system.json
Normal file
27789
src/migrations/20260916_083158_add_game_map_system.json
Normal file
File diff suppressed because it is too large
Load diff
185
src/migrations/20260916_083158_add_game_map_system.ts
Normal file
185
src/migrations/20260916_083158_add_game_map_system.ts
Normal file
File diff suppressed because one or more lines are too long
27794
src/migrations/20260916_185544_construction_timestamp_model.json
Normal file
27794
src/migrations/20260916_185544_construction_timestamp_model.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,41 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "structures" RENAME COLUMN "construction_time" TO "construction_duration_minutes";
|
||||
ALTER TABLE "game_structures" ADD COLUMN "construction_started_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "construction_completes_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "game_structures" DROP COLUMN "construction_progress";`)
|
||||
|
||||
// Convert legacy tick durations to minutes using the configured tick interval
|
||||
// (default 10 when the game rules row is missing).
|
||||
await db.execute(sql`
|
||||
UPDATE "structures"
|
||||
SET "construction_duration_minutes" = "construction_duration_minutes" * COALESCE(
|
||||
(SELECT "game_tick_interval_minutes" FROM "game_rules" LIMIT 1),
|
||||
10
|
||||
);`)
|
||||
|
||||
// Legacy building sites have no deadline; let them finish on the next sweep
|
||||
// instead of stalling forever.
|
||||
await db.execute(sql`
|
||||
UPDATE "game_structures"
|
||||
SET "construction_completes_at" = NOW()
|
||||
WHERE "construction_status" = 'building' AND "construction_completes_at" IS NULL;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "structures" RENAME COLUMN "construction_duration_minutes" TO "construction_time";
|
||||
ALTER TABLE "game_structures" ADD COLUMN "construction_progress" numeric DEFAULT 0;
|
||||
ALTER TABLE "game_structures" DROP COLUMN "construction_started_at";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "construction_completes_at";`)
|
||||
|
||||
// Best-effort inverse of the tick-to-minutes conversion.
|
||||
await db.execute(sql`
|
||||
UPDATE "structures"
|
||||
SET "construction_time" = "construction_time" / COALESCE(
|
||||
(SELECT "game_tick_interval_minutes" FROM "game_rules" LIMIT 1),
|
||||
10
|
||||
);`)
|
||||
}
|
||||
27895
src/migrations/20260916_220224_add_map_labels_and_styling.json
Normal file
27895
src/migrations/20260916_220224_add_map_labels_and_styling.json
Normal file
File diff suppressed because it is too large
Load diff
39
src/migrations/20260916_220224_add_map_labels_and_styling.ts
Normal file
39
src/migrations/20260916_220224_add_map_labels_and_styling.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_map_roads_stroke_style" AS ENUM('solid', 'dashed', 'dotted');
|
||||
ALTER TABLE "game_structures" ADD COLUMN "label_visible" boolean DEFAULT true;
|
||||
ALTER TABLE "game_structures" ADD COLUMN "label_priority" numeric DEFAULT 0;
|
||||
ALTER TABLE "map_roads" ADD COLUMN "description" varchar;
|
||||
ALTER TABLE "map_roads" ADD COLUMN "stroke_color" varchar;
|
||||
ALTER TABLE "map_roads" ADD COLUMN "stroke_weight" numeric DEFAULT 2;
|
||||
ALTER TABLE "map_roads" ADD COLUMN "stroke_style" "enum_map_roads_stroke_style" DEFAULT 'solid';
|
||||
ALTER TABLE "map_roads" ADD COLUMN "label_visible" boolean DEFAULT true;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "description" varchar;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "fill_color" varchar;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "stroke_color" varchar;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "fill_opacity" numeric;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "stroke_weight" numeric DEFAULT 1;
|
||||
ALTER TABLE "map_zones" ADD COLUMN "label_visible" boolean DEFAULT true;
|
||||
ALTER TABLE "resource_nodes" ADD COLUMN "description" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_structures" DROP COLUMN "label_visible";
|
||||
ALTER TABLE "game_structures" DROP COLUMN "label_priority";
|
||||
ALTER TABLE "map_roads" DROP COLUMN "description";
|
||||
ALTER TABLE "map_roads" DROP COLUMN "stroke_color";
|
||||
ALTER TABLE "map_roads" DROP COLUMN "stroke_weight";
|
||||
ALTER TABLE "map_roads" DROP COLUMN "stroke_style";
|
||||
ALTER TABLE "map_roads" DROP COLUMN "label_visible";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "description";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "fill_color";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "stroke_color";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "fill_opacity";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "stroke_weight";
|
||||
ALTER TABLE "map_zones" DROP COLUMN "label_visible";
|
||||
ALTER TABLE "resource_nodes" DROP COLUMN "description";
|
||||
DROP TYPE "public"."enum_map_roads_stroke_style";`)
|
||||
}
|
||||
28123
src/migrations/20260917_023028_add_node_and_zone_resources.json
Normal file
28123
src/migrations/20260917_023028_add_node_and_zone_resources.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,52 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "map_zones_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"resources_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "resource_nodes_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"resources_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "resource_nodes" DROP CONSTRAINT "resource_nodes_resource_id_resources_id_fk";
|
||||
|
||||
DROP INDEX "resource_nodes_resource_idx";
|
||||
ALTER TABLE "map_zones_rels" ADD CONSTRAINT "map_zones_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."map_zones"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "map_zones_rels" ADD CONSTRAINT "map_zones_rels_resources_fk" FOREIGN KEY ("resources_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "resource_nodes_rels" ADD CONSTRAINT "resource_nodes_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."resource_nodes"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "resource_nodes_rels" ADD CONSTRAINT "resource_nodes_rels_resources_fk" FOREIGN KEY ("resources_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "map_zones_rels_order_idx" ON "map_zones_rels" USING btree ("order");
|
||||
CREATE INDEX "map_zones_rels_parent_idx" ON "map_zones_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "map_zones_rels_path_idx" ON "map_zones_rels" USING btree ("path");
|
||||
CREATE INDEX "map_zones_rels_resources_id_idx" ON "map_zones_rels" USING btree ("resources_id");
|
||||
CREATE INDEX "resource_nodes_rels_order_idx" ON "resource_nodes_rels" USING btree ("order");
|
||||
CREATE INDEX "resource_nodes_rels_parent_idx" ON "resource_nodes_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "resource_nodes_rels_path_idx" ON "resource_nodes_rels" USING btree ("path");
|
||||
CREATE INDEX "resource_nodes_rels_resources_id_idx" ON "resource_nodes_rels" USING btree ("resources_id");
|
||||
-- Carry existing single-resource nodes into the new hasMany rel table before
|
||||
-- the old column goes away.
|
||||
INSERT INTO "resource_nodes_rels" ("order", "parent_id", "path", "resources_id")
|
||||
SELECT 0, "id", 'resources', "resource_id" FROM "resource_nodes" WHERE "resource_id" IS NOT NULL;
|
||||
ALTER TABLE "resource_nodes" DROP COLUMN "resource_id";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "map_zones_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "resource_nodes_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "map_zones_rels" CASCADE;
|
||||
DROP TABLE "resource_nodes_rels" CASCADE;
|
||||
ALTER TABLE "resource_nodes" ADD COLUMN "resource_id" integer NOT NULL;
|
||||
ALTER TABLE "resource_nodes" ADD CONSTRAINT "resource_nodes_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "resource_nodes_resource_idx" ON "resource_nodes" USING btree ("resource_id");`)
|
||||
}
|
||||
|
|
@ -60,6 +60,10 @@ import * as migration_20260912_071740_add_blackjack_minigame_stats from './20260
|
|||
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
|
||||
import * as migration_20260914_153046_add_chip_bank from './20260914_153046_add_chip_bank';
|
||||
import * as migration_20260914_171412_add_respawn_tickets from './20260914_171412_add_respawn_tickets';
|
||||
import * as migration_20260916_083158_add_game_map_system from './20260916_083158_add_game_map_system';
|
||||
import * as migration_20260916_185544_construction_timestamp_model from './20260916_185544_construction_timestamp_model';
|
||||
import * as migration_20260916_220224_add_map_labels_and_styling from './20260916_220224_add_map_labels_and_styling';
|
||||
import * as migration_20260917_023028_add_node_and_zone_resources from './20260917_023028_add_node_and_zone_resources';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -370,6 +374,26 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260914_171412_add_respawn_tickets.up,
|
||||
down: migration_20260914_171412_add_respawn_tickets.down,
|
||||
name: '20260914_171412_add_respawn_tickets'
|
||||
name: '20260914_171412_add_respawn_tickets',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_083158_add_game_map_system.up,
|
||||
down: migration_20260916_083158_add_game_map_system.down,
|
||||
name: '20260916_083158_add_game_map_system',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_185544_construction_timestamp_model.up,
|
||||
down: migration_20260916_185544_construction_timestamp_model.down,
|
||||
name: '20260916_185544_construction_timestamp_model',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_220224_add_map_labels_and_styling.up,
|
||||
down: migration_20260916_220224_add_map_labels_and_styling.down,
|
||||
name: '20260916_220224_add_map_labels_and_styling',
|
||||
},
|
||||
{
|
||||
up: migration_20260917_023028_add_node_and_zone_resources.up,
|
||||
down: migration_20260917_023028_add_node_and_zone_resources.down,
|
||||
name: '20260917_023028_add_node_and_zone_resources'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -118,6 +118,9 @@ export interface Config {
|
|||
releases: Release;
|
||||
labels: Label;
|
||||
maps: Map;
|
||||
'map-roads': MapRoad;
|
||||
'map-zones': MapZone;
|
||||
'resource-nodes': ResourceNode;
|
||||
'narrative-events': NarrativeEvent;
|
||||
'story-beats': StoryBeat;
|
||||
'story-beat-states': StoryBeatState;
|
||||
|
|
@ -197,6 +200,9 @@ export interface Config {
|
|||
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
||||
labels: LabelsSelect<false> | LabelsSelect<true>;
|
||||
maps: MapsSelect<false> | MapsSelect<true>;
|
||||
'map-roads': MapRoadsSelect<false> | MapRoadsSelect<true>;
|
||||
'map-zones': MapZonesSelect<false> | MapZonesSelect<true>;
|
||||
'resource-nodes': ResourceNodesSelect<false> | ResourceNodesSelect<true>;
|
||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
||||
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
||||
|
|
@ -338,6 +344,22 @@ export interface GameStructure {
|
|||
* The faction that owns this structure.
|
||||
*/
|
||||
faction?: (number | null) | Faction;
|
||||
/**
|
||||
* The game map this structure sits on.
|
||||
*/
|
||||
map?: (number | null) | Map;
|
||||
/**
|
||||
* Placed sites start awaiting materials. Once all blueprint materials are delivered construction starts immediately (timestamps below); the game tick flips past-deadline building sites to complete.
|
||||
*/
|
||||
constructionStatus?: ('awaiting_materials' | 'building' | 'complete') | null;
|
||||
/**
|
||||
* When construction began (materials consumed). Empty while awaiting materials.
|
||||
*/
|
||||
constructionStartedAt?: string | null;
|
||||
/**
|
||||
* The exact deadline at which the site becomes operational.
|
||||
*/
|
||||
constructionCompletesAt?: string | null;
|
||||
/**
|
||||
* Funds charged per base tick for structure upkeep, separate from staff salaries.
|
||||
*/
|
||||
|
|
@ -381,10 +403,20 @@ export interface GameStructure {
|
|||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Map meters [x, y] on the structure's game map (not geographic degrees).
|
||||
*
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
*/
|
||||
coordinates?: [number, number] | null;
|
||||
/**
|
||||
* Renders the structure's name on the map. When several structures cluster together, the highest-priority label wins until you zoom in.
|
||||
*/
|
||||
labelVisible?: boolean | null;
|
||||
/**
|
||||
* Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.
|
||||
*/
|
||||
labelPriority?: number | null;
|
||||
storedResources?:
|
||||
| {
|
||||
resource: number | Resource;
|
||||
|
|
@ -466,7 +498,10 @@ export interface Structure {
|
|||
amount?: number | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
constructionTime: number;
|
||||
/**
|
||||
* How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.
|
||||
*/
|
||||
constructionDurationMinutes: number;
|
||||
/**
|
||||
* What research must be done before this can be built.
|
||||
*/
|
||||
|
|
@ -1165,6 +1200,10 @@ export interface Vehicle {
|
|||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Ground vehicles route over the road network and refuse shipments with no connected route. Air and sea vehicles travel straight-line.
|
||||
*/
|
||||
transportMode: 'ground' | 'air' | 'sea';
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
maxMass?: number | null;
|
||||
|
|
@ -1676,6 +1715,12 @@ export interface Role {
|
|||
| 'maps:update'
|
||||
| 'maps:delete'
|
||||
| 'admin:maps:manage'
|
||||
| 'map-roads:read'
|
||||
| 'admin:map-roads:manage'
|
||||
| 'map-zones:read'
|
||||
| 'admin:map-zones:manage'
|
||||
| 'resource-nodes:read'
|
||||
| 'admin:resource-nodes:manage'
|
||||
| 'narrative-events:create'
|
||||
| 'narrative-events:read'
|
||||
| 'narrative-events:update'
|
||||
|
|
@ -1740,6 +1785,51 @@ export interface Role {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "maps".
|
||||
*/
|
||||
export interface Map {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* Inactive maps are hidden from the map page and cannot receive placements.
|
||||
*/
|
||||
isActive?: boolean | null;
|
||||
/**
|
||||
* Map width in real meters (e.g. Altis is 8192).
|
||||
*/
|
||||
worldSizeWidth: number;
|
||||
/**
|
||||
* Map height in real meters (e.g. Altis is 8192).
|
||||
*/
|
||||
worldSizeHeight: number;
|
||||
/**
|
||||
* Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.
|
||||
*/
|
||||
basemapMode: 'image' | 'tiles';
|
||||
/**
|
||||
* Top-down map image. Rendered stretched across the full world size.
|
||||
*/
|
||||
basemapImage?: (number | null) | Media;
|
||||
/**
|
||||
* XYZ template, e.g. https://tiles.example.com/{z}/{x}/{y}.png
|
||||
*/
|
||||
tileUrlTemplate?: string | null;
|
||||
minZoom?: number | null;
|
||||
maxZoom?: number | null;
|
||||
/**
|
||||
* Defaults to half the world width when empty.
|
||||
*/
|
||||
defaultCenterX?: number | null;
|
||||
/**
|
||||
* Defaults to half the world height when empty.
|
||||
*/
|
||||
defaultCenterY?: number | null;
|
||||
defaultZoom?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "game-vehicles".
|
||||
|
|
@ -2691,17 +2781,6 @@ export interface Mission {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "maps".
|
||||
*/
|
||||
export interface Map {
|
||||
id: number;
|
||||
name: string;
|
||||
fileName?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "mission-files".
|
||||
|
|
@ -2823,6 +2902,22 @@ export interface Shipment {
|
|||
name?: string | null;
|
||||
origin: number | GameStructure;
|
||||
destination: number | GameStructure;
|
||||
/**
|
||||
* The game map this shipment travels on. Cross-map shipments are not supported.
|
||||
*/
|
||||
map?: (number | null) | Map;
|
||||
/**
|
||||
* Snapshot polyline of the route as [x, y] meter tuples, for map rendering.
|
||||
*/
|
||||
routePath?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* The vehicle instance transporting this shipment.
|
||||
*/
|
||||
|
|
@ -3666,6 +3761,150 @@ export interface WikiTemplate {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "map-roads".
|
||||
*/
|
||||
export interface MapRoad {
|
||||
id: number;
|
||||
/**
|
||||
* Road or route label (e.g. "Highway 1", "Kavala ridge trail").
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Shown in the road's tooltip and popup on the map.
|
||||
*/
|
||||
description?: string | null;
|
||||
map: number | Map;
|
||||
surface: 'paved' | 'dirt' | 'trail';
|
||||
/**
|
||||
* Routing weight multiplier. Higher = faster/preferred (1 = neutral). Affects which route Dijkstra picks; fuel is charged on geometric length.
|
||||
*/
|
||||
speedMultiplier: number;
|
||||
/**
|
||||
* Line color as hex (e.g. #cbd5e1). Empty = default color for the surface.
|
||||
*/
|
||||
strokeColor?: string | null;
|
||||
/**
|
||||
* Line thickness in pixels.
|
||||
*/
|
||||
strokeWeight?: number | null;
|
||||
strokeStyle?: ('solid' | 'dashed' | 'dotted') | null;
|
||||
/**
|
||||
* Renders the road's name along the line at zoom-dependent intervals.
|
||||
*/
|
||||
labelVisible?: boolean | null;
|
||||
/**
|
||||
* Array of [x, y] meter tuples in map order.
|
||||
*/
|
||||
points:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "map-zones".
|
||||
*/
|
||||
export interface MapZone {
|
||||
id: number;
|
||||
/**
|
||||
* Zone label (e.g. "Altis bay", "Flood plain").
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Shown in the zone's tooltip and popup on the map.
|
||||
*/
|
||||
description?: string | null;
|
||||
map: number | Map;
|
||||
/**
|
||||
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.
|
||||
*/
|
||||
type: 'water' | 'land';
|
||||
/**
|
||||
* Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.
|
||||
*/
|
||||
fillColor?: string | null;
|
||||
/**
|
||||
* Outline color as hex. Empty = default color for the type.
|
||||
*/
|
||||
strokeColor?: string | null;
|
||||
/**
|
||||
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).
|
||||
*/
|
||||
fillOpacity?: number | null;
|
||||
/**
|
||||
* Outline thickness in pixels. 0 hides the outline.
|
||||
*/
|
||||
strokeWeight?: number | null;
|
||||
/**
|
||||
* Resources contained in this zone (e.g. an oil field). A zone with resources satisfies blueprint resourcesInRange requirements for any site inside it.
|
||||
*/
|
||||
resources?: (number | Resource)[] | null;
|
||||
/**
|
||||
* Renders the zone's name at the polygon's center.
|
||||
*/
|
||||
labelVisible?: boolean | null;
|
||||
/**
|
||||
* Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).
|
||||
*/
|
||||
points:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "resource-nodes".
|
||||
*/
|
||||
export interface ResourceNode {
|
||||
id: number;
|
||||
/**
|
||||
* Node label (e.g. "North copper deposit").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Shown in the node's tooltip and popup on the map.
|
||||
*/
|
||||
description?: string | null;
|
||||
map: number | Map;
|
||||
/**
|
||||
* Every resource this node yields (e.g. copper AND oil).
|
||||
*/
|
||||
resources: (number | Resource)[];
|
||||
/**
|
||||
* [x, y] tuple in meters on the map grid.
|
||||
*/
|
||||
position:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Display-only abundance rating for the map UI.
|
||||
*/
|
||||
richness?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "narrative-events".
|
||||
|
|
@ -4927,6 +5166,60 @@ export interface PayloadMcpApiKey {
|
|||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
mapRoads?: {
|
||||
/**
|
||||
* Allow clients to find map-roads.
|
||||
*/
|
||||
find?: boolean | null;
|
||||
/**
|
||||
* Allow clients to create map-roads.
|
||||
*/
|
||||
create?: boolean | null;
|
||||
/**
|
||||
* Allow clients to update map-roads.
|
||||
*/
|
||||
update?: boolean | null;
|
||||
/**
|
||||
* Allow clients to delete map-roads.
|
||||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
mapZones?: {
|
||||
/**
|
||||
* Allow clients to find map-zones.
|
||||
*/
|
||||
find?: boolean | null;
|
||||
/**
|
||||
* Allow clients to create map-zones.
|
||||
*/
|
||||
create?: boolean | null;
|
||||
/**
|
||||
* Allow clients to update map-zones.
|
||||
*/
|
||||
update?: boolean | null;
|
||||
/**
|
||||
* Allow clients to delete map-zones.
|
||||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
resourceNodes?: {
|
||||
/**
|
||||
* Allow clients to find resource-nodes.
|
||||
*/
|
||||
find?: boolean | null;
|
||||
/**
|
||||
* Allow clients to create resource-nodes.
|
||||
*/
|
||||
create?: boolean | null;
|
||||
/**
|
||||
* Allow clients to update resource-nodes.
|
||||
*/
|
||||
update?: boolean | null;
|
||||
/**
|
||||
* Allow clients to delete resource-nodes.
|
||||
*/
|
||||
delete?: boolean | null;
|
||||
};
|
||||
narrativeEvents?: {
|
||||
/**
|
||||
* Allow clients to find narrative-events.
|
||||
|
|
@ -5392,6 +5685,18 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'maps';
|
||||
value: number | Map;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'map-roads';
|
||||
value: number | MapRoad;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'map-zones';
|
||||
value: number | MapZone;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'resource-nodes';
|
||||
value: number | ResourceNode;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'narrative-events';
|
||||
value: number | NarrativeEvent;
|
||||
|
|
@ -5541,6 +5846,10 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
|||
name?: T;
|
||||
type?: T;
|
||||
faction?: T;
|
||||
map?: T;
|
||||
constructionStatus?: T;
|
||||
constructionStartedAt?: T;
|
||||
constructionCompletesAt?: T;
|
||||
maintenanceCost?: T;
|
||||
laborRequirements?:
|
||||
| T
|
||||
|
|
@ -5560,6 +5869,8 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
|||
id?: T;
|
||||
};
|
||||
coordinates?: T;
|
||||
labelVisible?: T;
|
||||
labelPriority?: T;
|
||||
storedResources?:
|
||||
| T
|
||||
| {
|
||||
|
|
@ -6489,6 +6800,7 @@ export interface VehiclesSelect<T extends boolean = true> {
|
|||
amount?: T;
|
||||
id?: T;
|
||||
};
|
||||
transportMode?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
maxMass?: T;
|
||||
|
|
@ -6536,7 +6848,7 @@ export interface StructuresSelect<T extends boolean = true> {
|
|||
amount?: T;
|
||||
id?: T;
|
||||
};
|
||||
constructionTime?: T;
|
||||
constructionDurationMinutes?: T;
|
||||
requiredTech?: T;
|
||||
requiresUpgrade?: T;
|
||||
upgradesInto?: T;
|
||||
|
|
@ -6625,6 +6937,8 @@ export interface ShipmentsSelect<T extends boolean = true> {
|
|||
name?: T;
|
||||
origin?: T;
|
||||
destination?: T;
|
||||
map?: T;
|
||||
routePath?: T;
|
||||
transportVehicle?: T;
|
||||
distance?: T;
|
||||
cargo?:
|
||||
|
|
@ -7018,7 +7332,68 @@ export interface LabelsSelect<T extends boolean = true> {
|
|||
*/
|
||||
export interface MapsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
fileName?: T;
|
||||
isActive?: T;
|
||||
worldSizeWidth?: T;
|
||||
worldSizeHeight?: T;
|
||||
basemapMode?: T;
|
||||
basemapImage?: T;
|
||||
tileUrlTemplate?: T;
|
||||
minZoom?: T;
|
||||
maxZoom?: T;
|
||||
defaultCenterX?: T;
|
||||
defaultCenterY?: T;
|
||||
defaultZoom?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "map-roads_select".
|
||||
*/
|
||||
export interface MapRoadsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
description?: T;
|
||||
map?: T;
|
||||
surface?: T;
|
||||
speedMultiplier?: T;
|
||||
strokeColor?: T;
|
||||
strokeWeight?: T;
|
||||
strokeStyle?: T;
|
||||
labelVisible?: T;
|
||||
points?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "map-zones_select".
|
||||
*/
|
||||
export interface MapZonesSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
description?: T;
|
||||
map?: T;
|
||||
type?: T;
|
||||
fillColor?: T;
|
||||
strokeColor?: T;
|
||||
fillOpacity?: T;
|
||||
strokeWeight?: T;
|
||||
resources?: T;
|
||||
labelVisible?: T;
|
||||
points?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "resource-nodes_select".
|
||||
*/
|
||||
export interface ResourceNodesSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
description?: T;
|
||||
map?: T;
|
||||
resources?: T;
|
||||
position?: T;
|
||||
richness?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
@ -7653,6 +8028,30 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
|||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
mapRoads?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
mapZones?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
resourceNodes?:
|
||||
| T
|
||||
| {
|
||||
find?: T;
|
||||
create?: T;
|
||||
update?: T;
|
||||
delete?: T;
|
||||
};
|
||||
narrativeEvents?:
|
||||
| T
|
||||
| {
|
||||
|
|
@ -7842,7 +8241,7 @@ export interface GameRule {
|
|||
*/
|
||||
intelAssignments?: (number | Assignment)[] | null;
|
||||
/**
|
||||
* Distance threshold for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.
|
||||
* Distance threshold in real meters for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.
|
||||
*/
|
||||
proximityThreshold?: number | null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import { Profiles } from "@/collections/users/Profiles";
|
|||
import { Missions } from "@/collections/intelligence/Missions";
|
||||
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
||||
import { Maps } from "@/collections/world/Maps";
|
||||
import { MapRoads } from "@/collections/world/MapRoads";
|
||||
import { MapZones } from "@/collections/world/MapZones";
|
||||
import { ResourceNodes } from "@/collections/world/ResourceNodes";
|
||||
import { Campaigns } from "@/collections/intelligence/Campaigns";
|
||||
import { Qualifications } from "@/collections/users/Qualifications";
|
||||
import { MissionFiles } from "@/collections/server/MissionFiles";
|
||||
|
|
@ -289,6 +292,9 @@ const collections = [
|
|||
|
||||
// World
|
||||
Maps,
|
||||
MapRoads,
|
||||
MapZones,
|
||||
ResourceNodes,
|
||||
NarrativeEvents,
|
||||
|
||||
// Story
|
||||
|
|
|
|||
|
|
@ -483,6 +483,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
{ value: "admin:maps:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Map Features",
|
||||
permissions: [
|
||||
{ value: "map-roads:read", label: "Read Roads" },
|
||||
{ value: "admin:map-roads:manage", label: "Manage Roads Admin Page" },
|
||||
{ value: "map-zones:read", label: "Read Zones" },
|
||||
{ value: "admin:map-zones:manage", label: "Manage Zones Admin Page" },
|
||||
{ value: "resource-nodes:read", label: "Read Resource Nodes" },
|
||||
{ value: "admin:resource-nodes:manage", label: "Manage Resource Nodes Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Narrative Events",
|
||||
permissions: [
|
||||
|
|
@ -825,6 +836,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
|||
"maps:update",
|
||||
"maps:delete",
|
||||
"admin:maps:manage",
|
||||
"map-roads:read",
|
||||
"admin:map-roads:manage",
|
||||
"map-zones:read",
|
||||
"admin:map-zones:manage",
|
||||
"resource-nodes:read",
|
||||
"admin:resource-nodes:manage",
|
||||
"narrative-events:create",
|
||||
"narrative-events:read",
|
||||
"narrative-events:update",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { SanitizedConfig } from "payload";
|
|||
import { getPayload } from "payload";
|
||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import { processShipmentTick } from "./processShipmentTick";
|
||||
import { processConstructionTick } from "@/lib/map/construction";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
|
@ -40,12 +41,15 @@ export const script = async (config: SanitizedConfig) => {
|
|||
}
|
||||
}
|
||||
|
||||
const construction = await processConstructionTick(payload, log);
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.GameTick,
|
||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||
data: {
|
||||
processedCount,
|
||||
totalActive: activeShipments.docs.length,
|
||||
construction,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { emitGameEvent } from "@/utils/event-log/emit";
|
|||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
||||
|
||||
type ShipmentDoc = Shipment & {
|
||||
|
|
@ -75,7 +77,7 @@ export async function processShipmentTick(
|
|||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||
|
||||
if (now >= estimatedArrival) {
|
||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
||||
await completeShipment(payload, shipment, newFuelConsumed, now, log);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +95,7 @@ async function completeShipment(
|
|||
payload: Payload,
|
||||
shipment: ShipmentDoc,
|
||||
finalFuelConsumed: number,
|
||||
now: Date,
|
||||
log: Pick<BinLogger, "error">,
|
||||
): Promise<void> {
|
||||
const vehicle = shipment.transportVehicle;
|
||||
|
|
@ -273,6 +276,15 @@ async function completeShipment(
|
|||
data: { vehicleId: vehicle.id, originId, destinationId },
|
||||
});
|
||||
|
||||
// Fire-and-forget: a failed construction start must not fail the completed shipment.
|
||||
try {
|
||||
await maybeStartConstruction(payload, destinationId, now);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Construction start failed at destination "${destinationId}": ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Notify the user who created the shipment (the closest thing to a recipient/owner).
|
||||
const creatorId =
|
||||
typeof shipment.createdBy === "object" && shipment.createdBy
|
||||
|
|
@ -314,6 +326,10 @@ async function createAutoReturnShipment(
|
|||
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
||||
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
||||
|
||||
const originalRoutePath = isPointArray(originalShipment.routePath)
|
||||
? ([...originalShipment.routePath] as MapPoint[]).reverse()
|
||||
: null;
|
||||
|
||||
const returnFuelCost = Math.ceil(originalShipment.distance * rate);
|
||||
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
|
@ -346,6 +362,11 @@ async function createAutoReturnShipment(
|
|||
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
||||
origin: destinationId,
|
||||
destination: originId,
|
||||
map:
|
||||
typeof originalShipment.map === "object"
|
||||
? (originalShipment.map?.id ?? null)
|
||||
: (originalShipment.map ?? null),
|
||||
routePath: originalRoutePath,
|
||||
transportVehicle: vehicle.id,
|
||||
distance: originalShipment.distance,
|
||||
cargo: [],
|
||||
|
|
|
|||
|
|
@ -227,6 +227,26 @@ export function requireCampaignOwnership(permission: Permission) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user can access the Payload admin panel.
|
||||
*
|
||||
* A user has admin panel access if they are a superuser OR hold any
|
||||
* `admin:<slug>:manage` permission (which is what `requireAdminPageAccess`
|
||||
* requires for any collection).
|
||||
*/
|
||||
export async function canAccessAdminPanel(
|
||||
payload: Payload,
|
||||
user: UserLike | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
const { permissions, isSuperuser } = await loadUserPermissions(payload, user);
|
||||
if (isSuperuser) return true;
|
||||
for (const p of permissions) {
|
||||
if (p.startsWith("admin:") && p.endsWith(":manage")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ids of every user holding a superuser role.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const EventTypes = {
|
|||
|
||||
// Structure
|
||||
StructureResize: "structure:resize",
|
||||
StructurePlaced: "structure:placed",
|
||||
StructureConstructionComplete: "structure:construction-complete",
|
||||
StructureUpgraded: "structure:upgraded",
|
||||
UpkeepShort: "structure:upkeep-short",
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ describe("Leadership evaluations", () => {
|
|||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
|
|
|
|||
1403
tests/int/map-system.int.spec.ts
Normal file
1403
tests/int/map-system.int.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -25,7 +25,7 @@ describe("Mission auto-complete", () => {
|
|||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe("Mission feedback reminder", () => {
|
|||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type PokerTableState,
|
||||
} from "@/lib/minigames/poker";
|
||||
import type { Card } from "@/lib/minigames/poker";
|
||||
import { applyCardPick } from "@/components/frontend/minigames/poker/PokerDevPanel";
|
||||
import {
|
||||
adjustStack,
|
||||
createTable,
|
||||
|
|
@ -148,3 +149,15 @@ describe("dev stack adjustment", () => {
|
|||
expect(() => adjustStack(tableId, 9611, 9, 100)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dev panel card picker", () => {
|
||||
it("builds a complete card from a first rank pick and preserves the chosen suit", () => {
|
||||
expect(applyCardPick(null, "rank", "13")).toEqual(card(13, "s"));
|
||||
expect(applyCardPick(card(13, "h"), "rank", "14")).toEqual(card(14, "h"));
|
||||
});
|
||||
|
||||
it("builds a complete card from a first suit pick and preserves the chosen rank", () => {
|
||||
expect(applyCardPick(null, "suit", "c")).toEqual(card(2, "c"));
|
||||
expect(applyCardPick(card(14, "h"), "suit", "d")).toEqual(card(14, "d"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe("Respawn ticket settlement", () => {
|
|||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
|
|
|
|||
Loading…
Reference in a new issue