1
0
Fork 0

Compare commits

...

12 commits

Author SHA1 Message Date
9ca85fce9b v0.2.40 - add game map system with road routing, timestamp construction model, nav refactor 2026-09-16 23:14:49 -04:00
b5925b7633 chore(generated): regenerate types and schema, register map migrations
Regenerated payload types and db schema; register the game map system, construction timestamp, map labels, and node/zone resources migrations.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:24 -04:00
8cab545b9d docs: document the game map system and construction timestamp model
Add docs/map-system.md with the mapping table and update AGENTS.md plus sub-AGENTS files for the map collections, routing, placement, and construction model.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:20 -04:00
a770c62d6f fix(ui): raise dialog and sheet z-indexes above map panes
Leaflet panes paint over body-level portals with z-50, so dialogs and sheets open invisibly on /map. Bump overlay and content layers to z-[1300].

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:20 -04:00
ad2fbb167a refactor(nav): sidebar minigames group, inline admin link, hidden map entry
Move minigame links from the user dropdown into a NavMinigames sidebar group, inline the admin link in AppSidebar via a new canAccessAdminPanel helper, guard NavCore against empty groups, drop the feedback secondary link, and add an empty navMap slot with a restore comment so the World Map ships hidden while /map stays live.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:16 -04:00
fcf220b961 feat(map): /map world map with authoring, placement, and feature APIs
Add the Leaflet map page and client (markers, labels, layer toggles, shipment interpolation), the placement and authoring server actions, the map-features and map-import API routes, and the map styles including leaflet pane isolation so portals stack correctly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:12 -04:00
5be390012a feat(base): timestamp-based construction lifecycle with progress panel
Blueprints declare constructionDurationMinutes instead of tick counts. Delivering the last material starts construction at delivery time via maybeStartConstruction, setting constructionStartedAt/CompletesAt on the site; the game tick only sweeps completed sites. Structure detail page extracts server data helpers and shows a ConstructionPanel with a literal ETA countdown.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:07 -04:00
c64c9da0da feat(map): road-network routing for shipments with transport modes
Add pure routing, placement, point, and style libs plus map-system tests. Shipments gain a map relationship and routePath snapshot; ground vehicles route over the road network via Dijkstra while air and sea travel straight-line. Vehicle types declare a ground/air/sea transportMode and transit math works in meters.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:14:01 -04:00
5f06039697 feat(world): game map collections with basemaps, map permissions, and meter proximity
Add maps basemap config (world size, image/tiles mode, zoom defaults, isActive), map-roads, map-zones, and resource-nodes collections with maps:* permissions. Track game-structures map placement, meter coordinates, and label visibility/priority. Bump proximityThreshold to meters and adapt side-op map creation plus test fixtures to the new required fields.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:13:57 -04:00
28816b7084 chore(deps): add leaflet, react-leaflet, and type packages for the world map
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:13:49 -04:00
24d707a68a chore(events): add structure placed and construction-complete event types
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-16 23:13:49 -04:00
dce6f8f2ee fix(poker): dev panel card pickers ignored the first selection
CardPicker propagated null into onChange when no card was selected yet,
so the board picker's parent coerced every first pick to 2 of spades and
the hole-card pickers could never hold a value, keeping Queue hand
disabled forever. Picks now build a complete card, preserving any
already-chosen half, with regression tests on the extracted helper.

Co-authored-by: Sisyphus <sisyphus@ohmyopencode.dev>
2026-09-16 19:10:37 -04:00
73 changed files with 121202 additions and 173 deletions

View file

@ -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`).

View file

@ -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
View 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.

View file

@ -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",

View file

@ -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;

View file

@ -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}

View 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 };
}
}

View file

@ -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) => ({

View 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,
};
}

View 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 };
}
}

View file

@ -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}

View file

@ -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.`,
}; };
} }
} }

View 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} />;
}

View 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>
);
}

View file

@ -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;
}

View 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);
}

View 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 });
}

View file

@ -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

View file

@ -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.",
}, },
}, },
{ {

View file

@ -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",

View file

@ -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",

View file

@ -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",
},
},
},
}, },
}, },
{ {

View file

@ -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",

View 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.",
},
},
],
};

View 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).",
},
},
],
};

View file

@ -21,9 +21,116 @@ export const Maps: CollectionConfig = {
unique: true, unique: true,
}, },
{ {
name: "fileName", name: "isActive",
type: "checkbox",
label: "Active",
defaultValue: true,
admin: {
description: "Inactive maps are hidden from the map page and cannot receive placements.",
},
},
{
label: "World Size (meters)",
type: "group",
fields: [
{
name: "worldSizeWidth",
type: "number",
label: "Width (m)",
required: true,
min: 1,
defaultValue: 8192,
admin: {
description: "Map width in real meters (e.g. Altis is 8192).",
},
},
{
name: "worldSizeHeight",
type: "number",
label: "Height (m)",
required: true,
min: 1,
defaultValue: 8192,
admin: {
description: "Map height in real meters (e.g. Altis is 8192).",
},
},
],
},
{
label: "Basemap",
type: "group",
fields: [
{
name: "basemapMode",
type: "select",
required: true,
defaultValue: "image",
options: [
{ label: "Image Upload", value: "image" },
{ label: "XYZ Tile Layer", value: "tiles" },
],
admin: {
description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
},
},
{
name: "basemapImage",
type: "upload",
relationTo: "media",
label: "Basemap Image",
admin: {
condition: (_data, siblingData) => siblingData?.basemapMode !== "tiles",
description: "Top-down map image. Rendered stretched across the full world size.",
},
},
{
name: "tileUrlTemplate",
type: "text", type: "text",
unique: true, 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,
},
],
}, },
], ],
}; };

View 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.",
},
},
],
};

View 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>;
}

View 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>
);
}

View file

@ -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">

View file

@ -32,6 +32,8 @@ export function NavCore({
}[]; }[];
}[]; }[];
}) { }) {
if (items.length === 0) return null;
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{label}</SidebarGroupLabel>

View 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>
);
}

View file

@ -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>

View file

@ -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,
},
],
}; };

View 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>
);
}

File diff suppressed because it is too large Load diff

View 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>
);
}

View 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>
);
}

View file

@ -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"

View file

@ -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}

View file

@ -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}

View file

@ -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: {

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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[];
}

View file

@ -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
} }

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -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
);`)
}

File diff suppressed because it is too large Load diff

View 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";`)
}

File diff suppressed because it is too large Load diff

View file

@ -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");`)
}

View file

@ -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

View file

@ -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;
/** /**

View file

@ -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

View file

@ -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",

View file

@ -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,
}, },
}); });

View file

@ -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: [],

View file

@ -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.
* *

View file

@ -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",

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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;

View file

@ -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"));
});
});

View file

@ -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;