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]`).
|
- **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`.
|
- **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 (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`).
|
- `/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:
|
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.
|
- **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
|
## Environment
|
||||||
|
|
|
||||||
13
bun.lock
13
bun.lock
|
|
@ -39,6 +39,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -48,6 +49,7 @@
|
||||||
"discord.js": "^14.27.0",
|
"discord.js": "^14.27.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lexical": "0.41.0",
|
"lexical": "0.41.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.2.11",
|
"next": "16.2.11",
|
||||||
|
|
@ -59,6 +61,7 @@
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-directive": "4.0.0",
|
"remark-directive": "4.0.0",
|
||||||
|
|
@ -737,6 +740,8 @@
|
||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
"@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/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=="],
|
"@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/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/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/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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",
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.21",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -72,6 +73,7 @@
|
||||||
"discord.js": "^14.27.0",
|
"discord.js": "^14.27.0",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lexical": "0.41.0",
|
"lexical": "0.41.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.2.11",
|
"next": "16.2.11",
|
||||||
|
|
@ -83,6 +85,7 @@
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-directive": "4.0.0",
|
"remark-directive": "4.0.0",
|
||||||
|
|
|
||||||
|
|
@ -445,7 +445,12 @@ async function resolveSideOpMap(
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
const created = await payload.create({
|
const created = await payload.create({
|
||||||
collection: "maps",
|
collection: "maps",
|
||||||
data: { name: customName },
|
data: {
|
||||||
|
name: customName,
|
||||||
|
worldSizeWidth: 8192,
|
||||||
|
worldSizeHeight: 8192,
|
||||||
|
basemapMode: "image",
|
||||||
|
},
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
return created.id;
|
return created.id;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Metadata } from "next";
|
||||||
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import { canAccessAdminPanel } from "@/utils/access-control/hasPermission";
|
||||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||||
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
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 shimsGlobal = await payload.findGlobal({ slug: "shims" });
|
||||||
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||||
|
|
||||||
|
let isAdmin = false;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
|
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -121,6 +124,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||||
|
|
||||||
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
||||||
|
|
||||||
|
isAdmin = await canAccessAdminPanel(payload, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -141,7 +146,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
rank: currentRank,
|
rank: currentRank,
|
||||||
}}
|
}}
|
||||||
xpLevel={xpLevel}
|
xpLevel={xpLevel}
|
||||||
adminUrl={payloadConfig.routes.admin}
|
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
||||||
hasIntelligence={isIntelligence}
|
hasIntelligence={isIntelligence}
|
||||||
hasLogistics={isLogistics}
|
hasLogistics={isLogistics}
|
||||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
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 { calculateDistance } from "@/lib/distance";
|
||||||
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
||||||
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
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
|
// Fetch vehicle
|
||||||
const vehicle = (await payload
|
const vehicle = (await payload
|
||||||
.findByID({
|
.findByID({
|
||||||
|
|
@ -146,6 +134,69 @@ export async function createShipment(params: {
|
||||||
return { success: false, error: "Vehicle type not found." };
|
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
|
// Calculate fuel cost
|
||||||
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
||||||
const fuelCost = calculateFuelCost(distance, consumptionRate);
|
const fuelCost = calculateFuelCost(distance, consumptionRate);
|
||||||
|
|
@ -283,9 +334,7 @@ export async function createShipment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate transit time
|
// Calculate transit time
|
||||||
const speed = getVehicleEffectiveSpeed(
|
const speed = getVehicleEffectiveSpeed(vehicleType);
|
||||||
vehicleType as unknown as Parameters<typeof getVehicleEffectiveSpeed>[0],
|
|
||||||
);
|
|
||||||
const transitTimeMs = calculateTransitTime(distance, speed);
|
const transitTimeMs = calculateTransitTime(distance, speed);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
|
@ -332,6 +381,8 @@ export async function createShipment(params: {
|
||||||
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
|
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
|
||||||
origin: originId,
|
origin: originId,
|
||||||
destination: destinationId,
|
destination: destinationId,
|
||||||
|
map: originMapId,
|
||||||
|
routePath,
|
||||||
transportVehicle: vehicleId,
|
transportVehicle: vehicleId,
|
||||||
distance: Math.round(distance * 100) / 100,
|
distance: Math.round(distance * 100) / 100,
|
||||||
cargo: cargo.map((c) => ({
|
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 { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
||||||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
||||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
||||||
|
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||||
import {
|
import {
|
||||||
calculateStoredMass,
|
calculateStoredMass,
|
||||||
calculateUtilization,
|
calculateUtilization,
|
||||||
|
|
@ -248,6 +249,30 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
return sum + (resource.baseValue ?? 0) * entry.amount;
|
return sum + (resource.baseValue ?? 0) * entry.amount;
|
||||||
}, 0);
|
}, 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 (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||||
<header
|
<header
|
||||||
|
|
@ -411,6 +436,14 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
|
|
||||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
<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
|
<StaffRoster
|
||||||
structure={structureStaffing}
|
structure={structureStaffing}
|
||||||
staff={staff}
|
staff={staff}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -278,6 +279,13 @@ async function addResourceInternal(
|
||||||
data: { resourceId, amount, resourceName: resource.name },
|
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 };
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
@ -445,14 +453,14 @@ export async function transferResource(
|
||||||
|
|
||||||
if (fromCoords && toCoords) {
|
if (fromCoords && toCoords) {
|
||||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
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 { calculateDistance } = await import("@/lib/distance");
|
||||||
const distance = calculateDistance(fromCoords, toCoords);
|
const distance = calculateDistance(fromCoords, toCoords);
|
||||||
|
|
||||||
if (distance > threshold) {
|
if (distance > threshold) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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;
|
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,
|
# GameVehicles, GameNpcs, GameHardResources, GameEventLogs,
|
||||||
# NpcStaffing, LaborClassifications (+ shared staffingFields.ts factory)
|
# NpcStaffing, LaborClassifications (+ shared staffingFields.ts factory)
|
||||||
server/ 5 files # MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
|
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)
|
projects/ 4 files # Projects, Labels, Releases, Sprints (Jira-like)
|
||||||
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
|
tickets/ 2 files # Tickets (Lexical rich text), TicketVotes
|
||||||
wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates
|
wiki/ 3 files # WikiPages, WikiRevisions, WikiTemplates
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,10 @@ export const GameRules: GlobalConfig = {
|
||||||
{
|
{
|
||||||
name: "proximityThreshold",
|
name: "proximityThreshold",
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 10,
|
defaultValue: 1000,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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.",
|
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"),
|
...createStaffingFields("instance"),
|
||||||
{
|
{
|
||||||
label: "Geolocation",
|
label: "Geolocation",
|
||||||
|
|
@ -107,9 +146,34 @@ export const GameStructures: CollectionConfig = {
|
||||||
{
|
{
|
||||||
name: "coordinates",
|
name: "coordinates",
|
||||||
type: "point",
|
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",
|
name: "storedResources",
|
||||||
type: "array",
|
type: "array",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,21 @@ export const Shipments: CollectionConfig = {
|
||||||
relationTo: "game-structures",
|
relationTo: "game-structures",
|
||||||
required: true,
|
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
|
// Transport
|
||||||
{
|
{
|
||||||
name: "transportVehicle",
|
name: "transportVehicle",
|
||||||
|
|
|
||||||
|
|
@ -349,20 +349,14 @@ export const Structures: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "constructionTime",
|
name: "constructionDurationMinutes",
|
||||||
type: "number",
|
type: "number",
|
||||||
required: true,
|
required: true,
|
||||||
label: "Build Time (in game ticks)",
|
label: "Build Time (minutes)",
|
||||||
min: 1,
|
min: 1,
|
||||||
admin: {
|
admin: {
|
||||||
components: {
|
description:
|
||||||
Description: {
|
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||||
path: "@/components/static/TickDurationDescription",
|
|
||||||
clientProps: {
|
|
||||||
task: "complete construction of this structure",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,21 @@ export const Vehicles: CollectionConfig = {
|
||||||
label: "Logistics & Grid Storage",
|
label: "Logistics & Grid Storage",
|
||||||
type: "group",
|
type: "group",
|
||||||
fields: [
|
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",
|
label: "Storage Grid Dimensions",
|
||||||
type: "group",
|
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,
|
unique: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fileName",
|
name: "isActive",
|
||||||
type: "text",
|
type: "checkbox",
|
||||||
unique: true,
|
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";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
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 { NavUser } from "@/components/frontend/blocks/NavUser";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|
@ -149,6 +149,7 @@ export function AppSidebar({
|
||||||
<SidebarContent className="overflow-hidden">
|
<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">
|
<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="Platform" items={data.navMain} />
|
||||||
|
<NavCore label="Map" items={data.navMap} />
|
||||||
{hasIntelligence && (
|
{hasIntelligence && (
|
||||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -162,14 +163,19 @@ export function AppSidebar({
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<NavSecondary
|
<SidebarMenu className="px-1">
|
||||||
items={
|
{adminUrl && (
|
||||||
adminUrl
|
<SidebarMenuItem>
|
||||||
? [...data.navSecondary, { title: "Admin", url: adminUrl, icon: Shield }]
|
<SidebarMenuButton size="sm" asChild>
|
||||||
: data.navSecondary
|
<Link href={adminUrl}>
|
||||||
}
|
<Shield />
|
||||||
className="mt-auto"
|
<span>Admin</span>
|
||||||
/>
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
|
<div className="pb-1"><NavMinigames /></div>
|
||||||
|
</SidebarMenu>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export function NavCore({
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
}) {
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<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 {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bird,
|
|
||||||
Bomb,
|
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Crosshair,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
Gamepad2,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Mic,
|
Mic,
|
||||||
Radio,
|
|
||||||
Spade,
|
|
||||||
Club,
|
|
||||||
Users2,
|
Users2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -27,9 +20,6 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
|
|
@ -139,56 +129,6 @@ export function NavUser({
|
||||||
Service Record
|
Service Record
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
Package,
|
Package,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Swords,
|
Swords,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
|
@ -24,6 +23,10 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const sidebarData = {
|
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: [
|
navHelpdesk: [
|
||||||
{
|
{
|
||||||
title: "Helpdesk",
|
title: "Helpdesk",
|
||||||
|
|
@ -157,11 +160,5 @@ export const sidebarData = {
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [],
|
||||||
{
|
|
||||||
title: "Feedback",
|
|
||||||
url: "https://onyxsimple.youtrack.cloud",
|
|
||||||
icon: Send,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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}`;
|
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({
|
function CardPicker({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|
@ -67,13 +85,13 @@ function CardPicker({
|
||||||
readonly value: Card | null;
|
readonly value: Card | null;
|
||||||
readonly onChange: (card: Card | null) => void;
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
||||||
<Select
|
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
|
||||||
value={value ? String(value.rank) : ""}
|
|
||||||
onValueChange={(next) => onChange(value ? { ...value, rank: Number(next) as Rank } : null)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-20 text-xs">
|
<SelectTrigger className="h-8 w-20 text-xs">
|
||||||
<SelectValue placeholder="Rank" />
|
<SelectValue placeholder="Rank" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -85,10 +103,7 @@ function CardPicker({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
|
||||||
value={value?.suit ?? ""}
|
|
||||||
onValueChange={(next) => onChange(value ? { ...value, suit: next as Suit } : null)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-24 text-xs">
|
<SelectTrigger className="h-8 w-24 text-xs">
|
||||||
<SelectValue placeholder="Suit" />
|
<SelectValue placeholder="Suit" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -191,11 +206,7 @@ export function PokerDevPanel({
|
||||||
<section className="flex flex-col gap-2 border border-border p-3">
|
<section className="flex flex-col gap-2 border border-border p-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider">Board queue</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider">Board queue</h3>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<CardPicker
|
<CardPicker label="Card" value={boardDraft} onChange={setBoardDraft} />
|
||||||
label="Card"
|
|
||||||
value={boardDraft}
|
|
||||||
onChange={(card) => setBoardDraft(card ?? { rank: 2, suit: "s" })}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -40,7 +40,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
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: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ lib/
|
||||||
base/ # Staffing (hire/fire/headcount), structure upgrades, storage, base tick
|
base/ # Staffing (hire/fire/headcount), structure upgrades, storage, base tick
|
||||||
evaluations/ # Evaluation levels + bot reminder plan (reminders.ts)
|
evaluations/ # Evaluation levels + bot reminder plan (reminders.ts)
|
||||||
locker/ # Grid logic, placement validation, loadouts, attachments/skins
|
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
|
logistics/ # Mission lifecycle + mission reminder logic
|
||||||
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
|
market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
|
||||||
notifications/ # User notification helper + muteable types
|
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.
|
* 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.)
|
* @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.)
|
||||||
*/
|
*/
|
||||||
export function calculateTransitTime(
|
export function calculateTransitTime(
|
||||||
distance: number,
|
distance: number,
|
||||||
vehicleSpeed: number,
|
vehicleSpeed: number,
|
||||||
): number {
|
): number {
|
||||||
const hours = distance / vehicleSpeed;
|
const km = distance / 1000;
|
||||||
|
const hours = km / vehicleSpeed;
|
||||||
return hours * 60 * 60 * 1000;
|
return hours * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the total fuel cost for a shipment.
|
* 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)
|
* @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate)
|
||||||
*/
|
*/
|
||||||
export function calculateFuelCost(
|
export function calculateFuelCost(
|
||||||
|
|
@ -50,22 +51,34 @@ export function calculateTicksForDuration(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the effective speed of a vehicle, preferring road speed.
|
* Gets the effective speed of a vehicle for its transport mode: ground
|
||||||
* Falls back to off-road, air, or sea speed.
|
* 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: {
|
export function getVehicleEffectiveSpeed(vehicle: {
|
||||||
|
transportMode?: "ground" | "air" | "sea" | null;
|
||||||
combat?: {
|
combat?: {
|
||||||
maxSpeedOnRoad?: number | null;
|
maxSpeedOnRoad?: number | null;
|
||||||
maxSpeedOffRoad?: number | null;
|
maxSpeedOffRoad?: number | null;
|
||||||
maxAirspeed?: number | null;
|
maxAirspeed?: number | null;
|
||||||
maxKnots?: number | null;
|
maxKnots?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
} & {
|
||||||
|
maxSpeedOnRoad?: number | null;
|
||||||
|
maxSpeedOffRoad?: number | null;
|
||||||
|
maxAirspeed?: number | null;
|
||||||
|
maxKnots?: number | null;
|
||||||
}): number {
|
}): number {
|
||||||
return (
|
const combat = vehicle.combat ?? vehicle;
|
||||||
vehicle.combat?.maxSpeedOnRoad ??
|
const mode = vehicle.transportMode ?? "ground";
|
||||||
vehicle.combat?.maxSpeedOffRoad ??
|
const byMode: Record<string, (number | null | undefined)[]> = {
|
||||||
vehicle.combat?.maxAirspeed ??
|
ground: [combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxAirspeed, combat.maxKnots],
|
||||||
vehicle.combat?.maxKnots ??
|
air: [combat.maxAirspeed, combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxKnots],
|
||||||
50 // default fallback speed
|
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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -370,6 +374,26 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260914_171412_add_respawn_tickets.up,
|
up: migration_20260914_171412_add_respawn_tickets.up,
|
||||||
down: migration_20260914_171412_add_respawn_tickets.down,
|
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;
|
releases: Release;
|
||||||
labels: Label;
|
labels: Label;
|
||||||
maps: Map;
|
maps: Map;
|
||||||
|
'map-roads': MapRoad;
|
||||||
|
'map-zones': MapZone;
|
||||||
|
'resource-nodes': ResourceNode;
|
||||||
'narrative-events': NarrativeEvent;
|
'narrative-events': NarrativeEvent;
|
||||||
'story-beats': StoryBeat;
|
'story-beats': StoryBeat;
|
||||||
'story-beat-states': StoryBeatState;
|
'story-beat-states': StoryBeatState;
|
||||||
|
|
@ -197,6 +200,9 @@ export interface Config {
|
||||||
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
||||||
labels: LabelsSelect<false> | LabelsSelect<true>;
|
labels: LabelsSelect<false> | LabelsSelect<true>;
|
||||||
maps: MapsSelect<false> | MapsSelect<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>;
|
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||||
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
'story-beats': StoryBeatsSelect<false> | StoryBeatsSelect<true>;
|
||||||
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
'story-beat-states': StoryBeatStatesSelect<false> | StoryBeatStatesSelect<true>;
|
||||||
|
|
@ -338,6 +344,22 @@ export interface GameStructure {
|
||||||
* The faction that owns this structure.
|
* The faction that owns this structure.
|
||||||
*/
|
*/
|
||||||
faction?: (number | null) | Faction;
|
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.
|
* Funds charged per base tick for structure upkeep, separate from staff salaries.
|
||||||
*/
|
*/
|
||||||
|
|
@ -381,10 +403,20 @@ export interface GameStructure {
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
/**
|
/**
|
||||||
|
* Map meters [x, y] on the structure's game map (not geographic degrees).
|
||||||
|
*
|
||||||
* @minItems 2
|
* @minItems 2
|
||||||
* @maxItems 2
|
* @maxItems 2
|
||||||
*/
|
*/
|
||||||
coordinates?: [number, number] | null;
|
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?:
|
storedResources?:
|
||||||
| {
|
| {
|
||||||
resource: number | Resource;
|
resource: number | Resource;
|
||||||
|
|
@ -466,7 +498,10 @@ export interface Structure {
|
||||||
amount?: number | null;
|
amount?: number | null;
|
||||||
id?: string | 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.
|
* What research must be done before this can be built.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1165,6 +1200,10 @@ export interface Vehicle {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| 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;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
maxMass?: number | null;
|
maxMass?: number | null;
|
||||||
|
|
@ -1676,6 +1715,12 @@ export interface Role {
|
||||||
| 'maps:update'
|
| 'maps:update'
|
||||||
| 'maps:delete'
|
| 'maps:delete'
|
||||||
| 'admin:maps:manage'
|
| '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:create'
|
||||||
| 'narrative-events:read'
|
| 'narrative-events:read'
|
||||||
| 'narrative-events:update'
|
| 'narrative-events:update'
|
||||||
|
|
@ -1740,6 +1785,51 @@ export interface Role {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "game-vehicles".
|
* via the `definition` "game-vehicles".
|
||||||
|
|
@ -2691,17 +2781,6 @@ export interface Mission {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "mission-files".
|
* via the `definition` "mission-files".
|
||||||
|
|
@ -2823,6 +2902,22 @@ export interface Shipment {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
origin: number | GameStructure;
|
origin: number | GameStructure;
|
||||||
destination: 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.
|
* The vehicle instance transporting this shipment.
|
||||||
*/
|
*/
|
||||||
|
|
@ -3666,6 +3761,150 @@ export interface WikiTemplate {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "narrative-events".
|
* via the `definition` "narrative-events".
|
||||||
|
|
@ -4927,6 +5166,60 @@ export interface PayloadMcpApiKey {
|
||||||
*/
|
*/
|
||||||
delete?: boolean | null;
|
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?: {
|
narrativeEvents?: {
|
||||||
/**
|
/**
|
||||||
* Allow clients to find narrative-events.
|
* Allow clients to find narrative-events.
|
||||||
|
|
@ -5392,6 +5685,18 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'maps';
|
relationTo: 'maps';
|
||||||
value: number | Map;
|
value: number | Map;
|
||||||
} | null)
|
} | 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';
|
relationTo: 'narrative-events';
|
||||||
value: number | NarrativeEvent;
|
value: number | NarrativeEvent;
|
||||||
|
|
@ -5541,6 +5846,10 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
type?: T;
|
type?: T;
|
||||||
faction?: T;
|
faction?: T;
|
||||||
|
map?: T;
|
||||||
|
constructionStatus?: T;
|
||||||
|
constructionStartedAt?: T;
|
||||||
|
constructionCompletesAt?: T;
|
||||||
maintenanceCost?: T;
|
maintenanceCost?: T;
|
||||||
laborRequirements?:
|
laborRequirements?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -5560,6 +5869,8 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
coordinates?: T;
|
coordinates?: T;
|
||||||
|
labelVisible?: T;
|
||||||
|
labelPriority?: T;
|
||||||
storedResources?:
|
storedResources?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -6489,6 +6800,7 @@ export interface VehiclesSelect<T extends boolean = true> {
|
||||||
amount?: T;
|
amount?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
|
transportMode?: T;
|
||||||
width?: T;
|
width?: T;
|
||||||
height?: T;
|
height?: T;
|
||||||
maxMass?: T;
|
maxMass?: T;
|
||||||
|
|
@ -6536,7 +6848,7 @@ export interface StructuresSelect<T extends boolean = true> {
|
||||||
amount?: T;
|
amount?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
constructionTime?: T;
|
constructionDurationMinutes?: T;
|
||||||
requiredTech?: T;
|
requiredTech?: T;
|
||||||
requiresUpgrade?: T;
|
requiresUpgrade?: T;
|
||||||
upgradesInto?: T;
|
upgradesInto?: T;
|
||||||
|
|
@ -6625,6 +6937,8 @@ export interface ShipmentsSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
origin?: T;
|
origin?: T;
|
||||||
destination?: T;
|
destination?: T;
|
||||||
|
map?: T;
|
||||||
|
routePath?: T;
|
||||||
transportVehicle?: T;
|
transportVehicle?: T;
|
||||||
distance?: T;
|
distance?: T;
|
||||||
cargo?:
|
cargo?:
|
||||||
|
|
@ -7018,7 +7332,68 @@ export interface LabelsSelect<T extends boolean = true> {
|
||||||
*/
|
*/
|
||||||
export interface MapsSelect<T extends boolean = true> {
|
export interface MapsSelect<T extends boolean = true> {
|
||||||
name?: T;
|
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;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
@ -7653,6 +8028,30 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
||||||
update?: T;
|
update?: T;
|
||||||
delete?: 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?:
|
narrativeEvents?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -7842,7 +8241,7 @@ export interface GameRule {
|
||||||
*/
|
*/
|
||||||
intelAssignments?: (number | Assignment)[] | null;
|
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;
|
proximityThreshold?: number | null;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import { Profiles } from "@/collections/users/Profiles";
|
||||||
import { Missions } from "@/collections/intelligence/Missions";
|
import { Missions } from "@/collections/intelligence/Missions";
|
||||||
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
||||||
import { Maps } from "@/collections/world/Maps";
|
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 { Campaigns } from "@/collections/intelligence/Campaigns";
|
||||||
import { Qualifications } from "@/collections/users/Qualifications";
|
import { Qualifications } from "@/collections/users/Qualifications";
|
||||||
import { MissionFiles } from "@/collections/server/MissionFiles";
|
import { MissionFiles } from "@/collections/server/MissionFiles";
|
||||||
|
|
@ -289,6 +292,9 @@ const collections = [
|
||||||
|
|
||||||
// World
|
// World
|
||||||
Maps,
|
Maps,
|
||||||
|
MapRoads,
|
||||||
|
MapZones,
|
||||||
|
ResourceNodes,
|
||||||
NarrativeEvents,
|
NarrativeEvents,
|
||||||
|
|
||||||
// Story
|
// Story
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||||
{ value: "admin:maps:manage", label: "Manage Admin Page" },
|
{ 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",
|
group: "Narrative Events",
|
||||||
permissions: [
|
permissions: [
|
||||||
|
|
@ -825,6 +836,12 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
||||||
"maps:update",
|
"maps:update",
|
||||||
"maps:delete",
|
"maps:delete",
|
||||||
"admin:maps:manage",
|
"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:create",
|
||||||
"narrative-events:read",
|
"narrative-events:read",
|
||||||
"narrative-events:update",
|
"narrative-events:update",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { SanitizedConfig } from "payload";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||||
import { processShipmentTick } from "./processShipmentTick";
|
import { processShipmentTick } from "./processShipmentTick";
|
||||||
|
import { processConstructionTick } from "@/lib/map/construction";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
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, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.GameTick,
|
type: EventTypes.GameTick,
|
||||||
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
|
||||||
data: {
|
data: {
|
||||||
processedCount,
|
processedCount,
|
||||||
totalActive: activeShipments.docs.length,
|
totalActive: activeShipments.docs.length,
|
||||||
|
construction,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { notifyUser } from "@/lib/notifications";
|
import { notifyUser } from "@/lib/notifications";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
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";
|
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
||||||
|
|
||||||
type ShipmentDoc = Shipment & {
|
type ShipmentDoc = Shipment & {
|
||||||
|
|
@ -75,7 +77,7 @@ export async function processShipmentTick(
|
||||||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||||
|
|
||||||
if (now >= estimatedArrival) {
|
if (now >= estimatedArrival) {
|
||||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
await completeShipment(payload, shipment, newFuelConsumed, now, log);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +95,7 @@ async function completeShipment(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
shipment: ShipmentDoc,
|
shipment: ShipmentDoc,
|
||||||
finalFuelConsumed: number,
|
finalFuelConsumed: number,
|
||||||
|
now: Date,
|
||||||
log: Pick<BinLogger, "error">,
|
log: Pick<BinLogger, "error">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const vehicle = shipment.transportVehicle;
|
const vehicle = shipment.transportVehicle;
|
||||||
|
|
@ -273,6 +276,15 @@ async function completeShipment(
|
||||||
data: { vehicleId: vehicle.id, originId, destinationId },
|
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).
|
// Notify the user who created the shipment (the closest thing to a recipient/owner).
|
||||||
const creatorId =
|
const creatorId =
|
||||||
typeof shipment.createdBy === "object" && shipment.createdBy
|
typeof shipment.createdBy === "object" && shipment.createdBy
|
||||||
|
|
@ -314,6 +326,10 @@ async function createAutoReturnShipment(
|
||||||
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
|
||||||
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
|
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 returnFuelCost = Math.ceil(originalShipment.distance * rate);
|
||||||
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -346,6 +362,11 @@ async function createAutoReturnShipment(
|
||||||
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
|
||||||
origin: destinationId,
|
origin: destinationId,
|
||||||
destination: originId,
|
destination: originId,
|
||||||
|
map:
|
||||||
|
typeof originalShipment.map === "object"
|
||||||
|
? (originalShipment.map?.id ?? null)
|
||||||
|
: (originalShipment.map ?? null),
|
||||||
|
routePath: originalRoutePath,
|
||||||
transportVehicle: vehicle.id,
|
transportVehicle: vehicle.id,
|
||||||
distance: originalShipment.distance,
|
distance: originalShipment.distance,
|
||||||
cargo: [],
|
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.
|
* Resolve the ids of every user holding a superuser role.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Structure
|
// Structure
|
||||||
StructureResize: "structure:resize",
|
StructureResize: "structure:resize",
|
||||||
|
StructurePlaced: "structure:placed",
|
||||||
|
StructureConstructionComplete: "structure:construction-complete",
|
||||||
StructureUpgraded: "structure:upgraded",
|
StructureUpgraded: "structure:upgraded",
|
||||||
UpkeepShort: "structure:upkeep-short",
|
UpkeepShort: "structure:upkeep-short",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ describe("Leadership evaluations", () => {
|
||||||
|
|
||||||
const map = (await payload.create({
|
const map = (await payload.create({
|
||||||
collection: "maps",
|
collection: "maps",
|
||||||
data: { name: `${RUN} Map` },
|
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as MissionMap;
|
})) 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({
|
const map = (await payload.create({
|
||||||
collection: "maps",
|
collection: "maps",
|
||||||
data: { name: `${RUN} Map` },
|
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as MissionMap;
|
})) as unknown as MissionMap;
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ describe("Mission feedback reminder", () => {
|
||||||
|
|
||||||
const map = (await payload.create({
|
const map = (await payload.create({
|
||||||
collection: "maps",
|
collection: "maps",
|
||||||
data: { name: `${RUN} Map` },
|
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as MissionMap;
|
})) as unknown as MissionMap;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type PokerTableState,
|
type PokerTableState,
|
||||||
} from "@/lib/minigames/poker";
|
} from "@/lib/minigames/poker";
|
||||||
import type { Card } from "@/lib/minigames/poker";
|
import type { Card } from "@/lib/minigames/poker";
|
||||||
|
import { applyCardPick } from "@/components/frontend/minigames/poker/PokerDevPanel";
|
||||||
import {
|
import {
|
||||||
adjustStack,
|
adjustStack,
|
||||||
createTable,
|
createTable,
|
||||||
|
|
@ -148,3 +149,15 @@ describe("dev stack adjustment", () => {
|
||||||
expect(() => adjustStack(tableId, 9611, 9, 100)).toThrow();
|
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({
|
const map = (await payload.create({
|
||||||
collection: "maps",
|
collection: "maps",
|
||||||
data: { name: `${RUN} Map` },
|
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as MissionMap;
|
})) as unknown as MissionMap;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue