Compare commits
39 commits
07eb8aa22e
...
b6a3226e3d
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a3226e3d | |||
| 8cb6590ce6 | |||
| 8135c9850b | |||
| 18bb6712cd | |||
| 89a04b4435 | |||
| 858cce4c40 | |||
| 529a9573b2 | |||
| 239e95c462 | |||
| a6b5b19807 | |||
| 213a8c106f | |||
| ae4ed8402d | |||
| 8213d76816 | |||
| 0617794da7 | |||
| fda3fc9642 | |||
| 85bd1b0659 | |||
| 5883990577 | |||
| 4dde790aa8 | |||
| c177f2486f | |||
| 5fb619a5cf | |||
| 8c10719124 | |||
| 9bbe44d38d | |||
| 679a0f7879 | |||
| f895f75317 | |||
| 1f7d1e47ef | |||
| f2bd5418b2 | |||
| 42066e9d13 | |||
| b483cf910b | |||
| c0563dae30 | |||
| 37d28893de | |||
| c19d0d05ee | |||
| 3c70160dfc | |||
| 67adaa301c | |||
| 67c993ca10 | |||
| cc23742dd8 | |||
| f35da021da | |||
| d719c6b455 | |||
| 7b82da58e7 | |||
| f8ce3f3dcc | |||
| 7a0f997f70 |
235 changed files with 322998 additions and 704 deletions
|
|
@ -66,4 +66,9 @@ APP_PORT=3000
|
||||||
# Shared secret required by the /api/arma/v1/* routes. The Arma server sends
|
# Shared secret required by the /api/arma/v1/* routes. The Arma server sends
|
||||||
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
|
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
|
||||||
# with: openssl rand -hex 32. Leave unset to disable the bridge (routes 503).
|
# with: openssl rand -hex 32. Leave unset to disable the bridge (routes 503).
|
||||||
# ARMA_BRIDGE_API_KEY=
|
# ARMA_BRIDGE_API_KEY=
|
||||||
|
# Optional: exact URI for the test database (defaults to <DATABASE_URI database>_test)
|
||||||
|
# TEST_DATABASE_URI=postgres://ptfapp:polaristaskforcedb@127.0.0.1:5432/ptf-app-dev-test
|
||||||
|
|
||||||
|
# Optional: set to 1 to drop and recreate the test database from scratch instead of truncating
|
||||||
|
# TEST_DB_HARD_RESET=1
|
||||||
|
|
|
||||||
109
.gitignore
vendored
109
.gitignore
vendored
|
|
@ -1,71 +1,58 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
|
/.archived/playwright-report/
|
||||||
|
/.archived/test-results/
|
||||||
|
/backups/
|
||||||
|
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
|
||||||
|
/blob-report/
|
||||||
|
/build
|
||||||
|
/coverage
|
||||||
|
# debug
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
.DS_Store
|
||||||
/.pnp
|
.env
|
||||||
.pnp.js
|
.env.*
|
||||||
.yarn/install-state.gz
|
.env.development
|
||||||
|
.env*.local
|
||||||
|
.env.prd
|
||||||
|
.env.stg
|
||||||
|
# generated version info (regenerated by predev/prebuild/postversion)
|
||||||
/.idea/*
|
/.idea/*
|
||||||
!/.idea/runConfigurations
|
!/.idea/runConfigurations
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# generated version info (regenerated by predev/prebuild/postversion)
|
|
||||||
/src/generated/
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
|
|
||||||
/logs/
|
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.stg
|
|
||||||
.env.prd
|
|
||||||
.env.development
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
/media
|
|
||||||
/backups/
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
node_modules/
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/.archived/test-results/
|
|
||||||
/.archived/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
|
|
||||||
# local tooling
|
# local tooling
|
||||||
/.logs/
|
/.logs/
|
||||||
|
/logs/
|
||||||
|
/media
|
||||||
|
# misc
|
||||||
|
/.next/
|
||||||
|
next-env.d.ts
|
||||||
|
# next.js
|
||||||
|
/node_modules
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
/.omo/
|
/.omo/
|
||||||
/.opencode/
|
|
||||||
/.openchamber/
|
/.openchamber/
|
||||||
|
/.opencode/
|
||||||
|
/out/
|
||||||
|
*.pem
|
||||||
|
# personal shell alias notes
|
||||||
|
# Playwright
|
||||||
|
/playwright/.cache/
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
/playwright-report/
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
# production
|
||||||
|
/public/tiles
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
/src/generated/
|
||||||
|
# testing
|
||||||
|
/test-results/
|
||||||
|
*.tsbuildinfo
|
||||||
|
# typescript
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.zinc
|
||||||
|
|
|
||||||
24
AGENTS.md
24
AGENTS.md
|
|
@ -44,8 +44,9 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
|
||||||
|
|
||||||
## Test details
|
## Test details
|
||||||
|
|
||||||
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires a live PostgreSQL database (connection from `.env`). Uses `dotenv/config` via `vitest.setup.ts`.
|
- **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
|
||||||
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` (with `reuseExistingServer: true`). Currently minimal (homepage smoke test).
|
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
|
||||||
|
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). Currently minimal (homepage smoke test). Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
|
||||||
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
||||||
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
||||||
|
|
||||||
|
|
@ -158,6 +159,7 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
|
||||||
|
|
||||||
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
|
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
|
||||||
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
|
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
|
||||||
|
- **Shipment lifecycle is tick-independent** (`src/lib/shipping/lifecycle.ts`): `advanceShipment(payload, shipment, now?)` derives everything from timestamps (continuous fuel burn `fuelCost x elapsedFraction` with the vehicle draining by the delta, `dispatched` -> `in_transit`, full delivery via `completeShipment` at/after ETA, `stranded` when fuel can't cover the elapsed burn) and is idempotent (fresh status re-check before crediting cargo). The game tick just sweeps all active shipments; the **shipment detail page also advances on load**, so a due shipment completes the moment anyone opens it instead of waiting for a tick. Callers must pass a depth-2 shipment doc (vehicle relation hydrated). The old `src/scripts/processShipmentTick.ts` module was folded into this lib.
|
||||||
- **Bin file logging**: all seven bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `base-tick`, `economy-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
- **Bin file logging**: all seven bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `base-tick`, `economy-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
||||||
- **Base tick**: `bun run payload base-tick` — bin registered like the others (needs external cron). Runs `processBaseTick` (`src/lib/base/tick.ts`): pays staff salaries from the structure treasury (banking `applyTransaction` type `salary`), charges structure maintenance, flags upkeep shortages (see Base Management below). Emits `staff:salary-paid`/`staff:salary-unpaid`/`structure:maintenance-paid`/`structure:maintenance-unpaid` events. Notifies clients via the same SSE path as the other ticks (`source: "base-tick"`).
|
- **Base tick**: `bun run payload base-tick` — bin registered like the others (needs external cron). Runs `processBaseTick` (`src/lib/base/tick.ts`): pays staff salaries from the structure treasury (banking `applyTransaction` type `salary`), charges structure maintenance, flags upkeep shortages (see Base Management below). Emits `staff:salary-paid`/`staff:salary-unpaid`/`structure:maintenance-paid`/`structure:maintenance-unpaid` events. Notifies clients via the same SSE path as the other ticks (`source: "base-tick"`).
|
||||||
- **Economy tick**: `bun run payload economy-tick` — bin registered like the others (needs external cron; cadence hint is GameRules `economy.tickIntervalHintMinutes`). Runs `processEconomyTick` (`src/lib/economy/tick.ts`): ensures a `market-state` ledger doc exists per tradeable resource/asset (copied from GM baselines), decays `realDemand`, drifts `artificialDemand` (deterministic per period via `periodSeed` → mulberry32 rng — a cron retry within the same period recomputes the same values), recomputes `priceModifier` from the demand/supply ratio clamped to the item's band. Master gate: GameRules `economy.enabled` (default **off** — no-op when disabled). Cold-start gate: items with fewer than `coldStartObservations` completed purchases keep `priceModifier = 1`. Period idempotency: docs with `lastComputedAt >= periodStart` are skipped. Emits a batch `economy:tick` event + one `economy:price-change` event per item whose modifier moved ≥ `PRICE_CHANGE_THRESHOLD` (0.05) — target `market-state` (logic: `src/lib/economy/` incl. `events.ts`, tests: `tests/int/economy.int.spec.ts` + `tests/int/economy-events.int.spec.ts`). Nothing consumes the modifier yet (P1d: market-tick new-listing pricing). Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
- **Economy tick**: `bun run payload economy-tick` — bin registered like the others (needs external cron; cadence hint is GameRules `economy.tickIntervalHintMinutes`). Runs `processEconomyTick` (`src/lib/economy/tick.ts`): ensures a `market-state` ledger doc exists per tradeable resource/asset (copied from GM baselines), decays `realDemand`, drifts `artificialDemand` (deterministic per period via `periodSeed` → mulberry32 rng — a cron retry within the same period recomputes the same values), recomputes `priceModifier` from the demand/supply ratio clamped to the item's band. Master gate: GameRules `economy.enabled` (default **off** — no-op when disabled). Cold-start gate: items with fewer than `coldStartObservations` completed purchases keep `priceModifier = 1`. Period idempotency: docs with `lastComputedAt >= periodStart` are skipped. Emits a batch `economy:tick` event + one `economy:price-change` event per item whose modifier moved ≥ `PRICE_CHANGE_THRESHOLD` (0.05) — target `market-state` (logic: `src/lib/economy/` incl. `events.ts`, tests: `tests/int/economy.int.spec.ts` + `tests/int/economy-events.int.spec.ts`). Nothing consumes the modifier yet (P1d: market-tick new-listing pricing). Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
||||||
|
|
@ -188,22 +190,25 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
|
||||||
Labor/staffing simulation for structures. Collections in `src/collections/game/`: `npc-staffing` (named NPC staff + labor headcount blocks on game-structures), `labor-classifications` (labor trades: name, key, `defaultUnitSalary`, `availableHeadcount`), and a shared `staffingFields.ts` factory (`createStaffingFields`) that adds `maintenanceCost` / `laborRequirements` / `staffPositions` to both GameStructures instances and Structures blueprints ("Staffing Defaults" group). A `GameStructures` beforeChange hook copies blueprint staffing defaults on create.
|
Labor/staffing simulation for structures. Collections in `src/collections/game/`: `npc-staffing` (named NPC staff + labor headcount blocks on game-structures), `labor-classifications` (labor trades: name, key, `defaultUnitSalary`, `availableHeadcount`), and a shared `staffingFields.ts` factory (`createStaffingFields`) that adds `maintenanceCost` / `laborRequirements` / `staffPositions` to both GameStructures instances and Structures blueprints ("Staffing Defaults" group). A `GameStructures` beforeChange hook copies blueprint staffing defaults on create.
|
||||||
|
|
||||||
- **Service lib**: `src/lib/base/` — `staffing.ts` (`hireStaff`, `fireStaff`, `setLaborHeadcount`, `assertStaffHiringEnabled` — enforces position caps, labor-vs-named-staff rules), `upgrade.ts` (`upgradeStructure` — validates + consumes stored materials, swaps structure type via `upgradesInto`), `storage.ts` (`consumeStorage`, `storedAmount`, ...), `staffingDefaults.ts`, `tick.ts` (`processBaseTick`), `types.ts`.
|
- **Service lib**: `src/lib/base/` — `staffing.ts` (`hireStaff`, `fireStaff`, `setLaborHeadcount`, `assertStaffHiringEnabled` — enforces position caps, labor-vs-named-staff rules), `upgrade.ts` (`upgradeStructure` — validates + consumes stored materials, swaps structure type via `upgradesInto`), `storage.ts` (`consumeStorage`, `storedAmount`, ...), `staffingDefaults.ts`, `tick.ts` (`processBaseTick`), `types.ts`.
|
||||||
|
- **Modules (slice 3)**: blueprints declare `moduleSlots` + a `moduleOptions` catalog (name, category, effects, install materials) via `moduleFields.ts`; instances only store `installedModules` (action-managed, readOnly). `src/lib/base/modules.ts`: `installModule`/`uninstallModule` (materials consumed grid+void, no refund; operational structures only), `installedModuleEffects` aggregates `capacity_mass` (extra kg) / `production_multiplier` (output fraction boost) / `storage_slots` (grid slots metric). `effectiveMaxMass` wires the capacity bonus into the deposit/transfer/shipment checks + map route; the production tick applies the multiplier and the raised cap; `getGridSlots`/`calculateUtilization` take the bonuses. Events: `structure:module-install` / `structure:module-uninstall`. UI: `SlotsPanel` (structure detail page + map drawer).
|
||||||
|
- **Adjacency auras (slice 5)**: blueprint "Adjacency Aura" group (`auraFields.ts`: `effectType` production_multiplier|capacity_mass, `value`, `rangeMeters`) projects one effect onto operational neighbors. `src/lib/base/adjacency.ts` `auraBonusAt` (pure: operational sources only, self excluded, range in meters) is wired into the production tick (multiplier stacks with module multipliers; capacity raises the clamp) and computed from the tick's own site list with no extra queries (scoped runs fetch unscoped sources).
|
||||||
- **Server actions**: `src/app/(frontend)/logistics/base/actions.ts` — `upgradeStructure`, `hireStaff`, `fireStaff`, `setLaborHeadcount` (logistics-qualification gated, canonical ActionResult pattern).
|
- **Server actions**: `src/app/(frontend)/logistics/base/actions.ts` — `upgradeStructure`, `hireStaff`, `fireStaff`, `setLaborHeadcount` (logistics-qualification gated, canonical ActionResult pattern).
|
||||||
- **Gate**: Game Rules globals `staffHiringEnabled` + `staffHiringDisabledMessage` disable all hiring when off.
|
- **Gate**: Game Rules globals `staffHiringEnabled` + `staffHiringDisabledMessage` disable all hiring when off.
|
||||||
- **Base tick**: see `base-tick` bin under Shipments & game tick.
|
- **Base tick**: see `base-tick` bin under Shipments & game tick.
|
||||||
- **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]`).
|
||||||
|
- **Compounds (slice 4)**: game-structures `parentStructure` self-rel; children attach at placement (PlacementDialog "Compound" picker, validated: same map, hub not itself a child). Operational children pool storage into the hub via `src/lib/map/compounds.ts` (`aggregateCompoundStorage`); the map feature route folds child storage into the hub feature, force-hides child map labels, and reports `compoundChildren`; the hub ribbon reads "Compound: N buildings" and the popup gains a Compound cell. Structure detail pages show a Compound block (hub link on children; child chips on hubs).
|
||||||
- **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
|
## Game Map System
|
||||||
|
|
||||||
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
|
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 (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border (territory/border are informational political boundaries that never gate placement; default fills amber 0.1 / violet 0.06), 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).
|
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`, `worldMap` checkbox (opt-in "World Map Entry", default false; existing active maps backfilled true), optional `campaign` relationship, optional intel fields: `description`, `weather` freeform text (colored by keyword match on picker cards), `latitude`/`longitude` real-world reference, `easeOfAccess` select (easy/moderate/difficult), `factionControl` relationship; picker cards show weather/ease/faction stats + a truncated description), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border/other (territory/border are informational political boundaries, other is for generic areas like named compounds; none gate placement, only water does; default fills amber 0.1 / violet 0.06 / slate 0.08), fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel OPTIONAL (empty = plain marker node: placement's resourcesInRange never matches it), `icon` select from the shared set in `src/lib/map/nodeIcons.ts` (marker/droplet/trees/mountain/flag/star/skull/home/anchor/fuel; rendered on the map + picked in the authoring toolbar and admin), `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. World Map screen eligibility (`src/lib/map/screen.ts`): `worldMap && isActive && (no campaign or campaignMode != custom)` — mission-only / side-op maps (flag off, or tied to a custom campaign) never appear on the screen but stay attachable to missions. 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).
|
- **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`.
|
- **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.
|
- **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.
|
- **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). Crew speed (slice 2): blueprint `constructionCrewRequired` gates start (`no_crew` with no labor) and scales duration by staffed fraction (floor 0.25); labor-classifications `buildEfficiency` weights each worker; `setLaborHeadcount` re-prices the remaining work via `applyCrewRateChange` (`src/lib/base/constructionSpeed.ts`). 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`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
|
- **UI**: `/map` is a **picker screen** (grid of eligible map cards with basemap thumbnails, linking to `/map/[id]`); the map view itself is `/map/[id]` (`MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`, single-map props, no switcher). CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, 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`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
|
||||||
- **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). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `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`.
|
- **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). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `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.
|
- **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"`.
|
- **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"`.
|
||||||
|
|
@ -341,6 +346,9 @@ Username-based login (no email login). Users log in via Payload admin with `user
|
||||||
- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=<current path>`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
|
- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=<current path>`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
|
||||||
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
|
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
|
||||||
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
|
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
|
||||||
|
- **Admin panel access is two separate gates, do not collapse them**: (1) panel ENTRY is the Users collection `access.admin` (`src/collections/users/Users.ts`) and uses `canAccessAdminPanel` (superuser OR any `admin:<slug>:manage`); (2) per-page sidebar visibility is `requireAdminPageAccess` (applied centrally in `payload.config.ts`), requiring BOTH `<slug>:read` AND `admin:<slug>:manage` per collection.
|
||||||
|
- **Permission tiers**: `system:admin-access` (registry label "Superuser Tier (user management, notification oversight, final approval states)") is the functional superuser tier: final approval states (`approvalStatusFieldAccess` in `src/utils/access-control/divisionAccess.ts`, wired to the `approvalStatus` field on Assets/Resources/Vehicles/Technologies), the Users admin page, and read-all notifications. Only the built-in `admin` role and superuser roles hold it. NEVER grant it to division roles; they get panel access through `admin:<slug>:manage`. The dev DB once had it hand-granted to Logistics/Intelligence Officer, which silently made officers approval-superusers.
|
||||||
|
- **Impersonation**: `/api/impersonation/start` (POST `{ userId }`, operator must be a DYNAMIC superuser: `roleDocs` → Roles collection with `isSuperuser: true`; the dev user's legacy `role: developer` does NOT pass and gets 403). Swaps `payload-token` for a JWT signed as the target (a fresh session `sid` is added to the target's `sessions` first, or `auth()` returns null) and stashes the original token in the `ptf-impersonation-original` cookie. Stop via `/api/impersonation/stop`.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
|
|
@ -408,6 +416,8 @@ Whenever creating a new component or refactoring an existing component's styling
|
||||||
|
|
||||||
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
||||||
|
|
||||||
|
**Volumes**: prod containers mount persistent volumes for `/app/media` (uploads) and `/app/public/tiles` (gitignored map tile pyramids, uploaded to the server by hand). Both dirs are created in the image; see DEPLOYMENT.md Sections 4 and 8.
|
||||||
|
|
||||||
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
|
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
|
||||||
|
|
||||||
**Version bumps and release tags: `main` deploys only**: whenever a `/git` or `/git-master` workflow lands work on `main` (or a deploy is being cut), bump the version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` (never a bare version number). The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits, but never use a bare `git push --tags`, which sends only tags and not the branch. **Feature branches must never bump versions or push release tags**: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. Release tags exist solely to mark `main` deploys. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump on a `main` deploy (or pushing the deploy commit without its tag) makes releases indistinguishable and breaks version history.
|
**Version bumps and release tags: `main` deploys only**: whenever a `/git` or `/git-master` workflow lands work on `main` (or a deploy is being cut), bump the version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` (never a bare version number). The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits, but never use a bare `git push --tags`, which sends only tags and not the branch. **Feature branches must never bump versions or push release tags**: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped. Release tags exist solely to mark `main` deploys. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump on a `main` deploy (or pushing the deploy commit without its tag) makes releases indistinguishable and breaks version history.
|
||||||
|
|
@ -422,6 +432,10 @@ Whenever creating a new component or refactoring an existing component's styling
|
||||||
- Payload admin layout and importMap are auto-generated — do not edit by hand.
|
- Payload admin layout and importMap are auto-generated — do not edit by hand.
|
||||||
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
|
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
|
||||||
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`).
|
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or use Payload's migration runner (`bun run payload migrate`).
|
||||||
|
- **Payload 3 field-level access never rejects an update request**: when a field's `access` fn returns `false`, Payload silently deletes that field from the incoming data (`node_modules/payload/dist/fields/hooks/beforeValidate/promise.js`) and the save SUCCEEDS with the previous value. No 403, "Updated successfully." toast, other fields still save. It is enforcement without feedback; if you need a loud rejection or hidden dropdown options, that requires a collection `beforeChange` hook or a custom admin field component.
|
||||||
|
- **Admin-panel-visible access bugs that contradict passing API tests**: check the actual role permission rows in the DB first, then the field-strip semantics above. The dev DB once had `system:admin-access` hand-granted to division roles (tests build clean roles, so tests pass while the panel misbehaves). The 30s `loadUserPermissions` cache is per-user-id and safe, but remember it exists when editing role data.
|
||||||
|
- PATCHing a document that is open in someone's admin edit tab returns **423 Locked** (Payload document locks). Test with a different document or wait for the lock to clear.
|
||||||
|
- For replaying admin-panel requests from the terminal: `/api/access` returns the caller's resolved permission map (collection `read` results are either a bare boolean like `true` or `{ permission, where }`; handle both shapes), and an officer-grade JWT can be crafted exactly like the impersonation route does (add a session `sid` to the target user, then `jwtSign` with `getFieldsToSign`).
|
||||||
|
|
||||||
## Server Actions convention
|
## Server Actions convention
|
||||||
|
|
||||||
|
|
|
||||||
104
DEPLOYMENT.md
104
DEPLOYMENT.md
|
|
@ -17,7 +17,7 @@ Local full-stack dev with the same image shape is described at the end (`docker
|
||||||
│ │ (Docker- │ │ svc │ per-env) │
|
│ │ (Docker- │ │ svc │ per-env) │
|
||||||
│ │ file) │ │ + volume │ │
|
│ │ file) │ │ + volume │ │
|
||||||
│ │ + media │ └─────────────┘ │
|
│ │ + media │ └─────────────┘ │
|
||||||
│ │ volume │ │
|
│ │ volume │ │
|
||||||
│ └─────┬──────┘ │
|
│ └─────┬──────┘ │
|
||||||
│ │ scheduled jobs (docker exec → node payload bin) │
|
│ │ scheduled jobs (docker exec → node payload bin) │
|
||||||
└────────┼──────────────────────────────────────────────────┘
|
└────────┼──────────────────────────────────────────────────┘
|
||||||
|
|
@ -26,12 +26,12 @@ Local full-stack dev with the same image shape is described at the end (`docker
|
||||||
```
|
```
|
||||||
|
|
||||||
- **One image**, parameterized by env vars per environment. No image bake per env required.
|
- **One image**, parameterized by env vars per environment. No image bake per env required.
|
||||||
- **PostgreSQL** is provisioned by Coolify as a *separate service* in each environment (`dev`, `stg`, `prd`). The app talks to it via `DATABASE_URI` injected by Coolify.
|
- **PostgreSQL** is provisioned by Coolify as a _separate service_ in each environment (`dev`, `stg`, `prd`). The app talks to it via `DATABASE_URI` injected by Coolify.
|
||||||
- **Payload uploads** (`media/` collection) are written to a Coolify **Persistent Storage Volume** mounted at `/app/media` in the app container, so uploads survive container restarts, rollbacks, and rebuilds.
|
- **Payload uploads** (`media/` collection) are written to a Coolify **Persistent Storage Volume** mounted at `/app/media` in the app container, so uploads survive container restarts, rollbacks, and rebuilds.
|
||||||
- **Scheduled one-shot jobs** (game-tick, market-tick) run via `docker exec` Coolify Scheduled Tasks against the running app container — same image, same env, no second service required.
|
- **Scheduled one-shot jobs** (game-tick, market-tick) run via `docker exec` Coolify Scheduled Tasks against the running app container — same image, same env, no second service required.
|
||||||
- **The Discord bot ships inside the image** (`src/bot/`) and activates per-environment when `DISCORD_TOKEN` is set — prod only. It shares the same Postgres DB via the container env and inherits the single-instance constraint: **2 replicas = two bot instances** (duplicate command registration, double embeds/DMs). Notifications generated while the container is down are **not backfilled** (the Discord notification bridge polls with an in-memory cursor).
|
- **The Discord bot ships inside the image** (`src/bot/`) and activates per-environment when `DISCORD_TOKEN` is set — prod only. It shares the same Postgres DB via the container env and inherits the single-instance constraint: **2 replicas = two bot instances** (duplicate command registration, double embeds/DMs). Notifications generated while the container is down are **not backfilled** (the Discord notification bridge polls with an in-memory cursor).
|
||||||
|
|
||||||
> ⚠️ **Single-instance only.** The realtime SSE pipeline (`gameTick → /api/game-tick/notify → in-process bus → SSE /api/realtime → client router.refresh()`) uses an **in-memory subscriber bus** (`src/lib/realtime/bus.ts`) that is not shared across processes. Coolify *must* run **exactly 1 replica** of the app container per environment for SSE to work. If you scale the app horizontally, SSE stops working (live shipment/event log updates silently fail). Everything else continues to work; you'll need a distributed bus before you can scale. See `src/lib/realtime/bus.ts` and `AGENTS.md` for context.
|
> ⚠️ **Single-instance only.** The realtime SSE pipeline (`gameTick → /api/game-tick/notify → in-process bus → SSE /api/realtime → client router.refresh()`) uses an **in-memory subscriber bus** (`src/lib/realtime/bus.ts`) that is not shared across processes. Coolify _must_ run **exactly 1 replica** of the app container per environment for SSE to work. If you scale the app horizontally, SSE stops working (live shipment/event log updates silently fail). Everything else continues to work; you'll need a distributed bus before you can scale. See `src/lib/realtime/bus.ts` and `AGENTS.md` for context.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -67,7 +67,8 @@ For each environment (`dev`, `stg`, `prd`):
|
||||||
- **Don't** use the `docker-compose.yml` for the production app service — that file is for local dev (it bundles its own Postgres). Coolify manages the Postgres service for you.
|
- **Don't** use the `docker-compose.yml` for the production app service — that file is for local dev (it bundles its own Postgres). Coolify manages the Postgres service for you.
|
||||||
3. Set the environment variables (Section 5).
|
3. Set the environment variables (Section 5).
|
||||||
4. Attach a **Persistent Storage** entry mapping `/app/media` (read/write). This is where Payload stores uploaded media. The directory is created and chowned inside the image; just mount the volume on top.
|
4. Attach a **Persistent Storage** entry mapping `/app/media` (read/write). This is where Payload stores uploaded media. The directory is created and chowned inside the image; just mount the volume on top.
|
||||||
5. Deploy. First build will take a few minutes (Bun install + Next.js standalone build, `--max-old-space-size=8000` — make sure the build host has ≥ 4 GB free RAM, ideally 8).
|
5. Attach a second **Persistent Storage** entry mapping `/app/public/tiles` (read/write) if you serve map tile pyramids. The directory is created and chowned inside the image; upload tile sets into the volume (see Section 8).
|
||||||
|
6. Deploy. First build will take a few minutes (Bun install + Next.js standalone build, `--max-old-space-size=8000` — make sure the build host has ≥ 4 GB free RAM, ideally 8).
|
||||||
|
|
||||||
### Build args you may want to override
|
### Build args you may want to override
|
||||||
|
|
||||||
|
|
@ -77,25 +78,25 @@ The Dockerfile pins Bun 1.3.11 (**build-time only**) and Node 22 alpine (runtime
|
||||||
|
|
||||||
## 5. Environment variables (per environment)
|
## 5. Environment variables (per environment)
|
||||||
|
|
||||||
| Var | Required | Example / Notes |
|
| Var | Required | Example / Notes |
|
||||||
|---|---|---|
|
| ------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DATABASE_URI` | ✅ | Coolify auto-injects this from the per-env Postgres service if linked. Format: `postgres://<user>:<pwd>@<host>:5432/<db>` |
|
| `DATABASE_URI` | ✅ | Coolify auto-injects this from the per-env Postgres service if linked. Format: `postgres://<user>:<pwd>@<host>:5432/<db>` |
|
||||||
| `PAYLOAD_SECRET` | ✅ | Random 32+ chars. `openssl rand -hex 16`. **Unique per environment.** |
|
| `PAYLOAD_SECRET` | ✅ | Random 32+ chars. `openssl rand -hex 16`. **Unique per environment.** |
|
||||||
| `APP_URL` | ✅ | Public base URL for this environment, e.g. `https://dev.ptf.example.com` |
|
| `APP_URL` | ✅ | Public base URL for this environment, e.g. `https://dev.ptf.example.com` |
|
||||||
| `GAME_TICK_NOTIFY_SECRET` | ✅ | Shared secret for `/api/game-tick/notify`. **Must match** the value used by your scheduled job (Sec. 6). |
|
| `GAME_TICK_NOTIFY_SECRET` | ✅ | Shared secret for `/api/game-tick/notify`. **Must match** the value used by your scheduled job (Sec. 6). |
|
||||||
| `EMAIL_FROM_ADDRESS` | — | Outbound email "from" address (e.g. `arma@onyxsimple.com`) |
|
| `EMAIL_FROM_ADDRESS` | — | Outbound email "from" address (e.g. `arma@onyxsimple.com`) |
|
||||||
| `EMAIL_FROM_NAME` | — | Display name (e.g. `Arma`) |
|
| `EMAIL_FROM_NAME` | — | Display name (e.g. `Arma`) |
|
||||||
| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) |
|
| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) |
|
||||||
| `EMAIL_PORT` | — | `587` |
|
| `EMAIL_PORT` | — | `587` |
|
||||||
| `EMAIL_USERNAME` | — | SMTP user |
|
| `EMAIL_USERNAME` | — | SMTP user |
|
||||||
| `EMAIL_PASSWORD` | — | SMTP password / API key |
|
| `EMAIL_PASSWORD` | — | SMTP password / API key |
|
||||||
| `DISCORD_TOKEN` | iff bot enabled in this env | Set in **prod only** — presence activates the bot (activation gate below). |
|
| `DISCORD_TOKEN` | iff bot enabled in this env | Set in **prod only** — presence activates the bot (activation gate below). |
|
||||||
| `DISCORD_GUILD_ID` | iff `DISCORD_TOKEN` set | Config throws at import if the token is set and this is missing. |
|
| `DISCORD_GUILD_ID` | iff `DISCORD_TOKEN` set | Config throws at import if the token is set and this is missing. |
|
||||||
| `DISCORD_OPS_CHANNEL_ID` | — | Ops channel for attendance RSVP embeds. |
|
| `DISCORD_OPS_CHANNEL_ID` | — | Ops channel for attendance RSVP embeds. |
|
||||||
| `DISCORD_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. |
|
| `DISCORD_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. |
|
||||||
| `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). |
|
| `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). |
|
||||||
| `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. |
|
| `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. |
|
||||||
| `DISCORD_NOTIFICATION_POLL_MS` | — | Notification bridge poll interval; default `20000`. |
|
| `DISCORD_NOTIFICATION_POLL_MS` | — | Notification bridge poll interval; default `20000`. |
|
||||||
|
|
||||||
> Never copy the dev `PAYLOAD_SECRET` into prod. Never copy `GAME_TICK_NOTIFY_SECRET`. Generate fresh per environment.
|
> Never copy the dev `PAYLOAD_SECRET` into prod. Never copy `GAME_TICK_NOTIFY_SECRET`. Generate fresh per environment.
|
||||||
|
|
||||||
|
|
@ -121,13 +122,13 @@ The `game-tick` and `market-tick` Payload bins are registered on `payload.config
|
||||||
|
|
||||||
### Option A — Coolify Scheduled Tasks (recommended)
|
### Option A — Coolify Scheduled Tasks (recommended)
|
||||||
|
|
||||||
Coolify has a **Scheduled Tasks** feature per service (`<service> → Scheduled Tasks → Add`). Each scheduled task runs a command inside the *running service container* — equivalent to `docker exec`. Add:
|
Coolify has a **Scheduled Tasks** feature per service (`<service> → Scheduled Tasks → Add`). Each scheduled task runs a command inside the _running service container_ — equivalent to `docker exec`. Add:
|
||||||
|
|
||||||
| Job | Schedule (cron) | Command |
|
| Job | Schedule (cron) | Command |
|
||||||
|---|---|---|
|
| ----------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||||
| Process shipments | `*/5 * * * *` (every 5 min, tune to match `gameTickIntervalMinutes` in your Game Rules global) | `node node_modules/payload/bin.js game-tick` |
|
| Process shipments | `*/5 * * * *` (every 5 min, tune to match `gameTickIntervalMinutes` in your Game Rules global) | `node node_modules/payload/bin.js game-tick` |
|
||||||
| Refresh market | `*/10 * * * *` (every 10 min — adjust as needed) | `node node_modules/payload/bin.js market-tick` |
|
| Refresh market | `*/10 * * * *` (every 10 min — adjust as needed) | `node node_modules/payload/bin.js market-tick` |
|
||||||
| Server presence | `* * * * *` (every minute) | `node node_modules/payload/bin.js server-tick` |
|
| Server presence | `* * * * *` (every minute) | `node node_modules/payload/bin.js server-tick` |
|
||||||
|
|
||||||
The commands run inside the app container with the app's env (incl. `DATABASE_URI`, `APP_URL`, `GAME_TICK_NOTIFY_SECRET`), so the bins talk to the same DB and post the notify callback correctly.
|
The commands run inside the app container with the app's env (incl. `DATABASE_URI`, `APP_URL`, `GAME_TICK_NOTIFY_SECRET`), so the bins talk to the same DB and post the notify callback correctly.
|
||||||
|
|
||||||
|
|
@ -149,6 +150,7 @@ The container name comes from Coolify's per-service container name (visible on t
|
||||||
### Confirming a tick ran
|
### Confirming a tick ran
|
||||||
|
|
||||||
Each tick:
|
Each tick:
|
||||||
|
|
||||||
- Updates `Shipments` rows in DB (status, fuel, arrival).
|
- Updates `Shipments` rows in DB (status, fuel, arrival).
|
||||||
- `POST`s to `/api/game-tick/notify` (internal SSE bus push → clients `router.refresh()`). If no connected clients, this just 204s.
|
- `POST`s to `/api/game-tick/notify` (internal SSE bus push → clients `router.refresh()`). If no connected clients, this just 204s.
|
||||||
- Emits `GameEventLog` entries you'll see in the Payload admin → Game Event Logs.
|
- Emits `GameEventLog` entries you'll see in the Payload admin → Game Event Logs.
|
||||||
|
|
@ -174,12 +176,25 @@ For dev (where `push: false` in `payload.config.ts`), run `bun run payload migra
|
||||||
Payload's `Media` collection (`src/collections/Media.ts`) uses `upload: true` with no storage adapter, so files land on local disk under `<projectRoot>/media` (i.e. `/app/media` inside the container, since `WORKDIR /app` and `process.cwd() = /app`).
|
Payload's `Media` collection (`src/collections/Media.ts`) uses `upload: true` with no storage adapter, so files land on local disk under `<projectRoot>/media` (i.e. `/app/media` inside the container, since `WORKDIR /app` and `process.cwd() = /app`).
|
||||||
|
|
||||||
In Coolify:
|
In Coolify:
|
||||||
|
|
||||||
1. On the app service → **Persistent Storage → Add Path**.
|
1. On the app service → **Persistent Storage → Add Path**.
|
||||||
2. Host path or named volume → mapped to `/app/media` in the container.
|
2. Host path or named volume → mapped to `/app/media` in the container.
|
||||||
3. Coolify preserves this volume across deploys, **including rollbacks**.
|
3. Coolify preserves this volume across deploys, **including rollbacks**.
|
||||||
|
|
||||||
Without this, every redeploy wipes user uploads.
|
Without this, every redeploy wipes user uploads.
|
||||||
|
|
||||||
|
### Map tiles volume (`/app/public/tiles`)
|
||||||
|
|
||||||
|
Map basemaps in `tiles` mode serve XYZ pyramids from `public/tiles/<map-slug>/{z}/{x}/{y}.jpg`.
|
||||||
|
The `public/tiles` directory is **gitignored** (like `media/`), so production images do not contain tiles; mount a
|
||||||
|
**Persistent Storage** volume at `/app/public/tiles` and get the tiles there by either:
|
||||||
|
|
||||||
|
- copying them into the volume's host path on the server (host-path mount: `scp -r public/tiles/antarctica/* <host-path>/`), or
|
||||||
|
- `docker cp` into the running app container before the volume is in place, or from the host into the volume.
|
||||||
|
|
||||||
|
Files added to the volume are served immediately; no restart needed. Keep the pyramid's top zoom level at the source
|
||||||
|
texture's native detail (256 x 2^z px >= source pixels) and set the map's `maxZoom` to that level.
|
||||||
|
|
||||||
> Note on the local-disk default: this works fine for a self-hosted single-instance deployment, but it does **not** scale beyond one replica (same caveat as the SSE bus). If you outgrow the single-instance architecture (more replicas, multi-host), switch to an S3-compatible storage adapter — add it to `payload.config.ts` under `plugins` (the project already has a `// storage-adapter-placeholder` hook for this) and drop the `/app/media` volume.
|
> Note on the local-disk default: this works fine for a self-hosted single-instance deployment, but it does **not** scale beyond one replica (same caveat as the SSE bus). If you outgrow the single-instance architecture (more replicas, multi-host), switch to an S3-compatible storage adapter — add it to `payload.config.ts` under `plugins` (the project already has a `// storage-adapter-placeholder` hook for this) and drop the `/app/media` volume.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -193,7 +208,13 @@ Without this, every redeploy wipes user uploads.
|
||||||
Response shape:
|
Response shape:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok": true, "uptime": 1243, "version": "0.1.6", "commit": "a1b2c3d", "buildTime": "2026-08-12T20:30:00.000Z" }
|
{
|
||||||
|
"ok": true,
|
||||||
|
"uptime": 1243,
|
||||||
|
"version": "0.1.6",
|
||||||
|
"commit": "a1b2c3d",
|
||||||
|
"buildTime": "2026-08-12T20:30:00.000Z"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -210,7 +231,7 @@ Recommended flow:
|
||||||
|
|
||||||
> **Tags only from `main` deploys.** Create and push the `vX.Y.Z` release tag only when deploying from `main`. Never bump versions or push release tags from feature branches: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped.
|
> **Tags only from `main` deploys.** Create and push the `vX.Y.Z` release tag only when deploying from `main`. Never bump versions or push release tags from feature branches: parallel branches bumping the same semver line desync `package.json` from the tags that actually deploy, and at merge time the version history no longer matches what shipped.
|
||||||
|
|
||||||
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's *Advanced* settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
|
> **Version metadata in builds.** The image build embeds git info into `src/generated/versionInfo.json` (shown in the admin VersionOverlay and on `/api/version`). Coolify **deletes `.git`** from the build context, so the build reads the commit SHA from the `SOURCE_COMMIT` build arg instead of `git` (see the Dockerfile's `ARG SOURCE_COMMIT` block). For that to work, enable **"Include Source Commit in Build"** under the application's _Advanced_ settings (and leave "Inject Build Args to Dockerfile" on, which is the default) — otherwise the version overlay falls back to `version` only.
|
||||||
>
|
>
|
||||||
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
|
> **Commit metadata availability.** On Coolify builds, `SOURCE_COMMIT` is the commit SHA, but tags, dates, and subjects are not injected automatically. If the build environment provides `GIT_COMMIT_DATE` and `GIT_COMMIT_SUBJECT`, the generator records them as `lastUpdated` and `commitMessage`; otherwise `lastUpdated` falls back to the image build time and the commit message remains unavailable. The existing `canSeeCommit` permission still gates commit details in the overlay.
|
||||||
>
|
>
|
||||||
|
|
@ -229,7 +250,8 @@ Coolify supports two rollout modes:
|
||||||
1. **Automatic rollback on failed health**: if the new container never becomes healthy (3 failed `/api/health` probes within the `start_period`), Coolify keeps the old container running and doesn't route traffic to the new one. Brings you back to the previous working version with no action.
|
1. **Automatic rollback on failed health**: if the new container never becomes healthy (3 failed `/api/health` probes within the `start_period`), Coolify keeps the old container running and doesn't route traffic to the new one. Brings you back to the previous working version with no action.
|
||||||
2. **Manual rollback**: in the service dashboard,/files history → pick a previous image tag → "Rollback". This redeploys that exact image with the existing `/app/media` volume intact (because the volume is mounted, not tied to the image).
|
2. **Manual rollback**: in the service dashboard,/files history → pick a previous image tag → "Rollback". This redeploys that exact image with the existing `/app/media` volume intact (because the volume is mounted, not tied to the image).
|
||||||
|
|
||||||
Rollbacks do **not** roll the database schema. If a deploy includes a non-reversible migration that has already run, rolling back the *image* but keeping the DB can break the older image's assumptions. Mitigations:
|
Rollbacks do **not** roll the database schema. If a deploy includes a non-reversible migration that has already run, rolling back the _image_ but keeping the DB can break the older image's assumptions. Mitigations:
|
||||||
|
|
||||||
- Only ship additive migrations (don't drop columns in the same deploy that removes their consumer code — drop them in a follow-up deploy instead).
|
- Only ship additive migrations (don't drop columns in the same deploy that removes their consumer code — drop them in a follow-up deploy instead).
|
||||||
- For destructive changes, take a Coolify DB snapshot first.
|
- For destructive changes, take a Coolify DB snapshot first.
|
||||||
|
|
||||||
|
|
@ -269,15 +291,15 @@ docker compose exec app sh # shell inside the app container
|
||||||
|
|
||||||
## 13. Quick troubleshooting
|
## 13. Quick troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely cause | Fix |
|
| Symptom | Likely cause | Fix |
|
||||||
|---|---|---|
|
| ------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Container restart loops, never healthy | DB connection unreachable | Verify `DATABASE_URI` resolves from inside the container (`docker compose exec app wget -qO- $DATABASE_URI:5432` or `nc -zv ...`). Coolify: confirm the Postgres and app services are in the same Project so Coolify's network reaches it. |
|
| Container restart loops, never healthy | DB connection unreachable | Verify `DATABASE_URI` resolves from inside the container (`docker compose exec app wget -qO- $DATABASE_URI:5432` or `nc -zv ...`). Coolify: confirm the Postgres and app services are in the same Project so Coolify's network reaches it. |
|
||||||
| 401 loop after deploy | `PAYLOAD_SECRET` changed without rotating user sessions | Keep `PAYLOAD_SECRET` constant across deploys of the same env. If you must rotate, users will need to re-log in (cookies are JWT-signed against this secret). |
|
| 401 loop after deploy | `PAYLOAD_SECRET` changed without rotating user sessions | Keep `PAYLOAD_SECRET` constant across deploys of the same env. If you must rotate, users will need to re-log in (cookies are JWT-signed against this secret). |
|
||||||
| Realtime updates stopped working | More than 1 replica running, OR scheduled tick stopped | Limit replicas to 1. Check the scheduled tasks in Coolify are firing and `GameEventLogs` show fresh `gameTick` events. |
|
| Realtime updates stopped working | More than 1 replica running, OR scheduled tick stopped | Limit replicas to 1. Check the scheduled tasks in Coolify are firing and `GameEventLogs` show fresh `gameTick` events. |
|
||||||
| First build OOMs | Host has < 8 GB RAM during Next build | The `bun run build` script sets `--max-old-space-size=8000`. Give the Coolify build host at least 8 GB, or lower this in `package.json#scripts.build` (memory will become the limiter earlier). |
|
| First build OOMs | Host has < 8 GB RAM during Next build | The `bun run build` script sets `--max-old-space-size=8000`. Give the Coolify build host at least 8 GB, or lower this in `package.json#scripts.build` (memory will become the limiter earlier). |
|
||||||
| `bun run db push` fails PostGIS error | This is expected (`must be owner of table spatial_ref_sys`) | Use `bun run payload migrate` only. See `AGENTS.md` Gotchas. |
|
| `bun run db push` fails PostGIS error | This is expected (`must be owner of table spatial_ref_sys`) | Use `bun run payload migrate` only. See `AGENTS.md` Gotchas. |
|
||||||
| Token set but bot never logs in / silent bot death | Entrypoint gate, config throw, or revoked/invalid token | Check the container logs for the bot's fail-fast line. Confirm `DISCORD_GUILD_ID` is set (the config throws at import if missing) and the token is valid/not revoked. The web app is unaffected — the bot is an in-process subordinate. |
|
| Token set but bot never logs in / silent bot death | Entrypoint gate, config throw, or revoked/invalid token | Check the container logs for the bot's fail-fast line. Confirm `DISCORD_GUILD_ID` is set (the config throws at import if missing) and the token is valid/not revoked. The web app is unaffected — the bot is an in-process subordinate. |
|
||||||
| Scheduled task / bot dies with `Illegal instruction (core dumped)` | Running Bun on the host CPU (no AVX/AVX2) | The runtime image is Node-only — never `docker exec ... bun ...`. Bins: `node node_modules/payload/bin.js <cmd>`; bot: `node --import tsx src/bot/index.ts`. |
|
| Scheduled task / bot dies with `Illegal instruction (core dumped)` | Running Bun on the host CPU (no AVX/AVX2) | The runtime image is Node-only — never `docker exec ... bun ...`. Bins: `node node_modules/payload/bin.js <cmd>`; bot: `node --import tsx src/bot/index.ts`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ ENV NODE_ENV=production \
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# The `node` user already exists in `node:*-alpine` images.
|
# The `node` user already exists in `node:*-alpine` images.
|
||||||
RUN mkdir -p /app/media \
|
RUN mkdir -p /app/media /app/public/tiles \
|
||||||
&& chown -R node:node /app
|
&& chown -R node:node /app
|
||||||
|
|
||||||
# 1) Next.js standalone runtime (server.js + traced node_modules + .next).
|
# 1) Next.js standalone runtime (server.js + traced node_modules + .next).
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
# Persist Payload uploads between rebuilds locally.
|
# Persist Payload uploads between rebuilds locally.
|
||||||
- ./media:/app/media
|
- ./media:/app/media
|
||||||
|
# Map tile pyramids (gitignored; generated locally, uploaded in prod).
|
||||||
|
- ./public/tiles:/app/public/tiles
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -63,4 +65,4 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
|
||||||
82
docs/arma-bridge/crate-sync.md
Normal file
82
docs/arma-bridge/crate-sync.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Crate Sync Contract
|
||||||
|
|
||||||
|
Binding contract for supply-crate (box) sync between the Arma 3 bridge mod and the web app. Todos 5 (web) and 6 (mod) implement exactly what is written here; do not redesign it while implementing.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Supply crates spawned in-game need to appear on the web, and web-side crate definitions need to spawn in-game. This document fixes the two channels that carry that data:
|
||||||
|
|
||||||
|
- **Events (mod to web)**: the `box.*` event family, delivered through the existing `flush_events` ingestion (`POST /api/arma/v1/events`).
|
||||||
|
- **Commands (web to mod)**: the `crate.*` command types, delivered through the existing `pull_commands` / `ack_command` loop.
|
||||||
|
|
||||||
|
Crates are deliberately OUT of the operation ledger: `operation-events` validation rejects array payloads for `operation.*` types, and crates are not operations. The web events route accepts any `type` string, so the `box.*` family flows through existing ingestion unchanged, array payloads included.
|
||||||
|
|
||||||
|
The bridge extension DLL is closed: only `heartbeat`, `flush_events`, `pull_commands`, `ack_command`, and `fetch_tickets` channels exist. Crate sync adds no new extension channels; it rides the event flush and the command poll.
|
||||||
|
|
||||||
|
## Event family
|
||||||
|
|
||||||
|
All payloads are flat arrays. `fn_queueEvent.sqf` stores queue entries as `[_id, _type, _payload, serverTime]` and defaults `_payload` to `[]`, so every event below is an array of positional values.
|
||||||
|
|
||||||
|
| Type | Payload | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `box.register` | `[boxId, boxClass, displayName, locationX, locationY, locationZ]` | A tracked box object exists at a position. |
|
||||||
|
| `box.sync` | `[boxId, [[itemClass, count], ...]]` | Full cargo snapshot for one box. |
|
||||||
|
| `box.deleted` | `[boxId]` | The box object no longer exists. |
|
||||||
|
|
||||||
|
`box.sync` cargo rules:
|
||||||
|
|
||||||
|
- TOP-LEVEL cargo only: `itemCargo`, `magazineCargo`, `weaponCargo`, and `backpackCargo` merged by class into `[itemClass, count]` pairs.
|
||||||
|
- Nested container contents are a deferred v1 extension. There is NO `containerIndex` field and no per-container breakdown; anything inside a nested container is invisible to this sync.
|
||||||
|
|
||||||
|
## Box identity
|
||||||
|
|
||||||
|
- Box id format: `box:<sessionId>:<seq>`.
|
||||||
|
- `sessionId` is minted in `fn_serverInit.sqf` as `format ["%1-%2", worldName, systemTime joinString ""]`, unique per mission load. The event sequence and queue are reset in the same place, so `seq` is a missionNamespace counter that starts at 0 each mission load.
|
||||||
|
- The mod NEVER embeds a server identifier in the box id. No server identifier exists mod-side. The WEB resolves the owning server authoritatively from the authenticated ingestion connection, the same pattern as `eventId = ${server.serverId}:${event.id}` in `src/app/api/arma/v1/events/route.ts`, which stores `server: server.id` (the `game-servers` row) on every raw event.
|
||||||
|
- A mission restart mints a new generation (new sessionId). Rows from older sessionIds remain in the database as stale snapshots; they are not deleted. The UI filters by the newest generation per server.
|
||||||
|
|
||||||
|
## Sync cadence
|
||||||
|
|
||||||
|
- Boxes are scanned and synced every 2nd bridge cycle, roughly every 30 seconds (the bridge cycle loop in `fn_serverInit.sqf` sleeps 15 seconds).
|
||||||
|
- Each sync is a FULL snapshot per box: `box.sync` replaces the box's cargo wholesale, it is never a delta.
|
||||||
|
- Dedup is server-side by `eventId` (the events route does a batched existence lookup and also survives unique-constraint races), so a retried flush cannot double-apply.
|
||||||
|
- Per-cycle candidate cap: 64 boxes per scan cycle. The cap bounds event volume; boxes beyond the cap wait for the next cycle.
|
||||||
|
|
||||||
|
## Web-side consumption rules
|
||||||
|
|
||||||
|
- Upsert by `boxId`: create or update the SupplyBoxes row, set `lastSyncedAt = now`.
|
||||||
|
- `box.deleted` RETAINS the row: it marks the row `status: deleted` and keeps both the row and the raw `arma-sync-events` evidence for provenance. It never hard-deletes. A later `box.register` or `box.sync` for the same boxId reactivates the row.
|
||||||
|
- Null-server guard: if the authenticated ingestion context yields a null server (`raw.server` null), the event is malformed and is a NO-OP with a logged warning. The `SupplyBoxes.server` relationship is REQUIRED and never receives null; no SupplyBoxes write happens on the malformed path.
|
||||||
|
- Provenance: every SupplyBoxes row links to the raw `arma-sync-events` rows that produced it (by `eventId`), so any web-side state can be traced back to the exact mod event.
|
||||||
|
|
||||||
|
## Command envelope and schemas
|
||||||
|
|
||||||
|
- Envelope: `[commandId, commandType, payload]`. This is exactly what `fn_pollCommands` passes and what `fn_executeCommand.sqf` destructures: `_command params ["_id", "_type", ["_payload", []]]`. Commands are stored in the `arma-commands` collection (`commandId` unique, `server` required relationship, `payload` JSON, `status` queued/delivered/succeeded/failed).
|
||||||
|
- Crate arguments live INSIDE `payload`, also as arrays:
|
||||||
|
|
||||||
|
| Type | Payload arguments | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `crate.spawn` | `[boxId, boxClass, x, y, z]` | Create the box object at the position and register it. |
|
||||||
|
| `crate.apply` | `[boxId, [[itemClass, count], ...]]` | Replace the box's top-level cargo with the given items. |
|
||||||
|
| `crate.despawn` | `[boxId]` | Delete the box object and remove it from the registry. |
|
||||||
|
|
||||||
|
- Expected ack results: every command is acked exactly once via `ptf_bridge.ack_command` with `[commandId, success, result, error]` (the shape `fn_executeCommand.sqf` already uses). Successful acks carry `success: true` and an empty `result` array. Failed acks carry `success: false` and a human-readable string in the `error` slot. The web side records the outcome through `acknowledgeCommand` (`src/lib/arma-bridge/commands.ts`), which sets `status: succeeded` or `status: failed` with the result and error.
|
||||||
|
|
||||||
|
## Failure rules
|
||||||
|
|
||||||
|
- Unknown `boxId` on `crate.apply`: the mod acks the command as a FAILURE with an error string; the command is marked `failed` web-side. It is not retried by the mod.
|
||||||
|
- Partial sync never blocks other boxes: a failure while processing one box's `register`/`sync`/`deleted` event does not abort the remaining boxes. Events are independent rows; one bad box costs only its own update.
|
||||||
|
- Duplicate eventIds are deduped server-side (counted as duplicates, no double-apply), so mod-side retry of a flush is safe.
|
||||||
|
- Ack guards (existing behavior in `acknowledgeCommand`): duplicate acks are no-ops (the first result wins), acking a command that was never delivered is rejected (`not-delivered`), and a delivered command whose 5-minute lease expires without an ack is re-queued by `recoverExpiredCommands`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All channels sit behind `requireArmaServer` (`ARMA_BRIDGE_API_KEY` plus the `x-arma-server-id` header). This covers event ingestion and command pull/ack alike.
|
||||||
|
- The owning server is always derived from the authenticated connection, never from payload content. Box ids and command payloads carry no server identity and are never trusted to do so.
|
||||||
|
|
||||||
|
## Mod-side behavior
|
||||||
|
|
||||||
|
- Registry: a missionNamespace variable `ptf_boxRegistry` holding `[boxId, objectHandle]` pairs for every tracked box.
|
||||||
|
- Deletion detection: ONLY via `isNull` checks against registry entries. The mod never rescans the world to discover deletions. A registry entry whose handle has become null produces a `box.deleted` event and is removed from the registry.
|
||||||
|
- Replacement objects: an object that replaces a deleted box (new handle) is a NEW boxId with a new sequence number. Box ids are never reused.
|
||||||
|
- Container predicate: a locally duplicated `fn_isBoxContainer.sqf` decides which objects are tracked. It checks `ReammoBox_F`, `WeaponHolder`, and `Transport*` config checks. It has NO dependency on `ptf_loot`; the predicate is intentionally duplicated so the bridge addon stays independent.
|
||||||
|
|
@ -88,6 +88,12 @@ runs on timestamps, not tick counts (src/lib/map/construction.ts):
|
||||||
- the game tick sweep (`processConstructionTick`) only completes: sites whose
|
- the game tick sweep (`processConstructionTick`) only completes: sites whose
|
||||||
`constructionCompletesAt` has passed flip to `complete` and emit
|
`constructionCompletesAt` has passed flip to `complete` and emit
|
||||||
`structure:construction-complete` (idempotent).
|
`structure:construction-complete` (idempotent).
|
||||||
|
- crew speed: blueprints with `constructionCrewRequired` > 0 refuse to start
|
||||||
|
with no labor (`no_crew`) and build at the staffed fraction of full speed
|
||||||
|
(floor 0.25); labor-classifications carry `buildEfficiency` per worker;
|
||||||
|
`setLaborHeadcount` re-prices the remaining work on a building site via
|
||||||
|
`applyCrewRateChange` (`src/lib/base/constructionSpeed.ts`): the finished
|
||||||
|
fraction is preserved and the deadline pulls in or pushes out.
|
||||||
|
|
||||||
`structure:placed` is emitted at placement time. Blueprint `requiredTech`
|
`structure:placed` is emitted at placement time. Blueprint `requiredTech`
|
||||||
stays display-only in the placement dialog (no research system exists).
|
stays display-only in the placement dialog (no research system exists).
|
||||||
|
|
@ -104,10 +110,11 @@ stays display-only in the placement dialog (no research system exists).
|
||||||
Hidden, where Auto labels paved roads always and dirt/trail roads only at
|
Hidden, where Auto labels paved roads always and dirt/trail roads only at
|
||||||
close zoom, 4 m/px or finer); Save.
|
close zoom, 4 m/px or finer); Save.
|
||||||
- **New zone**: click at least 3 vertices; set name, description, type
|
- **New zone**: click at least 3 vertices; set name, description, type
|
||||||
(water/land/territory/border), and styling (fill color, edge color, fill
|
(water/land/territory/border/other), and styling (fill color, edge color,
|
||||||
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
|
fill opacity 0-1, edge width, name-label toggle); Save. Territory and
|
||||||
Border are informational political boundaries (they never gate placement;
|
Country Border are informational political boundaries and Other is for
|
||||||
only water does) and default to amber 0.1 / violet 0.06 fill.
|
generic areas like named compounds (they never gate placement; only water
|
||||||
|
does); defaults are amber 0.1, violet 0.06, and slate 0.08 respectively.
|
||||||
- **New node**: click once to set the position; set name, description,
|
- **New node**: click once to set the position; set name, description,
|
||||||
resource, richness; Save.
|
resource, richness; Save.
|
||||||
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
|
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
|
||||||
|
|
@ -165,6 +172,26 @@ stays display-only in the placement dialog (no research system exists).
|
||||||
defaults (`paved` light gray, `dirt` tan, `trail` dimmer; water `#38bdf8`
|
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
|
0.25 fill, land `#4ade80` 0.08 fill). Invalid hex is rejected at save time
|
||||||
(both the authoring UI actions and the import route).
|
(both the authoring UI actions and the import route).
|
||||||
|
- **Compounds**: structures may set `parentStructure` (a compound hub) at
|
||||||
|
placement (dialog picker; same map, no nesting). Operational children pool
|
||||||
|
storage into the hub: `/api/map-features` folds child storage totals into
|
||||||
|
the hub feature, hides child map labels, and reports `compoundChildren`;
|
||||||
|
the hub ribbon and popup footer show "Compound: N buildings". Aggregation
|
||||||
|
math: `src/lib/map/compounds.ts`.
|
||||||
|
- **Adjacency auras**: blueprints may declare an "Adjacency Aura"
|
||||||
|
(production multiplier or capacity bonus over a meter range) that projects
|
||||||
|
onto operational neighbors only; the production tick stacks the aura with
|
||||||
|
module effects (`src/lib/base/adjacency.ts`).
|
||||||
|
|
||||||
|
### World Map picker
|
||||||
|
|
||||||
|
`/map` renders a grid of eligible map cards (basemap thumbnail, name, campaign,
|
||||||
|
world size); clicking one opens `/map/[id]`. Eligibility (`src/lib/map/screen.ts`):
|
||||||
|
`worldMap` flag on the map, active, and not tied to a custom (side) campaign.
|
||||||
|
Optional intel fields on maps (description, general weather, real-world
|
||||||
|
lat/long, ease of access, faction control) show on the picker cards when set.
|
||||||
|
The in-view map switcher was removed; mission-only maps never appear here but
|
||||||
|
remain attachable to missions.
|
||||||
|
|
||||||
### GeoJSON import
|
### GeoJSON import
|
||||||
|
|
||||||
|
|
@ -217,7 +244,7 @@ Geometry mapping:
|
||||||
| ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `LineString` | `map-roads` | `name`, `description` (optional), `surface` (`paved`\|`dirt`\|`trail`, default `paved`), `speedMultiplier` (default 1), `strokeColor` (`#rrggbb`), `strokeWeight`, `strokeStyle` (`solid` default, `dashed`, `dotted`) |
|
| `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` |
|
| `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) |
|
| `Point` | `resource-nodes` | `name`, `description` (optional), `resource` (resource ID) or `resourceName` (exact match), `richness` (default 100), `icon` (marker set key, default `marker`) |
|
||||||
|
|
||||||
GeoJSON positions are interpreted directly as map meters: `[x, y]` =
|
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
|
[east, south] on the map grid (no axis flip on import; the map UI applies its
|
||||||
|
|
|
||||||
75
docs/operations/runbook.md
Normal file
75
docs/operations/runbook.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Operations runbook
|
||||||
|
|
||||||
|
Operational procedures for the operation ledger, its rollout controls, and the
|
||||||
|
Arma command bridge. Everything here assumes the roadmap in
|
||||||
|
`.omo/plans/ptf-4x-tarkov-milsim-roadmap.md` has shipped.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
- All operation schema lives in named Payload migrations under `src/migrations/`:
|
||||||
|
`20260918_180851_add_operation_ledger`, `20260918_194842_add_operation_allocation`,
|
||||||
|
`20260918_222549_add_operation_readiness`, `20260920_044743_add_zone_pressure`.
|
||||||
|
- Apply with `bun run payload migrate` (dev uses `push: false`; never `bun run db push`,
|
||||||
|
which fails on `spatial_ref_sys` ownership).
|
||||||
|
- Verify status with `bun run payload migrate --status` (or `bun run migrate --status`).
|
||||||
|
- Regenerated artifacts after schema or collection changes: `bun run generate:types`
|
||||||
|
and `bun run generate:importmap`. Never hand-edit `src/payload-types.ts`,
|
||||||
|
`src/payload-generated-schema.ts`, or `src/app/(payload)/admin/importMap.js`.
|
||||||
|
|
||||||
|
## Rollout and rollback-by-disable
|
||||||
|
|
||||||
|
- Kill switch: set `OPERATION_LEDGER_ENABLED=false` in the environment. Raw
|
||||||
|
`arma-sync-events` evidence continues to be stored (that collection's writes
|
||||||
|
never depend on the flag); only the derived ledger (operation events and
|
||||||
|
effects) is skipped. This is the staged-activation control.
|
||||||
|
- Re-enable by removing the flag or setting it to anything other than `"false"`.
|
||||||
|
- Catch-up after a disabled window or a deploy gap: run the backfill action
|
||||||
|
(`reconcileBackfill` in `src/app/(frontend)/operations/actions.ts`, requires
|
||||||
|
`operation-events:update`). It replays raw events with no ledger row;
|
||||||
|
`processOperationEvent` is idempotent by `sourceEvent`, so it is safe to run
|
||||||
|
repeatedly.
|
||||||
|
|
||||||
|
## Duplicate and rejected event recovery
|
||||||
|
|
||||||
|
- Inbound dedup is by `eventId` (`<serverId>:<event.id>`, unique). A duplicate
|
||||||
|
batch is a no-op.
|
||||||
|
- Rejected (`invalid-payload`, `unsupported-version`) and dead-lettered
|
||||||
|
(`unsupported-type`) rows are retained as evidence and derive zero effects.
|
||||||
|
- Replay a single rejected/dead-letter row from the AAR page (Reconciliation
|
||||||
|
panel) or the `reconcileOperationEvent` action. Replay re-validates the
|
||||||
|
immutable raw event and updates the row in place with a reconciliation note;
|
||||||
|
if still invalid it stays rejected and no effects are created.
|
||||||
|
- Raw sync events are never deleted or rewritten, by code or by hand.
|
||||||
|
|
||||||
|
## Command lease expiry and recovery
|
||||||
|
|
||||||
|
- `arma-commands` lifecycle: `queued` -> `delivered` (lease starts, `deliveredAt`
|
||||||
|
set) -> `succeeded`/`failed` (terminal, `completedAt` set).
|
||||||
|
- Lease timeout: 5 minutes (`COMMAND_LEASE_TIMEOUT_MS` in
|
||||||
|
`src/lib/arma-bridge/commands.ts`). Every pull request first re-queues
|
||||||
|
delivered commands whose lease expired without an ack, so a lost command is
|
||||||
|
re-delivered on the next pull.
|
||||||
|
- Acks are guarded: a duplicate ack is an accepted no-op (first result wins);
|
||||||
|
acking a command that was never delivered is rejected with HTTP 409.
|
||||||
|
|
||||||
|
## Inventory and economy repair
|
||||||
|
|
||||||
|
- Extraction deposits and locker credits are preflighted before any write; a
|
||||||
|
full HQ or locker produces a `failed` effect (never a silent loss, never a
|
||||||
|
partial deposit). Repair a failed effect by freeing capacity, then replaying
|
||||||
|
the failed effect through reconciliation after fixing the root cause.
|
||||||
|
- Economy deltas are bounded (faucet/drain caps of 1000) and provenance-marked
|
||||||
|
(`ECONOMY_NOTE_PREFIX`). `resetEconomyState` rebuilds market-state docs and
|
||||||
|
preserves provenance; operation provenance lives in the operation ledger and
|
||||||
|
is never consumed by the reset.
|
||||||
|
- Payments never precede the locker preflight in `buyListing`; a credit failure
|
||||||
|
after payment is compensated by an adjustment refund. Investigate any
|
||||||
|
`adjustment` transaction referencing an operation effect as evidence of that
|
||||||
|
compensation path.
|
||||||
|
|
||||||
|
## Metrics and logging
|
||||||
|
|
||||||
|
- All ledger activity logs under the `[Operations]` prefix via `payload.logger`.
|
||||||
|
- Bridge lease recovery logs under `[ArmaBridge]`.
|
||||||
|
- Ledger rows carry `reconciliationNotes`; every manual replay appends a note
|
||||||
|
with the acting user id and outcome.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.44",
|
"version": "0.2.48",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ import { defineConfig, devices } from "@playwright/test";
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import { resolveTestDatabaseUri } from "./tests/test-db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E runs against the dedicated test database, never the development one.
|
||||||
|
* The webServer (dev server) inherits DATABASE_URI pointed at the test DB;
|
||||||
|
* global setup guarantees it exists, is migrated and is empty.
|
||||||
|
*/
|
||||||
|
const testDatabaseUri = resolveTestDatabaseUri();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
|
|
@ -53,5 +62,10 @@ export default defineConfig({
|
||||||
command: "pnpm dev",
|
command: "pnpm dev",
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URI: testDatabaseUri,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
globalSetup: "./tests/e2e/global-setup.ts",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
public/images/assets/icons/credits.png
Normal file
BIN
public/images/assets/icons/credits.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -12,6 +12,22 @@ export const metadata = {
|
||||||
title: "Helpdesk: Polaris Task Force",
|
title: "Helpdesk: Polaris Task Force",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function HelpdeskDisabled() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LifeBuoyIcon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Helpdesk</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The Helpdesk is currently disabled by a unit admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HelpdeskPage() {
|
export default async function HelpdeskPage() {
|
||||||
const headers = await nextHeaders();
|
const headers = await nextHeaders();
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|
@ -25,6 +41,11 @@ export default async function HelpdeskPage() {
|
||||||
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
|
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { helpdeskEnabled } = await payload.findGlobal({ slug: "game-rules" });
|
||||||
|
if (!helpdeskEnabled) {
|
||||||
|
return <HelpdeskDisabled />;
|
||||||
|
}
|
||||||
|
|
||||||
const ticketsRes = await payload.find({
|
const ticketsRes = await payload.find({
|
||||||
collection: "tickets",
|
collection: "tickets",
|
||||||
sort: "-updatedAt",
|
sort: "-updatedAt",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type { BankAccount, Campaign, Faction, Media, Mission, Resource } from "@/payload-types";
|
import type { BankAccount, Campaign, Faction, Media, Mission } from "@/payload-types";
|
||||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,7 +16,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||||
import { formatAmount } from "@/lib/banking/format";
|
import { getCurrencyConfig } from "@/lib/banking";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
|
|
||||||
interface FactionDetailPageProps {
|
interface FactionDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -118,10 +119,9 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
||||||
})
|
})
|
||||||
.catch(() => ({ docs: [] }));
|
.catch(() => ({ docs: [] }));
|
||||||
const bankAccount = (bankRes.docs[0] as BankAccount | undefined) ?? null;
|
const bankAccount = (bankRes.docs[0] as BankAccount | undefined) ?? null;
|
||||||
const bankCurrency =
|
// Currency emblem/label is shared across all accounts (from Game Rules), so
|
||||||
bankAccount && typeof bankAccount.currency === "object"
|
// the bank balance renders the same emblem the banking surfaces show.
|
||||||
? (bankAccount.currency as Resource)
|
const currencyConfig = await getCurrencyConfig(payload);
|
||||||
: null;
|
|
||||||
|
|
||||||
// Missions involving this faction
|
// Missions involving this faction
|
||||||
const missionsRes = await payload
|
const missionsRes = await payload
|
||||||
|
|
@ -365,7 +365,7 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-lg font-semibold font-mono">
|
<span className="text-lg font-semibold font-mono">
|
||||||
{formatAmount(bankAccount.balance ?? 0, bankCurrency)}
|
<CurrencyAmount value={bankAccount.balance ?? 0} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">{bankAccount.name}</span>
|
<span className="text-xs text-muted-foreground">{bankAccount.name}</span>
|
||||||
{bankAccount.status !== "open" && (
|
{bankAccount.status !== "open" && (
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<header className="relative flex min-h-[500px] w-full flex-col overflow-hidden bg-background">
|
<header className="relative flex min-h-[500px] w-full flex-col overflow-hidden bg-background">
|
||||||
<div className="absolute top-0 left-0 px-5 pt-5 pb-3 z-10">
|
<div className="absolute top-0 left-0 z-20 px-5 pt-5 pb-3">
|
||||||
<Link
|
<Link
|
||||||
href="/intelligence/missions"
|
href="/intelligence/missions"
|
||||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +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 { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
|
||||||
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";
|
||||||
|
|
@ -29,6 +29,7 @@ import { cookies } from "next/headers";
|
||||||
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
||||||
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
||||||
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
||||||
|
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
// The layout calls `getPayload()` at render time to resolve the current user,
|
||||||
|
|
@ -79,46 +80,57 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||||
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
let statisticsEnabled = true;
|
||||||
|
let helpdeskEnabled = true;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
|
const [
|
||||||
await Promise.all([
|
intelligenceQual,
|
||||||
hasIntelligenceQualification(payload, user).catch(() => false),
|
logisticsQual,
|
||||||
hasLogisticsQualification(payload, user).catch(() => false),
|
profileRes,
|
||||||
payload.find({
|
experienceRes,
|
||||||
collection: "profiles",
|
personalAccountRes,
|
||||||
where: { user: { equals: user.id } },
|
gameRulesRes,
|
||||||
select: {
|
] = await Promise.all([
|
||||||
rank: true,
|
hasIntelligenceQualification(payload, user).catch(() => false),
|
||||||
progression: {
|
hasLogisticsQualification(payload, user).catch(() => false),
|
||||||
awards: true,
|
payload.find({
|
||||||
scoreboard: true,
|
collection: "profiles",
|
||||||
xp: true,
|
where: { user: { equals: user.id } },
|
||||||
minigames: true,
|
select: {
|
||||||
},
|
rank: true,
|
||||||
|
progression: {
|
||||||
|
awards: true,
|
||||||
|
scoreboard: true,
|
||||||
|
xp: true,
|
||||||
|
minigames: true,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
payload.find({
|
}),
|
||||||
collection: "experience",
|
payload.find({
|
||||||
sort: "requiredPoints",
|
collection: "experience",
|
||||||
limit: 100,
|
sort: "requiredPoints",
|
||||||
select: {
|
limit: 100,
|
||||||
name: true,
|
select: {
|
||||||
description: true,
|
name: true,
|
||||||
requiredPoints: true,
|
description: true,
|
||||||
},
|
requiredPoints: true,
|
||||||
}),
|
},
|
||||||
payload.find({
|
}),
|
||||||
collection: "bank-accounts",
|
payload.find({
|
||||||
where: {
|
collection: "bank-accounts",
|
||||||
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
|
where: {
|
||||||
},
|
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
|
||||||
limit: 1,
|
},
|
||||||
}),
|
limit: 1,
|
||||||
]);
|
}),
|
||||||
|
payload.findGlobal({ slug: "game-rules" }),
|
||||||
|
]);
|
||||||
isIntelligence = intelligenceQual;
|
isIntelligence = intelligenceQual;
|
||||||
isLogistics = logisticsQual;
|
isLogistics = logisticsQual;
|
||||||
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
|
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
|
||||||
|
statisticsEnabled = gameRulesRes.statisticsEnabled ?? true;
|
||||||
|
helpdeskEnabled = gameRulesRes.helpdeskEnabled ?? true;
|
||||||
|
|
||||||
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
||||||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||||
|
|
@ -149,6 +161,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
||||||
hasIntelligence={isIntelligence}
|
hasIntelligence={isIntelligence}
|
||||||
hasLogistics={isLogistics}
|
hasLogistics={isLogistics}
|
||||||
|
statisticsEnabled={statisticsEnabled}
|
||||||
|
helpdeskEnabled={helpdeskEnabled}
|
||||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
||||||
canImpersonate={await isSuperuser(payload, user)}
|
canImpersonate={await isSuperuser(payload, user)}
|
||||||
preferences={user.preferences}
|
preferences={user.preferences}
|
||||||
|
|
@ -184,6 +198,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
)}
|
)}
|
||||||
{user && <GameTickRealtime />}
|
{user && <GameTickRealtime />}
|
||||||
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
||||||
|
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
|
||||||
{user && <MissionReminderToasts />}
|
{user && <MissionReminderToasts />}
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ import {
|
||||||
setLaborHeadcount as setLaborHeadcountAssignment,
|
setLaborHeadcount as setLaborHeadcountAssignment,
|
||||||
upgradeStructure as upgradeStructureService,
|
upgradeStructure as upgradeStructureService,
|
||||||
} from "@/lib/base";
|
} from "@/lib/base";
|
||||||
|
import {
|
||||||
|
installModule as installModuleService,
|
||||||
|
uninstallModule as uninstallModuleService,
|
||||||
|
} from "@/lib/base/modules";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
@ -81,6 +87,72 @@ export async function fireStaff(staffingId: number): Promise<ActionResult<null>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleProduction(
|
||||||
|
gameStructureId: number,
|
||||||
|
paused: boolean,
|
||||||
|
): Promise<ActionResult<{ paused: boolean }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!(await hasLogisticsQualification(payload, user))) {
|
||||||
|
return { success: false, error: "Insufficient permissions." };
|
||||||
|
}
|
||||||
|
const structure = await payload.findByID({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: gameStructureId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: gameStructureId,
|
||||||
|
data: { productionPaused: paused },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.ProductionToggled,
|
||||||
|
message: `${structure.name} production ${paused ? "paused" : "resumed"} by ${user.username ?? "logistics"}`,
|
||||||
|
structure: gameStructureId,
|
||||||
|
targetCollection: "game-structures",
|
||||||
|
targetId: gameStructureId,
|
||||||
|
});
|
||||||
|
return { success: true, data: { paused } };
|
||||||
|
} catch (error) {
|
||||||
|
return actionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installModule(
|
||||||
|
gameStructureId: number,
|
||||||
|
moduleName: string,
|
||||||
|
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!(await hasLogisticsQualification(payload, user))) {
|
||||||
|
return { success: false, error: "Insufficient permissions." };
|
||||||
|
}
|
||||||
|
const installed = await installModuleService(payload, gameStructureId, moduleName);
|
||||||
|
return { success: true, data: { installed } };
|
||||||
|
} catch (error) {
|
||||||
|
return actionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uninstallModule(
|
||||||
|
gameStructureId: number,
|
||||||
|
moduleName: string,
|
||||||
|
): Promise<ActionResult<{ installed: { name: string; installedAt: string | null }[] }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!(await hasLogisticsQualification(payload, user))) {
|
||||||
|
return { success: false, error: "Insufficient permissions." };
|
||||||
|
}
|
||||||
|
const installed = await uninstallModuleService(payload, gameStructureId, moduleName);
|
||||||
|
return { success: true, data: { installed } };
|
||||||
|
} catch (error) {
|
||||||
|
return actionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setLaborHeadcount(
|
export async function setLaborHeadcount(
|
||||||
structureId: number,
|
structureId: number,
|
||||||
classificationId: number,
|
classificationId: number,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
|
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
|
||||||
import { validateHexColor } from "@/lib/map/style";
|
import { validateHexColor } from "@/lib/map/style";
|
||||||
|
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
|
||||||
|
import type { ResourceNode } from "@/payload-types";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -46,6 +48,7 @@ export async function placeStructure(params: {
|
||||||
blueprintId: number;
|
blueprintId: number;
|
||||||
name: string;
|
name: string;
|
||||||
factionId?: number | null;
|
factionId?: number | null;
|
||||||
|
parentStructureId?: number | null;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
|
|
@ -101,6 +104,27 @@ export async function placeStructure(params: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parentStructureId: number | null = null;
|
||||||
|
if (params.parentStructureId != null) {
|
||||||
|
const hub = await payload
|
||||||
|
.findByID({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: params.parentStructureId,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (!hub) {
|
||||||
|
return { success: false, error: "Compound hub not found." };
|
||||||
|
}
|
||||||
|
if ((hub as unknown as GameStructure).map !== mapId) {
|
||||||
|
return { success: false, error: "The compound hub is on a different map." };
|
||||||
|
}
|
||||||
|
if ((hub as unknown as GameStructure).parentStructure != null) {
|
||||||
|
return { success: false, error: "Compound hubs cannot themselves be compound children." };
|
||||||
|
}
|
||||||
|
parentStructureId = hub.id;
|
||||||
|
}
|
||||||
|
|
||||||
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
|
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "map-zones",
|
collection: "map-zones",
|
||||||
|
|
@ -184,6 +208,7 @@ export async function placeStructure(params: {
|
||||||
map: mapId,
|
map: mapId,
|
||||||
coordinates: point,
|
coordinates: point,
|
||||||
constructionStatus: "awaiting_materials",
|
constructionStatus: "awaiting_materials",
|
||||||
|
parentStructure: parentStructureId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -240,7 +265,7 @@ export interface MapZoneInput {
|
||||||
mapId: number;
|
mapId: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
type: "water" | "land" | "territory" | "border";
|
type: "water" | "land" | "territory" | "border" | "other";
|
||||||
resourceIds?: number[];
|
resourceIds?: number[];
|
||||||
fillColor?: string | null;
|
fillColor?: string | null;
|
||||||
strokeColor?: string | null;
|
strokeColor?: string | null;
|
||||||
|
|
@ -258,6 +283,7 @@ export interface MapNodeInput {
|
||||||
resourceIds: number[];
|
resourceIds: number[];
|
||||||
position: MapPoint;
|
position: MapPoint;
|
||||||
richness?: number | null;
|
richness?: number | null;
|
||||||
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireMapPermission(
|
async function requireMapPermission(
|
||||||
|
|
@ -427,17 +453,23 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
|
||||||
return { success: false, error: "A node requires a single [x, y] position." };
|
return { success: false, error: "A node requires a single [x, y] position." };
|
||||||
}
|
}
|
||||||
const resourceIds = [...new Set(input.resourceIds)];
|
const resourceIds = [...new Set(input.resourceIds)];
|
||||||
if (resourceIds.length === 0) {
|
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 resources = await payload.find({
|
// An empty resource list makes the node a plain marker: no resource
|
||||||
collection: "resources",
|
// validation, and placement's resourcesInRange never matches it.
|
||||||
where: { id: { in: resourceIds } },
|
|
||||||
depth: 0,
|
const icon = input.icon ?? "marker";
|
||||||
limit: resourceIds.length,
|
if (!NODE_ICON_OPTIONS.some((option) => option.value === icon)) {
|
||||||
});
|
return { success: false, error: "Unknown node icon." };
|
||||||
if (resources.docs.length !== resourceIds.length) {
|
|
||||||
return { success: false, error: "One or more resources not found." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
|
|
@ -446,6 +478,7 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
|
||||||
resources: resourceIds,
|
resources: resourceIds,
|
||||||
position: input.position,
|
position: input.position,
|
||||||
richness: input.richness ?? 100,
|
richness: input.richness ?? 100,
|
||||||
|
icon: icon as ResourceNode["icon"],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
||||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||||
|
import { advanceShipment } from "@/lib/shipping/lifecycle";
|
||||||
|
import type { Shipment } from "@/payload-types";
|
||||||
|
|
||||||
interface ShipmentDetailPageProps {
|
interface ShipmentDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -35,7 +37,7 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
const shipment = await payload
|
let shipment = await payload
|
||||||
.findByID({
|
.findByID({
|
||||||
collection: "shipments",
|
collection: "shipments",
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
|
|
@ -47,6 +49,27 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance the shipment lifecycle on view (idempotent): an active shipment
|
||||||
|
// that is due completes the moment anyone opens its page instead of waiting
|
||||||
|
// for the next game tick.
|
||||||
|
if (shipment.status === "dispatched" || shipment.status === "in_transit") {
|
||||||
|
await advanceShipment(
|
||||||
|
payload,
|
||||||
|
shipment as unknown as Parameters<typeof advanceShipment>[1],
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
const refreshed = await payload
|
||||||
|
.findByID({
|
||||||
|
collection: "shipments",
|
||||||
|
id: Number(id),
|
||||||
|
depth: 2,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (refreshed) {
|
||||||
|
shipment = refreshed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const origin =
|
const origin =
|
||||||
typeof shipment.origin === "object" && shipment.origin !== null
|
typeof shipment.origin === "object" && shipment.origin !== null
|
||||||
? (shipment.origin as GameStructure)
|
? (shipment.origin as GameStructure)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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 { effectiveMaxMass } from "@/lib/base/modules";
|
||||||
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
||||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
|
|
||||||
|
|
@ -134,9 +135,12 @@ 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 originMapId =
|
||||||
|
typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null);
|
||||||
const destMapId =
|
const destMapId =
|
||||||
typeof destination.map === "object" ? (destination.map?.id ?? null) : (destination.map ?? null);
|
typeof destination.map === "object"
|
||||||
|
? (destination.map?.id ?? null)
|
||||||
|
: (destination.map ?? null);
|
||||||
|
|
||||||
if (!originMapId || !destMapId) {
|
if (!originMapId || !destMapId) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -301,10 +305,11 @@ export async function createShipment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
|
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
|
||||||
if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
|
const destMaxMass = effectiveMaxMass(destType, destination.installedModules);
|
||||||
|
if (destMaxMass && destMass > destMaxMass) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`,
|
error: `Destination "${destination.name}" would exceed its mass capacity of ${destMaxMass.toLocaleString()} kg.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { getPayload, type Payload } from "payload";
|
import { getPayload, type Payload } from "payload";
|
||||||
import type { Faction, GameStructure, Structure } from "@/payload-types";
|
import type { Faction, GameStructure, Structure } from "@/payload-types";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import {
|
import { calculateStoredMass, calculateUtilization, getGridSlots } from "@/lib/logistics";
|
||||||
calculateStoredMass,
|
|
||||||
calculateUtilization,
|
|
||||||
getGridSlots,
|
|
||||||
} from "@/lib/logistics";
|
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
|
import { effectiveCrew } from "@/lib/base/constructionSpeed";
|
||||||
|
import { installedModuleEffects } from "@/lib/base/modules";
|
||||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
|
@ -22,11 +20,29 @@ export interface StructureDetailData {
|
||||||
gridSlots: number | null;
|
gridSlots: number | null;
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
constructionMaterials: { name: string; required: number; delivered: number }[];
|
constructionMaterials: { name: string; required: number; delivered: number }[];
|
||||||
|
constructionCrew: { required: number; effective: number };
|
||||||
};
|
};
|
||||||
upgrade: {
|
upgrade: {
|
||||||
targetName: string;
|
targetName: string;
|
||||||
materials: { name: string; needed: number; have: number }[];
|
materials: { name: string; needed: number; have: number }[];
|
||||||
} | null;
|
} | null;
|
||||||
|
production: {
|
||||||
|
paused: boolean;
|
||||||
|
stallReason: string | null;
|
||||||
|
inputs: { name: string; amountPerHour: number }[];
|
||||||
|
outputs: { name: string; amountPerHour: number }[];
|
||||||
|
};
|
||||||
|
modules: {
|
||||||
|
slots: number;
|
||||||
|
installed: { name: string; installedAt: string | null }[];
|
||||||
|
options: {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
description: string | null;
|
||||||
|
effects: { type: string; value: number }[];
|
||||||
|
materials: { name: string; amount: number }[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
staffing: {
|
staffing: {
|
||||||
structureStaffing: StructureStaffingOption;
|
structureStaffing: StructureStaffingOption;
|
||||||
staff: {
|
staff: {
|
||||||
|
|
@ -76,8 +92,16 @@ export async function fetchStructureDetail(
|
||||||
const structureType = structure.type;
|
const structureType = structure.type;
|
||||||
const storedResources = structure.storedResources ?? [];
|
const storedResources = structure.storedResources ?? [];
|
||||||
const currentMass = calculateStoredMass(storedResources);
|
const currentMass = calculateStoredMass(storedResources);
|
||||||
const utilization = calculateUtilization(structureType, storedResources);
|
const moduleEffects = installedModuleEffects(
|
||||||
const gridSlots = getGridSlots(structureType);
|
structureType,
|
||||||
|
(structure.installedModules ?? []).map((entry) => entry.name),
|
||||||
|
);
|
||||||
|
const utilization = calculateUtilization(
|
||||||
|
structureType,
|
||||||
|
storedResources,
|
||||||
|
moduleEffects.capacityMass,
|
||||||
|
);
|
||||||
|
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
|
||||||
const description = structureType.description
|
const description = structureType.description
|
||||||
? convertLexicalToPlaintext({ data: structureType.description })
|
? convertLexicalToPlaintext({ data: structureType.description })
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -194,6 +218,9 @@ export async function fetchStructureDetail(
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
const buildEfficiencyById = new Map(
|
||||||
|
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
|
||||||
|
);
|
||||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
|
|
@ -274,13 +301,77 @@ export async function fetchStructureDetail(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const constructionCrew = {
|
||||||
|
required: structureType.constructionCrewRequired ?? 0,
|
||||||
|
effective: effectiveCrew(
|
||||||
|
staff
|
||||||
|
.filter(
|
||||||
|
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
|
||||||
|
)
|
||||||
|
.map((member) => ({
|
||||||
|
classificationId: member.laborClassificationId as number,
|
||||||
|
headcount: member.headcount,
|
||||||
|
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeLine = (line: {
|
||||||
|
resource: number | { id: number; name: string };
|
||||||
|
amountPerHour?: number | null;
|
||||||
|
}): { name: string; amountPerHour: number } => {
|
||||||
|
const resource = line.resource;
|
||||||
|
const name =
|
||||||
|
typeof resource === "object" && resource !== null ? resource.name : `Resource #${resource}`;
|
||||||
|
return { name, amountPerHour: line.amountPerHour ?? 0 };
|
||||||
|
};
|
||||||
|
const production = {
|
||||||
|
paused: Boolean(structure.productionPaused),
|
||||||
|
stallReason: structure.productionStallReason ?? null,
|
||||||
|
inputs: (structure.productionInputs ?? []).map(recipeLine),
|
||||||
|
outputs: (structure.productionOutputs ?? []).map(recipeLine),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modules = {
|
||||||
|
slots: structureType.moduleSlots ?? 0,
|
||||||
|
installed: (structure.installedModules ?? []).map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
installedAt: entry.installedAt ?? null,
|
||||||
|
})),
|
||||||
|
options: (structureType.moduleOptions ?? []).map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
category: option.category,
|
||||||
|
description: option.description ?? null,
|
||||||
|
effects: (option.effects ?? []).map((effect) => ({
|
||||||
|
type: effect.type ?? "",
|
||||||
|
value: effect.value ?? 0,
|
||||||
|
})),
|
||||||
|
materials: (option.materials ?? []).map((material) => ({
|
||||||
|
name:
|
||||||
|
typeof material.resource === "object" && material.resource !== null
|
||||||
|
? material.resource.name
|
||||||
|
: `Resource #${material.resource}`,
|
||||||
|
amount: Math.ceil(material.amount ?? 0),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
structure,
|
structure,
|
||||||
blueprint: structureType,
|
blueprint: structureType,
|
||||||
description,
|
description,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials },
|
derived: {
|
||||||
|
currentMass,
|
||||||
|
utilization,
|
||||||
|
gridSlots,
|
||||||
|
totalValue,
|
||||||
|
constructionMaterials,
|
||||||
|
constructionCrew,
|
||||||
|
},
|
||||||
upgrade,
|
upgrade,
|
||||||
|
production,
|
||||||
|
modules,
|
||||||
staffing: {
|
staffing: {
|
||||||
structureStaffing,
|
structureStaffing,
|
||||||
staff,
|
staff,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ 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 { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
|
||||||
|
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
|
||||||
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||||
import {
|
import {
|
||||||
calculateStoredMass,
|
calculateStoredMass,
|
||||||
|
|
@ -20,6 +22,8 @@ import {
|
||||||
} from "@/lib/logistics";
|
} from "@/lib/logistics";
|
||||||
import { pluralize } from "@/lib/utils";
|
import { pluralize } from "@/lib/utils";
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
|
import { effectiveCrew } from "@/lib/base/constructionSpeed";
|
||||||
|
import { installedModuleEffects } from "@/lib/base/modules";
|
||||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
|
@ -71,9 +75,33 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
|
|
||||||
const structureType = structure.type;
|
const structureType = structure.type;
|
||||||
const storedResources = structure.storedResources ?? [];
|
const storedResources = structure.storedResources ?? [];
|
||||||
|
const compoundHub =
|
||||||
|
typeof structure.parentStructure === "object" && structure.parentStructure !== null
|
||||||
|
? structure.parentStructure
|
||||||
|
: null;
|
||||||
|
const compoundChildren = (
|
||||||
|
await payload.find({
|
||||||
|
collection: "game-structures",
|
||||||
|
where: { parentStructure: { equals: structure.id } },
|
||||||
|
limit: 50,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
).docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
constructionStatus: doc.constructionStatus ?? "complete",
|
||||||
|
}));
|
||||||
const currentMass = calculateStoredMass(storedResources);
|
const currentMass = calculateStoredMass(storedResources);
|
||||||
const utilization = calculateUtilization(structureType, storedResources);
|
const moduleEffects = installedModuleEffects(
|
||||||
const gridSlots = getGridSlots(structureType);
|
structureType,
|
||||||
|
(structure.installedModules ?? []).map((entry) => entry.name),
|
||||||
|
);
|
||||||
|
const utilization = calculateUtilization(
|
||||||
|
structureType,
|
||||||
|
storedResources,
|
||||||
|
moduleEffects.capacityMass,
|
||||||
|
);
|
||||||
|
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
|
||||||
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
||||||
const description = structureType.description
|
const description = structureType.description
|
||||||
? convertLexicalToPlaintext({ data: structureType.description })
|
? convertLexicalToPlaintext({ data: structureType.description })
|
||||||
|
|
@ -193,6 +221,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
const buildEfficiencyById = new Map(
|
||||||
|
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
|
||||||
|
);
|
||||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
|
|
@ -200,6 +231,20 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
||||||
availableHeadcount: doc.availableHeadcount ?? 0,
|
availableHeadcount: doc.availableHeadcount ?? 0,
|
||||||
}));
|
}));
|
||||||
|
const constructionCrew = {
|
||||||
|
required: structureType.constructionCrewRequired ?? 0,
|
||||||
|
effective: effectiveCrew(
|
||||||
|
staff
|
||||||
|
.filter(
|
||||||
|
(member) => member.positionType === "labor" && member.laborClassificationId !== null,
|
||||||
|
)
|
||||||
|
.map((member) => ({
|
||||||
|
classificationId: member.laborClassificationId as number,
|
||||||
|
headcount: member.headcount,
|
||||||
|
efficiency: buildEfficiencyById.get(member.laborClassificationId as number) ?? 1,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const rules = await payload.findGlobal({
|
const rules = await payload.findGlobal({
|
||||||
slug: "game-rules",
|
slug: "game-rules",
|
||||||
|
|
@ -434,6 +479,45 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(compoundHub || compoundChildren.length > 0) && (
|
||||||
|
<div className="rounded-lg border border-border p-5 flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-semibold">Compound</h2>
|
||||||
|
{compoundHub && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Part of{" "}
|
||||||
|
<a
|
||||||
|
className="text-foreground underline underline-offset-2 hover:text-primary"
|
||||||
|
href={`/logistics/structures/${compoundHub.id}`}
|
||||||
|
>
|
||||||
|
{compoundHub.name}
|
||||||
|
</a>
|
||||||
|
{"; storage pools into the hub while this building is operational."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{compoundChildren.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{compoundChildren.length} child building{compoundChildren.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{compoundChildren.map((child) => (
|
||||||
|
<a
|
||||||
|
key={child.id}
|
||||||
|
href={`/logistics/structures/${child.id}`}
|
||||||
|
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted"
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{child.constructionStatus.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
||||||
|
|
||||||
<ConstructionPanel
|
<ConstructionPanel
|
||||||
|
|
@ -442,6 +526,55 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
completesAt={structure.constructionCompletesAt ?? null}
|
completesAt={structure.constructionCompletesAt ?? null}
|
||||||
durationMinutes={structureType.constructionDurationMinutes ?? null}
|
durationMinutes={structureType.constructionDurationMinutes ?? null}
|
||||||
materials={constructionMaterials}
|
materials={constructionMaterials}
|
||||||
|
crew={constructionCrew}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProductionPanel
|
||||||
|
gameStructureId={structure.id}
|
||||||
|
complete={(structure.constructionStatus ?? "complete") === "complete"}
|
||||||
|
paused={Boolean(structure.productionPaused)}
|
||||||
|
stallReason={structure.productionStallReason ?? null}
|
||||||
|
inputs={(structure.productionInputs ?? []).map((line) => ({
|
||||||
|
name:
|
||||||
|
typeof line.resource === "object" && line.resource !== null
|
||||||
|
? line.resource.name
|
||||||
|
: `Resource #${line.resource}`,
|
||||||
|
amountPerHour: line.amountPerHour ?? 0,
|
||||||
|
}))}
|
||||||
|
outputs={(structure.productionOutputs ?? []).map((line) => ({
|
||||||
|
name:
|
||||||
|
typeof line.resource === "object" && line.resource !== null
|
||||||
|
? line.resource.name
|
||||||
|
: `Resource #${line.resource}`,
|
||||||
|
amountPerHour: line.amountPerHour ?? 0,
|
||||||
|
}))}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlotsPanel
|
||||||
|
gameStructureId={structure.id}
|
||||||
|
slots={structureType.moduleSlots ?? 0}
|
||||||
|
installed={(structure.installedModules ?? []).map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
installedAt: entry.installedAt ?? null,
|
||||||
|
}))}
|
||||||
|
options={(structureType.moduleOptions ?? []).map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
category: option.category,
|
||||||
|
description: option.description ?? null,
|
||||||
|
effects: (option.effects ?? []).map((effect) => ({
|
||||||
|
type: effect.type ?? "",
|
||||||
|
value: effect.value ?? 0,
|
||||||
|
})),
|
||||||
|
materials: (option.materials ?? []).map((material) => ({
|
||||||
|
name:
|
||||||
|
typeof material.resource === "object" && material.resource !== null
|
||||||
|
? material.resource.name
|
||||||
|
: `Resource #${material.resource}`,
|
||||||
|
amount: Math.ceil(material.amount ?? 0),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
|
canManage={canManage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StaffRoster
|
<StaffRoster
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
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 { effectiveMaxMass } from "@/lib/base/modules";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
|
|
||||||
|
|
@ -105,7 +106,7 @@ export async function addResource(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addResourceInternal(
|
export async function addResourceInternal(
|
||||||
payload: PayloadClient,
|
payload: PayloadClient,
|
||||||
user: AuthedUser,
|
user: AuthedUser,
|
||||||
structureId: number,
|
structureId: number,
|
||||||
|
|
@ -150,7 +151,7 @@ async function addResourceInternal(
|
||||||
// Check capacity
|
// Check capacity
|
||||||
const currentMass = calculateMass(structure.storedResources);
|
const currentMass = calculateMass(structure.storedResources);
|
||||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
const maxMass = structureType.maxCapacityMass;
|
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
|
||||||
|
|
||||||
if (maxMass && currentMass + addedMass > maxMass) {
|
if (maxMass && currentMass + addedMass > maxMass) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -511,7 +512,7 @@ export async function transferResource(
|
||||||
// Check destination capacity
|
// Check destination capacity
|
||||||
const destMass = calculateMass(toStructure.storedResources);
|
const destMass = calculateMass(toStructure.storedResources);
|
||||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
const destMaxMass = toStructure.type.maxCapacityMass;
|
const destMaxMass = effectiveMaxMass(toStructure.type, toStructure.installedModules);
|
||||||
|
|
||||||
if (destMaxMass && destMass + addedMass > destMaxMass) {
|
if (destMaxMass && destMass + addedMass > destMaxMass) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -620,7 +621,7 @@ export async function placeResourceOnGrid(
|
||||||
|
|
||||||
const currentMass = calculateMass(structure.storedResources);
|
const currentMass = calculateMass(structure.storedResources);
|
||||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
const maxMass = structureType.maxCapacityMass;
|
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
|
||||||
if (maxMass && currentMass + addedMass > maxMass) {
|
if (maxMass && currentMass + addedMass > maxMass) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
281
src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx
Normal file
281
src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import {
|
||||||
|
LedgerRow,
|
||||||
|
NoticeState,
|
||||||
|
PageShell,
|
||||||
|
ProvenanceChip,
|
||||||
|
STATUS_TONE_CLASSES,
|
||||||
|
type ReadoutCell,
|
||||||
|
type StatusTone,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import { SupplyBoxActions } from "@/components/frontend/logistics/SupplyBoxActions";
|
||||||
|
import type { ArmaCommand, SupplyBox } from "@/payload-types";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Supply Box Detail: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
function relationId(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||||
|
const id = (value as { id: unknown }).id;
|
||||||
|
return typeof id === "number" ? id : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationFromJson(value: unknown): { x: number; y: number; z: number } | null {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
const { x, y, z } = value as { x?: unknown; y?: unknown; z?: unknown };
|
||||||
|
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return null;
|
||||||
|
return { x, y, z };
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(value: unknown): string {
|
||||||
|
const loc = locationFromJson(value);
|
||||||
|
return loc === null ? "n/a" : `${loc.x}, ${loc.y}, ${loc.z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandTone(status: string): StatusTone {
|
||||||
|
if (status === "succeeded") return "success";
|
||||||
|
if (status === "failed") return "danger";
|
||||||
|
if (status === "delivered") return "info";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyBoxDetailData {
|
||||||
|
box: SupplyBox | null;
|
||||||
|
capability: boolean;
|
||||||
|
commands: ArmaCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full detail for a single supply box plus its recent queued commands, fetched
|
||||||
|
* through collection access control (default access + the explicit session
|
||||||
|
* user, never overrideAccess). Returns null for an unknown id so the page can
|
||||||
|
* render a not-found notice.
|
||||||
|
*/
|
||||||
|
async function getSupplyBoxDetail(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
id: string,
|
||||||
|
): Promise<SupplyBoxDetailData> {
|
||||||
|
const qualified = await hasLogisticsQualification(payload, user);
|
||||||
|
if (!qualified) return { box: null, capability: false, commands: [] };
|
||||||
|
|
||||||
|
let box: SupplyBox | null = null;
|
||||||
|
try {
|
||||||
|
box = await payload.findByID({
|
||||||
|
collection: "supply-boxes",
|
||||||
|
id: Number(id),
|
||||||
|
depth: 2,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
box = null;
|
||||||
|
}
|
||||||
|
if (box === null) return { box: null, capability: true, commands: [] };
|
||||||
|
|
||||||
|
const serverId = relationId(box.server);
|
||||||
|
const commands =
|
||||||
|
serverId === null
|
||||||
|
? []
|
||||||
|
: (
|
||||||
|
await payload.find({
|
||||||
|
collection: "arma-commands",
|
||||||
|
where: { server: { equals: serverId } },
|
||||||
|
sort: "-createdAt",
|
||||||
|
limit: 50,
|
||||||
|
depth: 0,
|
||||||
|
user: user ?? undefined,
|
||||||
|
})
|
||||||
|
).docs.filter((cmd) => Array.isArray(cmd.payload) && cmd.payload[0] === box.boxId);
|
||||||
|
|
||||||
|
return { box, capability: true, commands };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section data-slot="supply-box-section" className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvRow(label: string, value: React.ReactNode) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||||
|
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SupplyBoxDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const { box, capability, commands } = await getSupplyBoxDetail(payload, surfaceUser, id);
|
||||||
|
|
||||||
|
if (capability === false) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
|
||||||
|
<NoticeState
|
||||||
|
variant="error"
|
||||||
|
message="You do not have the Logistics qualification to manage supply boxes."
|
||||||
|
hint="Request the Logistics qualification from unit command."
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (box === null) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
|
||||||
|
<NoticeState
|
||||||
|
variant="error"
|
||||||
|
message="Supply box not found."
|
||||||
|
hint="The supply box id is unknown or you lack permission to read it."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/logistics/supply-boxes"
|
||||||
|
className="mt-2 border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to supply boxes
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = Array.isArray(box.contents) ? box.contents : [];
|
||||||
|
const itemCount = contents.reduce<number>((sum, line) => {
|
||||||
|
if (typeof line !== "object" || line === null) return sum;
|
||||||
|
const count = (line as { count?: unknown }).count;
|
||||||
|
return sum + (typeof count === "number" ? count : 0);
|
||||||
|
}, 0);
|
||||||
|
const sourceEventId = relationId(box.sourceEvent);
|
||||||
|
|
||||||
|
const readoutCells: ReadoutCell[] = [
|
||||||
|
{ label: "Class", value: box.boxClass ?? "n/a" },
|
||||||
|
{ label: "Status", value: box.status },
|
||||||
|
{ label: "Items", value: String(itemCount) },
|
||||||
|
{ label: "Last Sync", value: box.lastSyncedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title={box.boxId} meta="SUPPLY BOX // DETAIL">
|
||||||
|
<Link
|
||||||
|
href="/logistics/supply-boxes"
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Back to supply boxes
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<LedgerRow cells={readoutCells} />
|
||||||
|
|
||||||
|
{sourceEventId !== null ? (
|
||||||
|
<ProvenanceChip
|
||||||
|
label={`RAW EVENT #${sourceEventId}`}
|
||||||
|
href={`/admin/collections/arma-sync-events/${sourceEventId}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Section title="Contents">
|
||||||
|
{contents.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="This crate is empty." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{contents.map((line, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{(line as { class?: unknown }).class as string}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{(line as { count?: unknown }).count as number}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Location">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{kvRow("Position", locationLabel(box.location))}
|
||||||
|
{kvRow("Session", box.sessionId)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Command Queue">
|
||||||
|
<SupplyBoxActions
|
||||||
|
boxId={box.boxId}
|
||||||
|
boxClass={box.boxClass ?? null}
|
||||||
|
location={locationFromJson(box.location)}
|
||||||
|
contents={contents.map((line) => ({
|
||||||
|
class: (line as { class?: unknown }).class as string,
|
||||||
|
count: (line as { count?: unknown }).count as number,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Recent Commands">
|
||||||
|
{commands.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No commands have been queued for this crate." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{commands.map((cmd) => (
|
||||||
|
<li
|
||||||
|
key={cmd.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{cmd.type}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
|
||||||
|
>
|
||||||
|
{cmd.status}
|
||||||
|
</span>
|
||||||
|
{cmd.error ? (
|
||||||
|
<span className="max-w-40 truncate font-mono text-[10px] text-rose-300">
|
||||||
|
{cmd.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</PageShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/(frontend)/logistics/supply-boxes/actions.ts
Normal file
124
src/app/(frontend)/logistics/supply-boxes/actions.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
|
||||||
|
export interface ActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CrateCommand = "spawn" | "apply" | "despawn";
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await headers(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relId(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||||
|
const id = (value as { id: unknown }).id;
|
||||||
|
return typeof id === "number" ? id : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the crate command payload against the crate-sync contract
|
||||||
|
* (docs/arma-bridge/crate-sync.md). The command envelope is built web-side by
|
||||||
|
* the pull route; the action only stores the row, so the payload must already
|
||||||
|
* match the shape the Arma bridge expects.
|
||||||
|
*/
|
||||||
|
function validateCratePayload(command: CrateCommand, payloadArgs: unknown[]): boolean {
|
||||||
|
if (command === "spawn") {
|
||||||
|
if (payloadArgs.length !== 4) return false;
|
||||||
|
const [boxClass, x, y, z] = payloadArgs;
|
||||||
|
return (
|
||||||
|
typeof boxClass === "string" &&
|
||||||
|
typeof x === "number" &&
|
||||||
|
typeof y === "number" &&
|
||||||
|
typeof z === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command === "apply") {
|
||||||
|
if (payloadArgs.length !== 1) return false;
|
||||||
|
const contents = payloadArgs[0];
|
||||||
|
if (!Array.isArray(contents)) return false;
|
||||||
|
return contents.every(
|
||||||
|
(line) =>
|
||||||
|
Array.isArray(line) &&
|
||||||
|
line.length === 2 &&
|
||||||
|
typeof line[0] === "string" &&
|
||||||
|
typeof line[1] === "number",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// despawn
|
||||||
|
return payloadArgs.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues an outbound crate command for a supply box. The command row is
|
||||||
|
* created with overrideAccess because arma-commands denies direct writes; the
|
||||||
|
* pull route picks it up and builds the [commandId, commandType, payload]
|
||||||
|
* envelope for the game server.
|
||||||
|
*/
|
||||||
|
export async function queueCrateCommand(
|
||||||
|
boxId: string,
|
||||||
|
command: CrateCommand,
|
||||||
|
payloadArgs: unknown[],
|
||||||
|
): Promise<ActionResult<{ commandId: string; status: string }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) return { success: false, error: "Authentication required." };
|
||||||
|
|
||||||
|
const qualified = await hasLogisticsQualification(payload, user);
|
||||||
|
if (!qualified) return { success: false, error: "forbidden" };
|
||||||
|
|
||||||
|
if (!validateCratePayload(command, payloadArgs)) {
|
||||||
|
return { success: false, error: `Invalid payload for crate.${command}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await payload.find({
|
||||||
|
collection: "supply-boxes",
|
||||||
|
where: { boxId: { equals: boxId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
const box = found.docs[0];
|
||||||
|
if (!box) return { success: false, error: "Supply box not found." };
|
||||||
|
|
||||||
|
const serverId = relId(box.server);
|
||||||
|
if (serverId === null) {
|
||||||
|
return { success: false, error: "Supply box has no owning server." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandId = `crate-${randomBytes(12).toString("hex")}`;
|
||||||
|
const created = await payload.create({
|
||||||
|
collection: "arma-commands",
|
||||||
|
data: {
|
||||||
|
commandId,
|
||||||
|
server: serverId,
|
||||||
|
type: `crate.${command}`,
|
||||||
|
payload: [boxId, ...payloadArgs],
|
||||||
|
status: "queued",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { commandId: created.commandId, status: created.status } };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/app/(frontend)/logistics/supply-boxes/page.tsx
Normal file
212
src/app/(frontend)/logistics/supply-boxes/page.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import {
|
||||||
|
LedgerRow,
|
||||||
|
NoticeState,
|
||||||
|
OperationsRealtimeRefresher,
|
||||||
|
PageShell,
|
||||||
|
STATUS_TONE_CLASSES,
|
||||||
|
type ReadoutCell,
|
||||||
|
type StatusTone,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Supply Boxes: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPLY_BOX_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function relationId(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||||
|
const id = (value as { id: unknown }).id;
|
||||||
|
return typeof id === "number" ? id : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxStatusTone(status: string): StatusTone {
|
||||||
|
if (status === "active") return "success";
|
||||||
|
if (status === "deleted") return "danger";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTone(iso: string): StatusTone {
|
||||||
|
const ageMs = Date.now() - new Date(iso).getTime();
|
||||||
|
if (ageMs < 2 * 60_000) return "success";
|
||||||
|
if (ageMs < 10 * 60_000) return "warning";
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAgeLabel(iso: string): string {
|
||||||
|
const ageMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(ageMs / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxItemCount(contents: unknown): number {
|
||||||
|
if (!Array.isArray(contents)) return 0;
|
||||||
|
return contents.reduce((sum, line) => {
|
||||||
|
if (typeof line !== "object" || line === null) return sum;
|
||||||
|
const count = (line as { count?: unknown }).count;
|
||||||
|
return sum + (typeof count === "number" ? count : 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxLocationLabel(location: unknown): string {
|
||||||
|
if (typeof location !== "object" || location === null) return "n/a";
|
||||||
|
const { x, y, z } = location as { x?: unknown; y?: unknown; z?: unknown };
|
||||||
|
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return "n/a";
|
||||||
|
return `${x}, ${y}, ${z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyBoxRow {
|
||||||
|
id: number;
|
||||||
|
boxId: string;
|
||||||
|
boxClass: string | null;
|
||||||
|
itemCount: number;
|
||||||
|
locationLabel: string;
|
||||||
|
lastSyncedAt: string;
|
||||||
|
status: string;
|
||||||
|
serverName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyBoxListData {
|
||||||
|
rows: SupplyBoxRow[];
|
||||||
|
capability: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of supply boxes, fetched through collection access control (default
|
||||||
|
* access + the explicit session user, never overrideAccess). A server restart
|
||||||
|
* mints a fresh sessionId, so rows from older generations are stale snapshots;
|
||||||
|
* only the newest sessionId per server is kept.
|
||||||
|
*/
|
||||||
|
async function getSupplyBoxList(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
): Promise<SupplyBoxListData> {
|
||||||
|
const qualified = await hasLogisticsQualification(payload, user);
|
||||||
|
if (!qualified) return { rows: [], capability: false };
|
||||||
|
|
||||||
|
const found = await payload.find({
|
||||||
|
collection: "supply-boxes",
|
||||||
|
sort: "-lastSyncedAt",
|
||||||
|
limit: SUPPLY_BOX_PAGE_SIZE,
|
||||||
|
depth: 1,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newestSessionByServer = new Map<number, string>();
|
||||||
|
for (const doc of found.docs) {
|
||||||
|
const serverId = relationId(doc.server);
|
||||||
|
if (serverId === null) continue;
|
||||||
|
const current = newestSessionByServer.get(serverId);
|
||||||
|
if (current === undefined || doc.sessionId > current) {
|
||||||
|
newestSessionByServer.set(serverId, doc.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: SupplyBoxRow[] = found.docs
|
||||||
|
.filter((doc) => {
|
||||||
|
const serverId = relationId(doc.server);
|
||||||
|
return serverId !== null && newestSessionByServer.get(serverId) === doc.sessionId;
|
||||||
|
})
|
||||||
|
.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
boxId: doc.boxId,
|
||||||
|
boxClass: doc.boxClass ?? null,
|
||||||
|
itemCount: boxItemCount(doc.contents),
|
||||||
|
locationLabel: boxLocationLabel(doc.location),
|
||||||
|
lastSyncedAt: doc.lastSyncedAt,
|
||||||
|
status: doc.status,
|
||||||
|
serverName: typeof doc.server === "object" && doc.server !== null ? doc.server.name : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { rows, capability: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SupplyBoxesPage() {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const data = await getSupplyBoxList(payload, surfaceUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
|
||||||
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
|
Crate inventory synced from the game server through the Arma bridge. Rows show the newest
|
||||||
|
generation per server; older sessions are filtered out.
|
||||||
|
</p>
|
||||||
|
</PageShell>
|
||||||
|
|
||||||
|
<OperationsRealtimeRefresher />
|
||||||
|
|
||||||
|
{data.capability === false ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="error"
|
||||||
|
message="You do not have the Logistics qualification to manage supply boxes."
|
||||||
|
hint="Request the Logistics qualification from unit command."
|
||||||
|
/>
|
||||||
|
) : data.rows.length === 0 ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="empty"
|
||||||
|
message="No supply boxes have been registered yet."
|
||||||
|
hint="Boxes appear here once the game server registers them through the Arma bridge."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4" data-slot="supply-boxes-list">
|
||||||
|
{data.rows.map((row) => {
|
||||||
|
const cells: ReadoutCell[] = [
|
||||||
|
{ label: "Class", value: row.boxClass ?? "n/a" },
|
||||||
|
{ label: "Items", value: String(row.itemCount) },
|
||||||
|
{ label: "Location", value: row.locationLabel },
|
||||||
|
{ label: "Sync", value: syncAgeLabel(row.lastSyncedAt) },
|
||||||
|
{ label: "Server", value: row.serverName ?? "n/a" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
data-slot="supply-box-row"
|
||||||
|
className="flex flex-col gap-2 rounded border border-white/10 bg-card/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
|
||||||
|
<Link
|
||||||
|
href={`/logistics/supply-boxes/${row.id}`}
|
||||||
|
data-slot="supply-box-id"
|
||||||
|
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{row.boxId}
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
data-slot="supply-box-status"
|
||||||
|
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[boxStatusTone(row.status)]}`}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<LedgerRow cells={cells} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(frontend)/map/[id]/page.tsx
Normal file
60
src/app/(frontend)/map/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface MapViewPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: MapViewPageProps): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
return { title: `World Map #${id}: Polaris Task Force` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MapViewPage({ params }: MapViewPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const mapId = Number(id);
|
||||||
|
const payload = await getPayload({ config: await config });
|
||||||
|
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
||||||
|
|
||||||
|
const map = Number.isInteger(mapId)
|
||||||
|
? await payload
|
||||||
|
.findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true })
|
||||||
|
.catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!map) notFound();
|
||||||
|
|
||||||
|
const [canEdit, canPlace] = await Promise.all([
|
||||||
|
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
|
||||||
|
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Inactive maps stay reachable for editors previewing them, but are hidden
|
||||||
|
// from everyone else.
|
||||||
|
if (!map.isActive && !canEdit) notFound();
|
||||||
|
|
||||||
|
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]">{map.name}</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>
|
||||||
|
{map.worldSizeWidth}×{map.worldSizeHeight} m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MapLoader map={{ id: map.id, name: map.name }} canEdit={canEdit} canPlace={canPlace} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,44 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { headers } from "next/headers";
|
import { Fragment } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Map as MapIcon } from "lucide-react";
|
import { Map as MapIcon, MapPinIcon } from "lucide-react";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import type { Campaign, Faction, Map as MapDoc, Media } from "@/payload-types";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import {
|
||||||
import { MapLoader } from "./MapLoader";
|
easeStatClass,
|
||||||
|
isWorldMapEntry,
|
||||||
|
weatherStatClass,
|
||||||
|
weatherStatLabel,
|
||||||
|
worldMapScreenQuery,
|
||||||
|
} from "@/lib/map/screen";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "World Map: Polaris Task Force",
|
title: "World Map: Polaris Task Force",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function MapPage() {
|
function basemapUrl(map: MapDoc): string | null {
|
||||||
|
const image = map.basemapImage as Media | number | null | undefined;
|
||||||
|
if (image && typeof image === "object" && image.url) return image.url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MapPickerPage() {
|
||||||
const payload = await getPayload({ config: await config });
|
const payload = await getPayload({ config: await config });
|
||||||
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
|
|
||||||
|
|
||||||
const [mapsRes, canEdit, canPlace] = await Promise.all([
|
const mapsRes = await payload.find({
|
||||||
payload.find({
|
collection: "maps",
|
||||||
collection: "maps",
|
where: worldMapScreenQuery(),
|
||||||
where: { isActive: { equals: true } },
|
sort: "name",
|
||||||
sort: "name",
|
limit: 100,
|
||||||
limit: 100,
|
depth: 1,
|
||||||
depth: 0,
|
overrideAccess: true,
|
||||||
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 }));
|
const maps = mapsRes.docs.filter((map) => isWorldMapEntry(map));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-[240px] flex-1 flex-col">
|
<div className="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 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">
|
<div className="flex items-center gap-2">
|
||||||
<MapIcon className="size-4 text-muted-foreground" />
|
<MapIcon className="size-4 text-muted-foreground" />
|
||||||
|
|
@ -44,7 +51,104 @@ export default async function MapPage() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MapLoader maps={maps} canEdit={canEdit} canPlace={canPlace} />
|
|
||||||
|
{maps.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-24 text-muted-foreground">
|
||||||
|
<MapPinIcon className="size-6" />
|
||||||
|
<p className="text-sm">
|
||||||
|
No maps published to the world map yet. Maps are configured in the admin panel (World:
|
||||||
|
Maps) with the "World Map Entry" flag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{maps.map((map) => {
|
||||||
|
const campaign =
|
||||||
|
map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null;
|
||||||
|
const faction =
|
||||||
|
map.factionControl && typeof map.factionControl === "object"
|
||||||
|
? (map.factionControl as Faction)
|
||||||
|
: null;
|
||||||
|
const url = basemapUrl(map);
|
||||||
|
const stats = [
|
||||||
|
map.weather
|
||||||
|
? {
|
||||||
|
key: `w-${map.id}`,
|
||||||
|
label: weatherStatLabel(map.weather),
|
||||||
|
cls: weatherStatClass(map.weather),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
map.easeOfAccess
|
||||||
|
? {
|
||||||
|
key: `e-${map.id}`,
|
||||||
|
label: `${map.easeOfAccess} access`,
|
||||||
|
cls: easeStatClass(map.easeOfAccess),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
faction ? { key: `f-${map.id}`, label: faction.name, cls: "text-violet-400" } : null,
|
||||||
|
].filter((stat) => stat !== null);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={map.id}
|
||||||
|
href={`/map/${map.id}`}
|
||||||
|
className="group overflow-hidden rounded-lg border border-border bg-card transition-all duration-200 hover:scale-[1.01] hover:border-primary/40"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden bg-muted">
|
||||||
|
{url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={map.name}
|
||||||
|
className="size-full object-cover opacity-80 transition-opacity group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||||
|
<MapIcon className="size-8 opacity-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate text-sm font-semibold">{map.name}</p>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{map.worldSizeWidth}×{map.worldSizeHeight} m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{campaign ? campaign.name : "Unit grid"}
|
||||||
|
</p>
|
||||||
|
{map.description && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{map.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/60 pt-1.5">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<Fragment key={stat.key}>
|
||||||
|
{index > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="font-mono text-[10px] text-white/25"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[10px] uppercase tracking-wider ${stat.cls}`}
|
||||||
|
>
|
||||||
|
{stat.label}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
src/app/(frontend)/operations/[operationId]/page.tsx
Normal file
79
src/app/(frontend)/operations/[operationId]/page.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { getOperationAar } from "@/lib/operations/surfaceQuery";
|
||||||
|
import {
|
||||||
|
AarView,
|
||||||
|
NoticeState,
|
||||||
|
OperationsRealtimeRefresher,
|
||||||
|
OperationsStrip,
|
||||||
|
PageShell,
|
||||||
|
ReconcilePanel,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { ReconcilableEvent } from "@/components/frontend/operations";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Operation AAR: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OperationAarPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ operationId: string }>;
|
||||||
|
}) {
|
||||||
|
const { operationId } = await params;
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const aar = await getOperationAar(payload, surfaceUser, operationId);
|
||||||
|
const canReconcile = user ? await hasPermission(payload, user, "operation-events:update") : false;
|
||||||
|
const reconcilable: ReconcilableEvent[] =
|
||||||
|
aar && canReconcile
|
||||||
|
? aar.events
|
||||||
|
.filter((ev) => ev.status !== "validated")
|
||||||
|
.map((ev) => ({
|
||||||
|
id: ev.id,
|
||||||
|
eventId: ev.eventId,
|
||||||
|
status: ev.status,
|
||||||
|
errorCode: ev.errorCode,
|
||||||
|
errorMessage: ev.errorMessage,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<OperationsRealtimeRefresher />
|
||||||
|
<PageShell title={`Operation ${operationId}`} meta="AFTER-ACTION REPORT // PROVENANCE LINKED">
|
||||||
|
{aar === null ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="empty"
|
||||||
|
message={`No operation events are recorded under "${operationId}".`}
|
||||||
|
hint="The operation id may be wrong, or its events have not synced yet. Check the operations ledger."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/operations/ledger"
|
||||||
|
className="border border-white/25 px-3 py-1.5 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/40 hover:text-white"
|
||||||
|
>
|
||||||
|
Open operations ledger
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AarView aar={aar} />
|
||||||
|
<ReconcilePanel events={reconcilable} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageShell>
|
||||||
|
<OperationsStrip left="OPERATION AAR // UNIT COMMAND" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/(frontend)/operations/actions.ts
Normal file
48
src/app/(frontend)/operations/actions.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { replayOperationEvent } from "@/lib/operations/reconcile";
|
||||||
|
import { backfillOperationEvents } from "@/lib/operations/rollout";
|
||||||
|
|
||||||
|
export interface ActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await headers(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileOperationEvent(operationEventId: number): Promise<ActionResult<{ status?: string; effectsCreated?: number }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) return { success: false, error: "Authentication required." };
|
||||||
|
const result = await replayOperationEvent(payload, user, operationEventId);
|
||||||
|
if (!result.ok) return { success: false, error: result.error ?? "Replay failed." };
|
||||||
|
return { success: true, data: { status: result.status, effectsCreated: result.effectsCreated } };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileBackfill(): Promise<ActionResult<{ processed: number; skipped: number }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) return { success: false, error: "Authentication required." };
|
||||||
|
const result = await backfillOperationEvents(payload, user);
|
||||||
|
if (result.forbidden) return { success: false, error: "forbidden" };
|
||||||
|
return { success: true, data: { processed: result.processed, skipped: result.skipped } };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/(frontend)/operations/ledger/page.tsx
Normal file
65
src/app/(frontend)/operations/ledger/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { getOperationLedger } from "@/lib/operations/surfaceQuery";
|
||||||
|
import {
|
||||||
|
LedgerTable,
|
||||||
|
OperationsRealtimeRefresher,
|
||||||
|
OperationsStrip,
|
||||||
|
PageShell,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { LedgerTableFilters } from "@/components/frontend/operations";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Operations Ledger: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LedgerSearchParams {
|
||||||
|
operationId?: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
page?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OperationsLedgerPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<LedgerSearchParams>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const page = Number.parseInt(params.page ?? "1", 10);
|
||||||
|
const filters: LedgerTableFilters = {
|
||||||
|
operationId: params.operationId || undefined,
|
||||||
|
status: params.status || undefined,
|
||||||
|
type: params.type || undefined,
|
||||||
|
};
|
||||||
|
const result = await getOperationLedger(
|
||||||
|
payload,
|
||||||
|
surfaceUser,
|
||||||
|
filters,
|
||||||
|
Number.isNaN(page) ? 1 : page,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<OperationsRealtimeRefresher />
|
||||||
|
<PageShell title="Operations ledger" meta="SOURCE EVENTS // EFFECT LEDGER">
|
||||||
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
|
Every validated, rejected, and dead-lettered operation event with its derived effects.
|
||||||
|
Rejected and dead-lettered entries are retained as evidence and never applied.
|
||||||
|
</p>
|
||||||
|
</PageShell>
|
||||||
|
<LedgerTable result={result} filters={filters} />
|
||||||
|
<OperationsStrip left="OPERATIONS LEDGER // UNIT COMMAND" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/app/(frontend)/operations/page.tsx
Normal file
59
src/app/(frontend)/operations/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { getOperationOverview } from "@/lib/operations/surface";
|
||||||
|
import {
|
||||||
|
MyExtractionsCard,
|
||||||
|
OperationsRealtimeRefresher,
|
||||||
|
OperationsStrip,
|
||||||
|
PageShell,
|
||||||
|
PressureCard,
|
||||||
|
ReadinessCard,
|
||||||
|
RecentOperationsCard,
|
||||||
|
ReservationsCard,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Operations: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OperationsPage() {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const overview = await getOperationOverview(payload, surfaceUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<OperationsRealtimeRefresher />
|
||||||
|
<PageShell title="Operations" meta="OPERATION FEED // AFTER-ACTION RECORDS">
|
||||||
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
|
Live and historical operation activity: extraction returns, settlements, readiness
|
||||||
|
aggregates, and zone pressure. Every effect here is provenance-linked to a validated
|
||||||
|
source event from the Arma bridge.
|
||||||
|
</p>
|
||||||
|
</PageShell>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<RecentOperationsCard operations={overview.recentOperations} />
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<MyExtractionsCard extractions={overview.myExtractions} signedIn={user !== null} />
|
||||||
|
<ReadinessCard readiness={overview.ownReadiness} canSee={overview.capabilities.canSeeReadiness} />
|
||||||
|
<ReservationsCard
|
||||||
|
reservations={overview.activeReservations}
|
||||||
|
canSee={overview.capabilities.canSeeReservations}
|
||||||
|
/>
|
||||||
|
<PressureCard pressure={overview.pressure} canSee={overview.capabilities.canSeePressure} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OperationsStrip left="OPERATIONS // UNIT COMMAND" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/app/(frontend)/operations/reservations/[id]/page.tsx
Normal file
252
src/app/(frontend)/operations/reservations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operations";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
|
import type { OperationReservation } from "@/payload-types";
|
||||||
|
import { getReservationDetail } from "../surface";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
function relationId(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||||
|
const id = (value as { id: unknown }).id;
|
||||||
|
return typeof id === "number" ? id : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationLabel(value: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"codeName" in (value as { codeName?: unknown })
|
||||||
|
) {
|
||||||
|
const codeName = (value as { codeName?: unknown }).codeName;
|
||||||
|
if (typeof codeName === "string" && codeName) return codeName;
|
||||||
|
}
|
||||||
|
return String(relationId(value) ?? "n/a");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationDetail {
|
||||||
|
reservation: OperationReservation | null;
|
||||||
|
canCancel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full detail for a single reservation, fetched through collection access
|
||||||
|
* control (default access + the explicit session user, never overrideAccess).
|
||||||
|
* Returns null for an unknown id so the page can render a not-found notice.
|
||||||
|
* canCancel is true only when the row is still reserved AND the viewer holds
|
||||||
|
* operation-reservations:update.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section data-slot="reservation-section" className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kvRow(label: string, value: React.ReactNode) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||||
|
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Reservation Detail: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ReservationDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const { reservation, canCancel } = await getReservationDetail(payload, surfaceUser, id);
|
||||||
|
|
||||||
|
if (reservation === null) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title="Reservation" meta="ALLOCATION RESERVATIONS // COMMAND VIEW">
|
||||||
|
<NoticeState
|
||||||
|
variant="error"
|
||||||
|
message="Reservation not found."
|
||||||
|
hint="The reservation id is unknown or you lack permission to read it."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/operations/reservations"
|
||||||
|
className="mt-2 border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to reservations
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const personnel = reservation.personnel ?? [];
|
||||||
|
const vehicles = reservation.vehicles ?? [];
|
||||||
|
const cargo = reservation.cargo ?? [];
|
||||||
|
const budget = reservation.budget;
|
||||||
|
|
||||||
|
const readoutCells = [
|
||||||
|
{ label: "Key", value: reservation.reservationKey },
|
||||||
|
{ label: "Operation", value: reservation.operationId },
|
||||||
|
{ label: "Status", value: reservation.status },
|
||||||
|
{ label: "Expires", value: reservation.expiresAt ?? "n/a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title={reservation.reservationKey} meta="RESERVATION // DETAIL">
|
||||||
|
<Link
|
||||||
|
href="/operations/reservations"
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Back to reservations
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<LedgerRow cells={readoutCells} />
|
||||||
|
|
||||||
|
<Section title="Personnel">
|
||||||
|
{personnel.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No personnel reserved." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{personnel.map((member) => (
|
||||||
|
<li
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{relationLabel(member.user)} · id {relationId(member.user)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||||
|
{member.slot ?? "slot"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Vehicles">
|
||||||
|
{vehicles.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No vehicles reserved." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{vehicles.map((vehicle) => (
|
||||||
|
<li
|
||||||
|
key={vehicle.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{relationLabel(vehicle.vehicle)} · id {relationId(vehicle.vehicle)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Cargo">
|
||||||
|
{cargo.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No cargo reserved." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{cargo.map((line) => (
|
||||||
|
<li
|
||||||
|
key={line.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{relationLabel(line.resource)} · id {relationId(line.resource)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{line.amount}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Budget">
|
||||||
|
{budget?.amount != null || budget?.account != null ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{kvRow(
|
||||||
|
"Account",
|
||||||
|
budget.account != null
|
||||||
|
? `${relationLabel(budget.account)} · id ${relationId(budget.account)}`
|
||||||
|
: "n/a",
|
||||||
|
)}
|
||||||
|
{kvRow("Amount", budget.amount ?? "n/a")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NoticeState variant="empty" message="No budget earmarked." />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Routing">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{kvRow(
|
||||||
|
"Origin",
|
||||||
|
reservation.origin != null ? relationLabel(reservation.origin) : "n/a",
|
||||||
|
)}
|
||||||
|
{kvRow(
|
||||||
|
"Destination",
|
||||||
|
reservation.destination != null ? relationLabel(reservation.destination) : "n/a",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Metadata">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{kvRow(
|
||||||
|
"Created by",
|
||||||
|
reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a",
|
||||||
|
)}
|
||||||
|
{kvRow("Created at", reservation.createdAt)}
|
||||||
|
{kvRow("Expires", reservation.expiresAt ?? "n/a")}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{reservation.status !== "reserved" && (
|
||||||
|
<Section title="Settlement">
|
||||||
|
<pre className="overflow-x rounded border border-white/10 bg-black/40 p-2 font-mono text-[11px] text-white/70">
|
||||||
|
{JSON.stringify(reservation.settlement, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancel && reservation.status === "reserved" ? (
|
||||||
|
<CancelButton reservationId={reservation.id} />
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/app/(frontend)/operations/reservations/actions.ts
Normal file
85
src/app/(frontend)/operations/reservations/actions.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import type { OperationReservation } from "@/payload-types";
|
||||||
|
import {
|
||||||
|
reserveOperationAssets,
|
||||||
|
settleOperationReservation,
|
||||||
|
type ReserveOperationAssetsInput,
|
||||||
|
} from "@/lib/operations/allocation";
|
||||||
|
|
||||||
|
export interface ActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await headers(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve people, vehicles, cargo, and treasury budget for an operation.
|
||||||
|
*
|
||||||
|
* Anti-spoofing: the actor is ALWAYS the authenticated session user. The
|
||||||
|
* client-supplied `actorId` is overwritten with `user.id` before the input
|
||||||
|
* reaches the allocation engine, so a caller cannot attribute a reservation
|
||||||
|
* to (or impersonate) another account.
|
||||||
|
*/
|
||||||
|
export async function createReservation(
|
||||||
|
input: ReserveOperationAssetsInput,
|
||||||
|
): Promise<ActionResult<OperationReservation>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) return { success: false, error: "Authentication required." };
|
||||||
|
if (!(await hasPermission(payload, user, "operation-reservations:create"))) {
|
||||||
|
return { success: false, error: "forbidden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedInput: ReserveOperationAssetsInput = {
|
||||||
|
...input,
|
||||||
|
actorId: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = await reserveOperationAssets(payload, sanitizedInput);
|
||||||
|
return { success: true, data: row };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a still-reserved reservation. Settlement is exactly-once: a second
|
||||||
|
* cancel (or any settle on an already-terminal row) is a clean no-op that
|
||||||
|
* returns the stored row, and an unknown ref maps to a "not found" error.
|
||||||
|
*/
|
||||||
|
export async function cancelReservation(
|
||||||
|
ref: { reservationId?: number; reservationKey?: string },
|
||||||
|
opts?: { reason?: string },
|
||||||
|
): Promise<ActionResult<OperationReservation>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
if (!user) return { success: false, error: "Authentication required." };
|
||||||
|
if (!(await hasPermission(payload, user, "operation-reservations:update"))) {
|
||||||
|
return { success: false, error: "forbidden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await settleOperationReservation(payload, ref, {
|
||||||
|
outcome: "cancelled",
|
||||||
|
reason: opts?.reason,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
return { success: true, data: row };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/app/(frontend)/operations/reservations/page.tsx
Normal file
263
src/app/(frontend)/operations/reservations/page.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { Where } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import {
|
||||||
|
LedgerRow,
|
||||||
|
NoticeState,
|
||||||
|
PageShell,
|
||||||
|
STATUS_TONE_CLASSES,
|
||||||
|
statusToneFor,
|
||||||
|
type ReadoutCell,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
|
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
|
||||||
|
import { getReservationList, type ReservationListData } from "./surface";
|
||||||
|
|
||||||
|
export const RESERVATION_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export interface ReservationRow {
|
||||||
|
id: number;
|
||||||
|
reservationKey: string;
|
||||||
|
operationId: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationFilters {
|
||||||
|
status?: string;
|
||||||
|
operationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of allocation reservations with status/operationId filters and
|
||||||
|
* pagination. Runs through collection access control (default access + the
|
||||||
|
* explicit session user, never overrideAccess). Gated behind
|
||||||
|
* operation-reservations:read: a viewer lacking that permission gets an empty
|
||||||
|
* result (capability false) so the page can render the permission notice
|
||||||
|
* instead of a list that looks like "no data".
|
||||||
|
*/
|
||||||
|
|
||||||
|
function reservationHref(filters: ReservationFilters, page: number): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.status) params.set("status", filters.status);
|
||||||
|
if (filters.operationId) params.set("operationId", filters.operationId);
|
||||||
|
if (page > 1) params.set("page", String(page));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `/operations/reservations?${qs}` : "/operations/reservations";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "", label: "All" },
|
||||||
|
{ value: "reserved", label: "Reserved" },
|
||||||
|
{ value: "settled", label: "Settled" },
|
||||||
|
{ value: "cancelled", label: "Cancelled" },
|
||||||
|
{ value: "expired", label: "Expired" },
|
||||||
|
{ value: "no-show", label: "No-Show" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Reservations: Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ReservationsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<ReservationFilters & { page?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
const { user: authUser } = await payload.auth({
|
||||||
|
headers: await nextHeaders(),
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
|
||||||
|
|
||||||
|
const page = Number.parseInt(params.page ?? "1", 10);
|
||||||
|
const filters: ReservationFilters = {
|
||||||
|
status: params.status || undefined,
|
||||||
|
operationId: params.operationId || undefined,
|
||||||
|
};
|
||||||
|
const data = await getReservationList(
|
||||||
|
payload,
|
||||||
|
surfaceUser,
|
||||||
|
filters,
|
||||||
|
Number.isNaN(page) ? 1 : page,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The cancel control is server-gated: only viewers holding
|
||||||
|
// operation-reservations:update may render it, and only on reserved rows.
|
||||||
|
const canCancel =
|
||||||
|
data.capability && user !== null
|
||||||
|
? await hasPermission(payload, user, "operation-reservations:update")
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Server-gated create dialog: only operation-reservations:create viewers open it;
|
||||||
|
// logistics qualification gates the vehicle/cargo/budget sections inside.
|
||||||
|
const canCreate = user
|
||||||
|
? await hasPermission(payload, user, "operation-reservations:create")
|
||||||
|
: false;
|
||||||
|
const canManageLogistics = user ? await hasLogisticsQualification(payload, user) : false;
|
||||||
|
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
|
<PageShell title="Reservations" meta="ALLOCATION RESERVATIONS // COMMAND VIEW">
|
||||||
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
|
Every allocation of people, vehicles, cargo, and treasury budget against an operation,
|
||||||
|
with its settlement outcome. Reservations are earmarks only; the settlement record on a
|
||||||
|
row captures what was consumed or returned.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
data-slot="reservations-filters"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
|
||||||
|
htmlFor="reservation-status"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="reservation-status"
|
||||||
|
name="status"
|
||||||
|
defaultValue={filters.status || ""}
|
||||||
|
className="border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value} className="bg-black">
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
|
||||||
|
htmlFor="reservation-operation"
|
||||||
|
>
|
||||||
|
Operation
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reservation-operation"
|
||||||
|
name="operationId"
|
||||||
|
defaultValue={filters.operationId || ""}
|
||||||
|
placeholder="operationId"
|
||||||
|
className="w-64 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="border border-white/15 bg-black/40 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/operations/reservations"
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Link>
|
||||||
|
</form>
|
||||||
|
</PageShell>
|
||||||
|
|
||||||
|
{canCreate && options ? (
|
||||||
|
<CreateReservationDialog
|
||||||
|
options={options}
|
||||||
|
canManageLogistics={canManageLogistics}
|
||||||
|
canCreate={canCreate}
|
||||||
|
currentUserId={user!.id as number}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data.capability === false ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="error"
|
||||||
|
message="You do not have permission to view reservations."
|
||||||
|
hint="Request the operation-reservations:read capability from a command staffer."
|
||||||
|
/>
|
||||||
|
) : data.rows.length === 0 ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="empty"
|
||||||
|
message="No reservations match this view."
|
||||||
|
hint="Reservations appear here once staff allocate assets to an operation."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4" data-slot="reservations-list">
|
||||||
|
{data.rows.map((row) => {
|
||||||
|
const cells: ReadoutCell[] = [
|
||||||
|
{ label: "Operation", value: row.operationId },
|
||||||
|
{ label: "Status", value: row.status },
|
||||||
|
{ label: "Expires", value: row.expiresAt ?? "n/a" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
data-slot="reservation-row"
|
||||||
|
className="flex flex-col gap-2 rounded border border-white/10 bg-card/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
|
||||||
|
<Link
|
||||||
|
href={`/operations/reservations/${row.id}`}
|
||||||
|
data-slot="reservation-key"
|
||||||
|
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{row.reservationKey}
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
data-slot="reservation-status"
|
||||||
|
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(row.status)]}`}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
{canCancel && row.status === "reserved" ? (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<CancelButton reservationId={row.id} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<LedgerRow cells={cells} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||||
|
Page {data.page} of {Math.max(1, data.totalPages)} ({data.totalDocs} reservations)
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
{data.page > 1 && (
|
||||||
|
<Link
|
||||||
|
href={reservationHref(filters, data.page - 1)}
|
||||||
|
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{data.page < data.totalPages && (
|
||||||
|
<Link
|
||||||
|
href={reservationHref(filters, data.page + 1)}
|
||||||
|
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/(frontend)/operations/reservations/surface.ts
Normal file
109
src/app/(frontend)/operations/reservations/surface.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
RESERVATION_PAGE_SIZE,
|
||||||
|
type PayloadType,
|
||||||
|
type ReservationFilters,
|
||||||
|
type ReservationRow,
|
||||||
|
} from "./page";
|
||||||
|
import type { OperationReservation } from "@/payload-types";
|
||||||
|
import type { ReservationDetail } from "./[id]/page";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { Where } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import {
|
||||||
|
LedgerRow,
|
||||||
|
NoticeState,
|
||||||
|
PageShell,
|
||||||
|
STATUS_TONE_CLASSES,
|
||||||
|
statusToneFor,
|
||||||
|
type ReadoutCell,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
|
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
|
||||||
|
|
||||||
|
export interface ReservationListData {
|
||||||
|
rows: ReservationRow[];
|
||||||
|
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
|
||||||
|
capability: boolean;
|
||||||
|
page: number;
|
||||||
|
totalDocs: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservationList(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
filters: ReservationFilters,
|
||||||
|
requestedPage = 1,
|
||||||
|
): Promise<ReservationListData> {
|
||||||
|
const canSee = await hasPermission(payload, user, "operation-reservations:read");
|
||||||
|
if (!canSee) {
|
||||||
|
return { rows: [], capability: false, page: 1, totalDocs: 0, totalPages: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: Where[] = [];
|
||||||
|
if (filters.status) conditions.push({ status: { equals: filters.status } });
|
||||||
|
if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } });
|
||||||
|
const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined;
|
||||||
|
|
||||||
|
const found = await payload.find({
|
||||||
|
collection: "operation-reservations",
|
||||||
|
where,
|
||||||
|
sort: "-createdAt",
|
||||||
|
page: Math.max(1, requestedPage),
|
||||||
|
limit: RESERVATION_PAGE_SIZE,
|
||||||
|
depth: 0,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: ReservationRow[] = found.docs.map((doc) => {
|
||||||
|
const r = doc as unknown as ReservationRow;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
reservationKey: r.reservationKey,
|
||||||
|
operationId: r.operationId,
|
||||||
|
status: r.status,
|
||||||
|
expiresAt: r.expiresAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
capability: true,
|
||||||
|
page: found.page ?? Math.max(1, requestedPage),
|
||||||
|
totalDocs: found.totalDocs ?? found.docs.length,
|
||||||
|
totalPages: found.totalPages ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservationDetail(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
id: string,
|
||||||
|
): Promise<ReservationDetail> {
|
||||||
|
let reservation: OperationReservation | null = null;
|
||||||
|
try {
|
||||||
|
reservation = await payload.findByID({
|
||||||
|
collection: "operation-reservations",
|
||||||
|
id: Number(id),
|
||||||
|
depth: 1,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
reservation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCancel =
|
||||||
|
reservation !== null &&
|
||||||
|
reservation.status === "reserved" &&
|
||||||
|
user !== null &&
|
||||||
|
(await hasPermission(payload, user, "operation-reservations:update"));
|
||||||
|
|
||||||
|
return { reservation, canCancel };
|
||||||
|
}
|
||||||
|
|
@ -32,8 +32,28 @@ function npcIdFromListing(listing: MarketListing): number | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatisticsDisabled() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Statistics</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Personnel statistics are currently disabled by a unit admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function StatisticsPage() {
|
export default async function StatisticsPage() {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
const { statisticsEnabled } = await payload.findGlobal({ slug: "game-rules" });
|
||||||
|
if (!statisticsEnabled) {
|
||||||
|
return <StatisticsDisabled />;
|
||||||
|
}
|
||||||
const [npcsRes, listingsRes] = await Promise.all([
|
const [npcsRes, listingsRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "game-npcs",
|
collection: "game-npcs",
|
||||||
|
|
|
||||||
|
|
@ -3576,3 +3576,4 @@
|
||||||
[data-radix-popper-content-wrapper] {
|
[data-radix-popper-content-wrapper] {
|
||||||
z-index: 1300 !important;
|
z-index: 1300 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { asJson } from "@/lib/arma-bridge/json";
|
import { asJson } from "@/lib/arma-bridge/json";
|
||||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||||
|
import { acknowledgeCommand } from "@/lib/arma-bridge/commands";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -17,26 +18,20 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json({ error: "id and success are required." }, { status: 400 });
|
return NextResponse.json({ error: "id and success are required." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands = await payload.find({
|
const outcome = await acknowledgeCommand(payload, server.id, {
|
||||||
collection: "arma-commands",
|
id: body.id,
|
||||||
where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] },
|
success: body.success,
|
||||||
limit: 1,
|
result: asJson(body.result, null),
|
||||||
overrideAccess: true,
|
error: typeof body.error === "string" ? body.error : undefined,
|
||||||
});
|
|
||||||
const command = commands.docs[0];
|
|
||||||
if (!command) return NextResponse.json({ error: "Command not found." }, { status: 404 });
|
|
||||||
|
|
||||||
await payload.update({
|
|
||||||
collection: "arma-commands",
|
|
||||||
id: command.id,
|
|
||||||
data: {
|
|
||||||
status: body.success ? "succeeded" : "failed",
|
|
||||||
result: asJson(body.result),
|
|
||||||
error: typeof body.error === "string" ? body.error : undefined,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
if (!outcome.ok) {
|
||||||
|
const status = outcome.error === "not-found" ? 404 : 409;
|
||||||
|
const message =
|
||||||
|
outcome.error === "not-found"
|
||||||
|
? "Command not found."
|
||||||
|
: "Command was not delivered to this server; ack rejected.";
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, duplicate: outcome.duplicate === true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||||
|
import { recoverExpiredCommands } from "@/lib/arma-bridge/commands";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
|
||||||
if (auth instanceof NextResponse) return auth;
|
if (auth instanceof NextResponse) return auth;
|
||||||
const { payload, server } = auth;
|
const { payload, server } = auth;
|
||||||
|
|
||||||
|
// Expired leases (delivered, never acked) rejoin the queued pool.
|
||||||
|
await recoverExpiredCommands(payload, server.id);
|
||||||
|
|
||||||
const commands = await payload.find({
|
const commands = await payload.find({
|
||||||
collection: "arma-commands",
|
collection: "arma-commands",
|
||||||
where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] },
|
where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] },
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
|
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
|
||||||
|
import { effectiveMaxMass } from "@/lib/base/modules";
|
||||||
|
import {
|
||||||
|
aggregateCompoundStorage,
|
||||||
|
compoundParentId,
|
||||||
|
isOperationalMember,
|
||||||
|
} from "@/lib/map/compounds";
|
||||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
|
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
|
||||||
|
|
||||||
|
|
@ -132,9 +138,11 @@ export async function GET(req: NextRequest) {
|
||||||
constructionCompletesAt: structure.constructionCompletesAt ?? null,
|
constructionCompletesAt: structure.constructionCompletesAt ?? null,
|
||||||
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
|
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
|
||||||
storageUsedKg,
|
storageUsedKg,
|
||||||
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
|
storageCapacityKg: blueprint ? effectiveMaxMass(blueprint, structure.installedModules) : null,
|
||||||
labelVisible: structure.labelVisible ?? null,
|
labelVisible: structure.labelVisible ?? null,
|
||||||
labelPriority: structure.labelPriority ?? null,
|
labelPriority: structure.labelPriority ?? null,
|
||||||
|
parentId: compoundParentId(structure),
|
||||||
|
compoundChildren: 0,
|
||||||
blueprintId: blueprint?.id ?? null,
|
blueprintId: blueprint?.id ?? null,
|
||||||
blueprintName: blueprint?.name ?? null,
|
blueprintName: blueprint?.name ?? null,
|
||||||
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
|
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
|
||||||
|
|
@ -160,6 +168,26 @@ export async function GET(req: NextRequest) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compound pooling: children fold their storage into the hub feature and lose
|
||||||
|
// their own map labels; the hub carries the compound label and ribbon totals.
|
||||||
|
const featuresById = new Map(structures.map((feature) => [feature.id, feature]));
|
||||||
|
const childrenByHub = new Map<number, StructureFeature[]>();
|
||||||
|
for (const feature of structures) {
|
||||||
|
if (feature.parentId === null) continue;
|
||||||
|
feature.labelVisible = false;
|
||||||
|
const siblings = childrenByHub.get(feature.parentId) ?? [];
|
||||||
|
siblings.push(feature);
|
||||||
|
childrenByHub.set(feature.parentId, siblings);
|
||||||
|
}
|
||||||
|
for (const [hubId, children] of childrenByHub) {
|
||||||
|
const hub = featuresById.get(hubId);
|
||||||
|
if (!hub) continue;
|
||||||
|
const totals = aggregateCompoundStorage(hub, children);
|
||||||
|
hub.storageUsedKg = totals.storageUsedKg;
|
||||||
|
hub.storageCapacityKg = totals.storageCapacityKg;
|
||||||
|
hub.compoundChildren = children.length;
|
||||||
|
}
|
||||||
|
|
||||||
const relEntries = (value: unknown): { id: number; name: string }[] =>
|
const relEntries = (value: unknown): { id: number; name: string }[] =>
|
||||||
(Array.isArray(value) ? value : []).map((entry) =>
|
(Array.isArray(value) ? value : []).map((entry) =>
|
||||||
typeof entry === "object" && entry !== null && "id" in entry
|
typeof entry === "object" && entry !== null && "id" in entry
|
||||||
|
|
@ -174,6 +202,7 @@ export async function GET(req: NextRequest) {
|
||||||
description: node.description ?? null,
|
description: node.description ?? null,
|
||||||
resources: relEntries(node.resources),
|
resources: relEntries(node.resources),
|
||||||
richness: node.richness ?? null,
|
richness: node.richness ?? null,
|
||||||
|
icon: node.icon ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const roads = roadsRes.docs.map((road) => ({
|
const roads = roadsRes.docs.map((road) => ({
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,11 @@ Collections define `access` at field + document level using helpers from `src/ut
|
||||||
|
|
||||||
Admin group access: `developer` only for destructive operations, `admin` for read/write.
|
Admin group access: `developer` only for destructive operations, `admin` for read/write.
|
||||||
|
|
||||||
|
Permission tiers and panel gates:
|
||||||
|
- `system:admin-access` ("Superuser Tier" label) = functional superuser tier: final approval states (`approvalStatusFieldAccess`), Users admin page, read-all notifications. Only built-in `admin` + superuser roles hold it; NEVER grant to division roles.
|
||||||
|
- Admin panel ENTRY: Users collection `access.admin` uses `canAccessAdminPanel` (superuser OR any `admin:<slug>:manage`). Per-page sidebar visibility: `requireAdminPageAccess` (`<slug>:read` + `admin:<slug>:manage`). Two gates, do not collapse them.
|
||||||
|
- Payload 3 field-level `access` returning `false` on update silently drops the field (no 403, success toast); the document saves with the previous value. Loud rejection or hidden options require a `beforeChange` hook or custom admin field component.
|
||||||
|
|
||||||
## Hooks with side effects
|
## Hooks with side effects
|
||||||
|
|
||||||
- `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts`
|
- `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts`
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ export const GameRules: GlobalConfig = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "currencySymbol",
|
name: "currencySymbol",
|
||||||
type: "text",
|
type: "upload",
|
||||||
|
relationTo: "media",
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Optional symbol prefixed to amounts, e.g. "¢" or "$".',
|
description:
|
||||||
|
"Optional emblem shown inline before amounts. It renders scaled to the surrounding text height (unlike a text symbol).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -224,5 +226,31 @@ export const GameRules: GlobalConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "zonePressureEnabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"When off, the Zone Pressure feature is hidden from the Operations feed (the overview card and the AAR section).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "statisticsEnabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"When off, the Personnel > Statistics page and its navigation entry are hidden.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "helpdeskEnabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description: "When off, the Helpdesk page and its navigation entry are hidden.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
|
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
|
||||||
import { createStaffingFields } from "./staffingFields";
|
import { createStaffingFields } from "./staffingFields";
|
||||||
|
import { createProductionFields } from "./productionFields";
|
||||||
|
import { createModuleFields } from "./moduleFields";
|
||||||
|
import { productionDefaultsFromBlueprint } from "@/lib/base/productionDefaults";
|
||||||
|
|
||||||
export const GameStructures: CollectionConfig = {
|
export const GameStructures: CollectionConfig = {
|
||||||
slug: "game-structures",
|
slug: "game-structures",
|
||||||
|
|
@ -28,6 +31,7 @@ export const GameStructures: CollectionConfig = {
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
const defaults = staffingDefaultsFromBlueprint(blueprint);
|
const defaults = staffingDefaultsFromBlueprint(blueprint);
|
||||||
|
const productionDefaults = productionDefaultsFromBlueprint(blueprint);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -35,6 +39,12 @@ export const GameStructures: CollectionConfig = {
|
||||||
? { laborRequirements: defaults.laborRequirements }
|
? { laborRequirements: defaults.laborRequirements }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
|
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
|
||||||
|
...(data.productionInputs === undefined
|
||||||
|
? { productionInputs: productionDefaults.productionInputs }
|
||||||
|
: {}),
|
||||||
|
...(data.productionOutputs === undefined
|
||||||
|
? { productionOutputs: productionDefaults.productionOutputs }
|
||||||
|
: {}),
|
||||||
...(data.maintenanceCost === undefined
|
...(data.maintenanceCost === undefined
|
||||||
? { maintenanceCost: defaults.maintenanceCost }
|
? { maintenanceCost: defaults.maintenanceCost }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -127,7 +137,8 @@ export const GameStructures: CollectionConfig = {
|
||||||
type: "date",
|
type: "date",
|
||||||
label: "Construction Started At",
|
label: "Construction Started At",
|
||||||
admin: {
|
admin: {
|
||||||
description: "When construction began (materials consumed). Empty while awaiting materials.",
|
description:
|
||||||
|
"When construction began (materials consumed). Empty while awaiting materials.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -139,6 +150,30 @@ export const GameStructures: CollectionConfig = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...createStaffingFields("instance"),
|
...createStaffingFields("instance"),
|
||||||
|
...createProductionFields("instance"),
|
||||||
|
...createModuleFields("instance"),
|
||||||
|
{
|
||||||
|
name: "productionPaused",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description: "When checked the structure stops producing until unchecked.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastProductionAt",
|
||||||
|
type: "date",
|
||||||
|
admin: { readOnly: true, description: "Last game tick that ran this structure's recipe." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "productionStallReason",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description:
|
||||||
|
"Why production is not running: no_labor, missing_inputs, storage_full. Empty = running.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Geolocation",
|
label: "Geolocation",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
@ -174,6 +209,16 @@ export const GameStructures: CollectionConfig = {
|
||||||
"Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.",
|
"Label priority for clustering (0-100). Higher wins when structures overlap at low zoom; ties break by stored mass.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "parentStructure",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "game-structures",
|
||||||
|
label: "Compound Hub",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Set when this building is part of a compound: its storage pools into the hub, which carries the compound's map label and ribbon.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "storedResources",
|
name: "storedResources",
|
||||||
type: "array",
|
type: "array",
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ export const LaborClassifications: CollectionConfig = {
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
label: "Description",
|
label: "Description",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "buildEfficiency",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 1,
|
||||||
|
label: "Build efficiency",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Construction speed this trade contributes per worker while building structures (1 = a full worker).",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "availableHeadcount",
|
name: "availableHeadcount",
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|
|
||||||
46
src/collections/game/auraFields.ts
Normal file
46
src/collections/game/auraFields.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { Field } from "payload";
|
||||||
|
|
||||||
|
export const AURA_EFFECT_TYPES = ["production_multiplier", "capacity_mass"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint adjacency aura: structures of this type project one effect onto
|
||||||
|
* operational structures within range (meters). Instances read the aura live
|
||||||
|
* from their blueprint; no instance fields.
|
||||||
|
*/
|
||||||
|
export function createAuraFields(): Field[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "aura",
|
||||||
|
type: "group",
|
||||||
|
label: "Adjacency Aura",
|
||||||
|
admin: {
|
||||||
|
description: "Projected onto nearby operational structures (neighbors only, not itself).",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "effectType",
|
||||||
|
type: "select",
|
||||||
|
options: [...AURA_EFFECT_TYPES],
|
||||||
|
label: "Aura Effect",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"production_multiplier: boosts the neighbor's production output (value 0.25 = +25%). capacity_mass: raises the neighbor's storage capacity (value in kg).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "value",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
label: "Aura Value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rangeMeters",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
label: "Aura Range (meters)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
131
src/collections/game/moduleFields.ts
Normal file
131
src/collections/game/moduleFields.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { Field } from "payload";
|
||||||
|
|
||||||
|
type ModuleFieldContext = "defaults" | "instance";
|
||||||
|
|
||||||
|
export const MODULE_CATEGORIES = ["production", "logistics", "defense", "utility"] as const;
|
||||||
|
|
||||||
|
export const MODULE_EFFECT_TYPES = [
|
||||||
|
"capacity_mass",
|
||||||
|
"production_multiplier",
|
||||||
|
"storage_slots",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module system fields. Blueprints declare their slot count and the catalog of
|
||||||
|
* installable modules (with effects and install costs). Instances only record
|
||||||
|
* which catalog entries are installed; slots and effects always read live from
|
||||||
|
* the blueprint, and installation is driven by the module server actions
|
||||||
|
* because it consumes stored materials.
|
||||||
|
*/
|
||||||
|
export function createModuleFields(context: ModuleFieldContext): Field[] {
|
||||||
|
const defaults = context === "defaults";
|
||||||
|
|
||||||
|
if (defaults) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "moduleSlots",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
label: "Module Slots",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"How many modules can be installed in structures of this type. 0 = no module support.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "moduleOptions",
|
||||||
|
type: "array",
|
||||||
|
label: "Module Options",
|
||||||
|
admin: {
|
||||||
|
description: "Modules that can be installed into structures of this type.",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "category",
|
||||||
|
type: "select",
|
||||||
|
options: [...MODULE_CATEGORIES],
|
||||||
|
defaultValue: "utility",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
type: "textarea",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "effects",
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "select",
|
||||||
|
options: [...MODULE_EFFECT_TYPES],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "value",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"capacity_mass: extra kg of storage. storage_slots: extra grid slots. production_multiplier: output boost as a fraction (0.25 = +25%).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "materials",
|
||||||
|
type: "array",
|
||||||
|
required: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "resource",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "resources",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amount",
|
||||||
|
type: "number",
|
||||||
|
min: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "installedModules",
|
||||||
|
type: "array",
|
||||||
|
label: "Installed Modules",
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: "Managed by the install/uninstall module actions.",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "installedAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
45
src/collections/game/productionFields.ts
Normal file
45
src/collections/game/productionFields.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Field } from "payload";
|
||||||
|
|
||||||
|
type ProductionFieldContext = "instance" | "defaults";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production recipe fields: blueprint arrays are the defaults copied into
|
||||||
|
* newly created game-structures; instances carry the same arrays (editable by
|
||||||
|
* managers) plus runtime controls.
|
||||||
|
*/
|
||||||
|
export function createProductionFields(context: ProductionFieldContext): Field[] {
|
||||||
|
const defaults = context === "defaults";
|
||||||
|
const recipeField = (name: "productionInputs" | "productionOutputs", label: string): Field => ({
|
||||||
|
name,
|
||||||
|
type: "array",
|
||||||
|
label,
|
||||||
|
admin: {
|
||||||
|
description: defaults
|
||||||
|
? "Default recipe copied into newly created game-structures of this type. Empty = no recipe."
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "resource",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "resources",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amountPerHour",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: "Units processed per real hour while running and staffed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
recipeField("productionInputs", "Production Inputs"),
|
||||||
|
recipeField("productionOutputs", "Production Outputs"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import {
|
||||||
|
approvalStatusFieldAccess,
|
||||||
|
requireApprovalPermission,
|
||||||
|
requireIntelligencePermission,
|
||||||
|
} from "@/utils/access-control/divisionAccess";
|
||||||
|
|
||||||
|
const approvalStatusFieldAccessConfig = approvalStatusFieldAccess("technologies");
|
||||||
|
|
||||||
export const Technologies: CollectionConfig = {
|
export const Technologies: CollectionConfig = {
|
||||||
slug: "technologies",
|
slug: "technologies",
|
||||||
|
|
@ -9,9 +15,9 @@ export const Technologies: CollectionConfig = {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
create: requirePermission("technologies:create"),
|
create: requireIntelligencePermission("technologies:create"),
|
||||||
update: requirePermission("technologies:update"),
|
update: requireIntelligencePermission("technologies:update"),
|
||||||
delete: requirePermission("technologies:delete"),
|
delete: requireIntelligencePermission("technologies:delete"),
|
||||||
read: requirePermission("technologies:read"),
|
read: requirePermission("technologies:read"),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
|
|
@ -24,6 +30,7 @@ export const Technologies: CollectionConfig = {
|
||||||
name: "approvalStatus",
|
name: "approvalStatus",
|
||||||
type: "select",
|
type: "select",
|
||||||
label: "Approval Status",
|
label: "Approval Status",
|
||||||
|
access: approvalStatusFieldAccessConfig,
|
||||||
options: [
|
options: [
|
||||||
{ label: "In Progress", value: "in_progress" },
|
{ label: "In Progress", value: "in_progress" },
|
||||||
{ label: "Ready for Review", value: "ready_for_review" },
|
{ label: "Ready for Review", value: "ready_for_review" },
|
||||||
|
|
@ -32,18 +39,6 @@ export const Technologies: CollectionConfig = {
|
||||||
{ label: "Revision Requested", value: "revision_requested" },
|
{ label: "Revision Requested", value: "revision_requested" },
|
||||||
],
|
],
|
||||||
defaultValue: "in_progress",
|
defaultValue: "in_progress",
|
||||||
filterOptions: ({ options, req }) => {
|
|
||||||
const roles = isPayloadUser(req.user)
|
|
||||||
? ((req.user.roles as string[] | undefined) ?? [])
|
|
||||||
: [];
|
|
||||||
const canReadAll = roles.includes("admin") || roles.includes("developer");
|
|
||||||
if (canReadAll) return options;
|
|
||||||
return options.filter((option) =>
|
|
||||||
typeof option === "string"
|
|
||||||
? options
|
|
||||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
|
|
@ -137,6 +132,10 @@ export const Technologies: CollectionConfig = {
|
||||||
{
|
{
|
||||||
name: "resources",
|
name: "resources",
|
||||||
type: "array",
|
type: "array",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"These resources are consumed when this technology is researched. If a resource is not listed, you can add a new one, but creating new resources requires the Logistics qualification. Without it, the save will fail.",
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "resource",
|
name: "resource",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
import {
|
||||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
approvalStatusFieldAccess,
|
||||||
|
requireApprovalPermission,
|
||||||
|
requireLogisticsPermission,
|
||||||
|
} from "@/utils/access-control/divisionAccess";
|
||||||
|
|
||||||
|
const approvalFieldAccess = {
|
||||||
|
create: requireApprovalPermission(),
|
||||||
|
update: requireApprovalPermission(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalStatusFieldAccessConfig = approvalStatusFieldAccess("assets");
|
||||||
|
|
||||||
export const Assets: CollectionConfig = {
|
export const Assets: CollectionConfig = {
|
||||||
slug: "assets",
|
slug: "assets",
|
||||||
|
|
@ -8,6 +18,11 @@ export const Assets: CollectionConfig = {
|
||||||
group: "Logistics",
|
group: "Logistics",
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
},
|
},
|
||||||
|
access: {
|
||||||
|
create: requireLogisticsPermission("assets:create"),
|
||||||
|
update: requireLogisticsPermission("assets:update"),
|
||||||
|
delete: requireLogisticsPermission("assets:delete"),
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
|
|
@ -18,6 +33,7 @@ export const Assets: CollectionConfig = {
|
||||||
name: "approvalStatus",
|
name: "approvalStatus",
|
||||||
type: "select",
|
type: "select",
|
||||||
label: "Approval Status",
|
label: "Approval Status",
|
||||||
|
access: approvalStatusFieldAccessConfig,
|
||||||
options: [
|
options: [
|
||||||
{ label: "In Progress", value: "in_progress" },
|
{ label: "In Progress", value: "in_progress" },
|
||||||
{ label: "Ready for Review", value: "ready_for_review" },
|
{ label: "Ready for Review", value: "ready_for_review" },
|
||||||
|
|
@ -26,15 +42,6 @@ export const Assets: CollectionConfig = {
|
||||||
{ label: "Revision Requested", value: "revision_requested" },
|
{ label: "Revision Requested", value: "revision_requested" },
|
||||||
],
|
],
|
||||||
defaultValue: "in_progress",
|
defaultValue: "in_progress",
|
||||||
filterOptions: ({ options, req }) => {
|
|
||||||
return !isDeveloper({ req })
|
|
||||||
? options.filter((option) =>
|
|
||||||
typeof option === "string"
|
|
||||||
? options
|
|
||||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
|
||||||
)
|
|
||||||
: options;
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
|
|
@ -643,14 +650,15 @@ export const Assets: CollectionConfig = {
|
||||||
label: "Auditing",
|
label: "Auditing",
|
||||||
description: "Auditing and moderation for this Asset.",
|
description: "Auditing and moderation for this Asset.",
|
||||||
access: {
|
access: {
|
||||||
read: requirePermission("assets:read"),
|
read: requireLogisticsPermission("assets:read"),
|
||||||
create: requirePermission("assets:create"),
|
create: requireLogisticsPermission("assets:create"),
|
||||||
update: requirePermission("assets:update"),
|
update: requireLogisticsPermission("assets:update"),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "isLive",
|
name: "isLive",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
|
access: approvalFieldAccess,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
'If checked, this item is approved and considered "live" (i.e. it will be considered in calculations and made available in-game.)',
|
'If checked, this item is approved and considered "live" (i.e. it will be considered in calculations and made available in-game.)',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { CollectionConfig, PayloadRequest } from "payload";
|
import { AccessArgs, AccessResult, CollectionConfig, PayloadRequest } from "payload";
|
||||||
|
import type { Permission } from "@/permissions";
|
||||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
|
import {
|
||||||
|
approvalStatusFieldAccess,
|
||||||
|
requireApprovalPermission,
|
||||||
|
requireLogisticsPermission,
|
||||||
|
} from "@/utils/access-control/divisionAccess";
|
||||||
|
|
||||||
const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
|
const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
|
||||||
if (isDeveloper({ req })) {
|
if (isDeveloper({ req })) {
|
||||||
|
|
@ -31,6 +37,18 @@ const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logistics members may write resources, but the primary-currency guard
|
||||||
|
// (developer-only, non-primary docs only) is layered on top, not replaced.
|
||||||
|
const logisticsWriteWithCurrencyGuard = (permission: Permission) => {
|
||||||
|
const logisticsCheck = requireLogisticsPermission(permission);
|
||||||
|
return async (args: AccessArgs): Promise<AccessResult> => {
|
||||||
|
if (!(await logisticsCheck(args))) return false;
|
||||||
|
return onlyIfNotPrimaryCurrency(args);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalStatusFieldAccessConfig = approvalStatusFieldAccess("resources");
|
||||||
|
|
||||||
export const Resources: CollectionConfig = {
|
export const Resources: CollectionConfig = {
|
||||||
slug: "resources",
|
slug: "resources",
|
||||||
admin: {
|
admin: {
|
||||||
|
|
@ -39,8 +57,9 @@ export const Resources: CollectionConfig = {
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req }) => !!req.user,
|
read: ({ req }) => !!req.user,
|
||||||
update: onlyIfNotPrimaryCurrency,
|
create: requireLogisticsPermission("resources:create"),
|
||||||
delete: onlyIfNotPrimaryCurrency,
|
update: logisticsWriteWithCurrencyGuard("resources:update"),
|
||||||
|
delete: logisticsWriteWithCurrencyGuard("resources:delete"),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|
@ -118,6 +137,7 @@ export const Resources: CollectionConfig = {
|
||||||
name: "approvalStatus",
|
name: "approvalStatus",
|
||||||
type: "select",
|
type: "select",
|
||||||
label: "Approval Status",
|
label: "Approval Status",
|
||||||
|
access: approvalStatusFieldAccessConfig,
|
||||||
options: [
|
options: [
|
||||||
{ label: "In Progress", value: "in_progress" },
|
{ label: "In Progress", value: "in_progress" },
|
||||||
{ label: "Ready for Review", value: "ready_for_review" },
|
{ label: "Ready for Review", value: "ready_for_review" },
|
||||||
|
|
@ -126,15 +146,6 @@ export const Resources: CollectionConfig = {
|
||||||
{ label: "Revision Requested", value: "revision_requested" },
|
{ label: "Revision Requested", value: "revision_requested" },
|
||||||
],
|
],
|
||||||
defaultValue: "in_progress",
|
defaultValue: "in_progress",
|
||||||
filterOptions: ({ options, data, req }) => {
|
|
||||||
return !isDeveloper({ req })
|
|
||||||
? options.filter((option) =>
|
|
||||||
typeof option === "string"
|
|
||||||
? options
|
|
||||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
|
||||||
)
|
|
||||||
: options;
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
import type { Resource } from "@/payload-types";
|
import type { Resource } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { createStaffingFields } from "@/collections/game/staffingFields";
|
import { createStaffingFields } from "@/collections/game/staffingFields";
|
||||||
|
import { createProductionFields } from "@/collections/game/productionFields";
|
||||||
|
import { createModuleFields } from "@/collections/game/moduleFields";
|
||||||
|
import { createAuraFields } from "@/collections/game/auraFields";
|
||||||
|
|
||||||
export const Structures: CollectionConfig = {
|
export const Structures: CollectionConfig = {
|
||||||
slug: "structures",
|
slug: "structures",
|
||||||
|
|
@ -359,6 +362,17 @@ export const Structures: CollectionConfig = {
|
||||||
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
"How many real minutes it takes to complete construction of this structure after materials are delivered and building starts.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "constructionCrewRequired",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
label: "Construction Crew Required",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Effective crew needed for the listed build time. 0 builds at full speed with no labor. Understaffed sites build proportionally slower.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "requiredTech",
|
name: "requiredTech",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
|
|
@ -498,6 +512,17 @@ export const Structures: CollectionConfig = {
|
||||||
type: "group",
|
type: "group",
|
||||||
fields: createStaffingFields("defaults"),
|
fields: createStaffingFields("defaults"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Production Recipe Defaults",
|
||||||
|
type: "group",
|
||||||
|
fields: createProductionFields("defaults"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Modules",
|
||||||
|
type: "group",
|
||||||
|
fields: createModuleFields("defaults"),
|
||||||
|
},
|
||||||
|
...createAuraFields(),
|
||||||
|
|
||||||
{
|
{
|
||||||
label: "Production & Function",
|
label: "Production & Function",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
import {
|
||||||
|
approvalStatusFieldAccess,
|
||||||
|
requireApprovalPermission,
|
||||||
|
requireLogisticsPermission,
|
||||||
|
} from "@/utils/access-control/divisionAccess";
|
||||||
|
|
||||||
|
const approvalStatusFieldAccessConfig = approvalStatusFieldAccess("vehicles");
|
||||||
|
|
||||||
export const Vehicles: CollectionConfig = {
|
export const Vehicles: CollectionConfig = {
|
||||||
slug: "vehicles",
|
slug: "vehicles",
|
||||||
|
|
@ -7,6 +13,11 @@ export const Vehicles: CollectionConfig = {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
group: "Logistics",
|
group: "Logistics",
|
||||||
},
|
},
|
||||||
|
access: {
|
||||||
|
create: requireLogisticsPermission("vehicles:create"),
|
||||||
|
update: requireLogisticsPermission("vehicles:update"),
|
||||||
|
delete: requireLogisticsPermission("vehicles:delete"),
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: "Identity",
|
label: "Identity",
|
||||||
|
|
@ -29,6 +40,7 @@ export const Vehicles: CollectionConfig = {
|
||||||
name: "approvalStatus",
|
name: "approvalStatus",
|
||||||
type: "select",
|
type: "select",
|
||||||
label: "Approval Status",
|
label: "Approval Status",
|
||||||
|
access: approvalStatusFieldAccessConfig,
|
||||||
options: [
|
options: [
|
||||||
{ label: "In Progress", value: "in_progress" },
|
{ label: "In Progress", value: "in_progress" },
|
||||||
{ label: "Ready for Review", value: "ready_for_review" },
|
{ label: "Ready for Review", value: "ready_for_review" },
|
||||||
|
|
@ -37,15 +49,6 @@ export const Vehicles: CollectionConfig = {
|
||||||
{ label: "Revision Requested", value: "revision_requested" },
|
{ label: "Revision Requested", value: "revision_requested" },
|
||||||
],
|
],
|
||||||
defaultValue: "in_progress",
|
defaultValue: "in_progress",
|
||||||
filterOptions: ({ options, data, req }) => {
|
|
||||||
return !isDeveloper({ req })
|
|
||||||
? options.filter((option) =>
|
|
||||||
typeof option === "string"
|
|
||||||
? options
|
|
||||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
|
||||||
)
|
|
||||||
: options;
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
108
src/collections/operations/OperationEffects.ts
Normal file
108
src/collections/operations/OperationEffects.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived-effect ledger for validated operation events.
|
||||||
|
*
|
||||||
|
* One row per (event, effect type) pair, keyed by `effectKey` =
|
||||||
|
* `${eventId}:${effectType}` so an exact retry is a no-op. Effects are the
|
||||||
|
* only place derived operation state may be recorded; they always carry
|
||||||
|
* provenance back to both the operation event and the raw source event.
|
||||||
|
*
|
||||||
|
* Writes are internal only (create/update/delete denied; the bridge writes
|
||||||
|
* via `overrideAccess`). Read is open to any logged-in user.
|
||||||
|
*/
|
||||||
|
export const OperationEffects: CollectionConfig = {
|
||||||
|
slug: "operation-effects",
|
||||||
|
admin: {
|
||||||
|
group: "Operations",
|
||||||
|
useAsTitle: "effectKey",
|
||||||
|
defaultColumns: ["effectKey", "operationId", "effectType", "status", "appliedAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "operationEvent",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "operation-events",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "The validated operation event this effect derives from.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sourceEvent",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "arma-sync-events",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "The raw source event this effect derives from.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operationId",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "effectKey",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "Idempotency key: `${eventId}:${effectType}`.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "effectType",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Operation Started", value: "operation.started" },
|
||||||
|
{ label: "Operation Ended", value: "operation.ended" },
|
||||||
|
{ label: "Objective Updated", value: "operation.objective-updated" },
|
||||||
|
{ label: "Extraction Recorded", value: "operation.extraction-recorded" },
|
||||||
|
{ label: "Inventory Recorded", value: "operation.inventory-recorded" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "applied",
|
||||||
|
options: [
|
||||||
|
{ label: "Applied", value: "applied" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Failed", value: "failed" },
|
||||||
|
{ label: "Reversed", value: "reversed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "payload", type: "json" },
|
||||||
|
{
|
||||||
|
name: "reversalOf",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "operation-effects",
|
||||||
|
admin: {
|
||||||
|
description: "Set when this effect reverses another effect.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversedBy",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "operation-effects",
|
||||||
|
admin: {
|
||||||
|
description: "Set when this effect has been reversed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "reconciliationNotes", type: "textarea" },
|
||||||
|
{ name: "appliedAt", type: "date" },
|
||||||
|
],
|
||||||
|
};
|
||||||
108
src/collections/operations/OperationEvents.ts
Normal file
108
src/collections/operations/OperationEvents.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated operation-event ledger.
|
||||||
|
*
|
||||||
|
* Consumes immutable raw `arma-sync-events` evidence and records the outcome
|
||||||
|
* of validating it against the supported operation event contract. Every raw
|
||||||
|
* event that reaches the ledger gets exactly one row here (idempotent by
|
||||||
|
* `sourceEvent`), including malformed or unsupported events, which are kept
|
||||||
|
* as `rejected` / `dead-letter` records with zero derived effects.
|
||||||
|
*
|
||||||
|
* Writes are internal only (create/update/delete are denied to everyone; the
|
||||||
|
* bridge writes via `overrideAccess`). Read is open to any logged-in user.
|
||||||
|
*/
|
||||||
|
export const OperationEvents: CollectionConfig = {
|
||||||
|
slug: "operation-events",
|
||||||
|
admin: {
|
||||||
|
group: "Operations",
|
||||||
|
useAsTitle: "eventId",
|
||||||
|
defaultColumns: ["eventId", "operationId", "type", "status", "occurredAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "sourceEvent",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "arma-sync-events",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "The immutable raw sync event this record was derived from.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eventId",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "Mirror of the raw event's server-prefixed id (serverId:eventId).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operationId",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "Operation identity from the event payload, or \"unknown\".",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Event type as received. Supported: operation.start, operation.end, operation.objective, operation.extraction, operation.inventory, player.kill. Unknown types are kept as dead-letter records.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
defaultValue: 1,
|
||||||
|
admin: { description: "Event contract version from the payload (default 1)." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "validated",
|
||||||
|
options: [
|
||||||
|
{ label: "Validated", value: "validated" },
|
||||||
|
{ label: "Rejected", value: "rejected" },
|
||||||
|
{ label: "Dead Letter", value: "dead-letter" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "Validation outcome. Only validated events derive effects.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error",
|
||||||
|
type: "json",
|
||||||
|
admin: { description: "Rejection/dead-letter reason: { code, message }." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "payloadHash",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
admin: { description: "SHA-256 of the stable-stringified raw payload." },
|
||||||
|
},
|
||||||
|
{ name: "actor", type: "relationship", relationTo: "users" },
|
||||||
|
{ name: "server", type: "relationship", relationTo: "game-servers" },
|
||||||
|
{ name: "mission", type: "relationship", relationTo: "missions" },
|
||||||
|
{ name: "occurredAt", type: "date", required: true },
|
||||||
|
{ name: "validatedAt", type: "date" },
|
||||||
|
{ name: "reconciliationNotes", type: "textarea" },
|
||||||
|
],
|
||||||
|
};
|
||||||
152
src/collections/operations/OperationReadiness.ts
Normal file
152
src/collections/operations/OperationReadiness.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-scoped, advisory operation readiness + planning-confidence aggregates.
|
||||||
|
*
|
||||||
|
* One row per (subjectType, subjectId, windowStart). Writes are internal only
|
||||||
|
* (create/update/delete denied; the readiness service writes via `overrideAccess`
|
||||||
|
* with its own permission gate). Read is gated by the NEW
|
||||||
|
* `operation-readiness:read` permission, so it is role-scoped, not open to any
|
||||||
|
* logged-in user.
|
||||||
|
*
|
||||||
|
* The stored row carries only normalized scores, category weights, and input
|
||||||
|
* counts plus a generated explanation. It never contains raw evaluation content
|
||||||
|
* (evaluator names, rater ids, or comment text), which is what the privacy
|
||||||
|
* guarantee rests on.
|
||||||
|
*/
|
||||||
|
export const OperationReadiness: CollectionConfig = {
|
||||||
|
slug: "operation-readiness",
|
||||||
|
admin: {
|
||||||
|
group: "Operations",
|
||||||
|
useAsTitle: "subjectLabel",
|
||||||
|
defaultColumns: ["subjectType", "subjectLabel", "windowStart", "readinessScore", "planningConfidenceScore"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: requirePermission("operation-readiness:read"),
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "subjectType",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: "User", value: "user" },
|
||||||
|
{ label: "Role", value: "role" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "Who the aggregate is for: a specific user, or a whole role.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subjectId",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Id of the subject (user id for user-scoped; null for role-scoped).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subjectLabel",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
description: "Human-readable label for the subject (role name or username). Display only.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windowStart",
|
||||||
|
type: "date",
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: "Start of the explicit time window this aggregate covers.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windowEnd",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description: "End of the time window.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windowLabel",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
description: "Optional human label for the window (e.g. an ISO week).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operationId",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
description: "Optional operation identity the window's data is attributed to.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "calculationVersion",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "1.0.0",
|
||||||
|
admin: {
|
||||||
|
description: "Readiness calculation model version.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nature",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "advisory",
|
||||||
|
options: [{ label: "Advisory (non-gating)", value: "advisory" }],
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Advisory-only. Never used to gate, punish, or rank players against one another.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "readinessScore",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Weighted 0-100 readiness score over present contributing categories.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "planningConfidenceScore",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Weighted 0-100 planning-confidence sub-score from pre-op signals.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contributingCategories",
|
||||||
|
type: "json",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Structured per-category breakdown ({ category, weight, score, inputCount }). No evaluator data.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inputCount",
|
||||||
|
type: "number",
|
||||||
|
admin: {
|
||||||
|
description: "Total component inputs consumed by the aggregate.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explanation",
|
||||||
|
type: "textarea",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Human-readable explanation enumerating contributing categories and weights. Never contains evaluator names, ids, or comments.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recomputedAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description: "When the aggregate was last computed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
146
src/collections/operations/OperationReservations.ts
Normal file
146
src/collections/operations/OperationReservations.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission allocation reservations.
|
||||||
|
*
|
||||||
|
* One row per allocation request against an operation: personnel, vehicles,
|
||||||
|
* cargo/resources, and a treasury budget earmarked for a mission, plus the
|
||||||
|
* origin/destination structures and an optional expiry. Reservations are
|
||||||
|
* earmarks only — no locker, shipment, bank, or storage rows are mutated at
|
||||||
|
* reserve or settle time; the deterministic settlement record on the row
|
||||||
|
* captures what was consumed versus returned, exactly once.
|
||||||
|
*
|
||||||
|
* Settlement state machine: `reserved` -> `settled` | `cancelled` | `expired`
|
||||||
|
* | `no-show` (all terminal). `reservationKey` is the idempotency key: a
|
||||||
|
* retry with the same key returns the existing row instead of creating a
|
||||||
|
* second reservation.
|
||||||
|
*
|
||||||
|
* Writes are internal only (create/update/delete denied; the allocation
|
||||||
|
* service in `src/lib/operations/allocation.ts` writes via `overrideAccess`).
|
||||||
|
* Read is open to any logged-in user.
|
||||||
|
*/
|
||||||
|
export const OperationReservations: CollectionConfig = {
|
||||||
|
slug: "operation-reservations",
|
||||||
|
admin: {
|
||||||
|
group: "Operations",
|
||||||
|
useAsTitle: "reservationKey",
|
||||||
|
defaultColumns: ["reservationKey", "operationId", "status", "expiresAt", "settledAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "reservationKey",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "Idempotency key for the allocation request. Retries with the same key are no-ops.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operationId",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: { description: "Operation identity this allocation belongs to." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mission",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "missions",
|
||||||
|
admin: { description: "The mission these assets are allocated to, when known." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "reserved",
|
||||||
|
options: [
|
||||||
|
{ label: "Reserved", value: "reserved" },
|
||||||
|
{ label: "Settled", value: "settled" },
|
||||||
|
{ label: "Cancelled", value: "cancelled" },
|
||||||
|
{ label: "Expired", value: "expired" },
|
||||||
|
{ label: "No-Show", value: "no-show" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "reserved is the only non-terminal state; every terminal state is settled exactly once.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "personnel",
|
||||||
|
type: "array",
|
||||||
|
admin: { description: "Reserved unit members." },
|
||||||
|
fields: [
|
||||||
|
{ name: "user", type: "relationship", relationTo: "users", required: true },
|
||||||
|
{ name: "slot", type: "text", admin: { description: "Optional role/slot label." } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vehicles",
|
||||||
|
type: "array",
|
||||||
|
admin: { description: "Reserved deployed vehicles." },
|
||||||
|
fields: [
|
||||||
|
{ name: "vehicle", type: "relationship", relationTo: "game-vehicles", required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cargo",
|
||||||
|
type: "array",
|
||||||
|
admin: { description: "Reserved resources (drawn from the origin structure). " },
|
||||||
|
fields: [
|
||||||
|
{ name: "resource", type: "relationship", relationTo: "resources", required: true },
|
||||||
|
{ name: "amount", type: "number", required: true, min: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "budget",
|
||||||
|
type: "group",
|
||||||
|
admin: { description: "Treasury budget earmarked for the operation." },
|
||||||
|
fields: [
|
||||||
|
{ name: "account", type: "relationship", relationTo: "bank-accounts" },
|
||||||
|
{ name: "amount", type: "number", min: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "origin",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "game-structures",
|
||||||
|
admin: { description: "Structure the reserved cargo/vehicles are drawn from." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "destination",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "game-structures",
|
||||||
|
admin: { description: "Structure the cargo is bound for (storage rules checked at reserve time)." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expiresAt",
|
||||||
|
type: "date",
|
||||||
|
admin: { description: "When the reservation lapses; the expiry sweeper settles it as expired." },
|
||||||
|
},
|
||||||
|
{ name: "createdBy", type: "relationship", relationTo: "users" },
|
||||||
|
{ name: "settledAt", type: "date" },
|
||||||
|
{
|
||||||
|
name: "settledByEffect",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "operation-effects",
|
||||||
|
admin: {
|
||||||
|
description: "Provenance: the operation effect that drove this settlement, when effect-driven.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "settlement",
|
||||||
|
type: "json",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Deterministic settlement record: outcome, reason, consumed and returned amounts, actor, effect, timestamp.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
73
src/collections/operations/ZonePressure.ts
Normal file
73
src/collections/operations/ZonePressure.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategic pressure ledger over map zones (task 7).
|
||||||
|
*
|
||||||
|
* One row per settled operation outcome affecting a zone: an additive delta
|
||||||
|
* attributed to the faction, mission, and source effect that caused it.
|
||||||
|
* `sourceEffect` is unique, so a replayed effect is a structural no-op. The
|
||||||
|
* zone's strategic state is computed deterministically from decayed deltas in
|
||||||
|
* `src/lib/operations/territory.ts`; this collection stores no derived state.
|
||||||
|
*
|
||||||
|
* This is an informational strategic layer only: it never changes placement
|
||||||
|
* rules, terrain validation, or any map authoring behavior.
|
||||||
|
*
|
||||||
|
* Writes are internal only (create/update/delete denied; the settlement layer
|
||||||
|
* writes via `overrideAccess`). Read is open to any logged-in user.
|
||||||
|
*/
|
||||||
|
export const ZonePressure: CollectionConfig = {
|
||||||
|
slug: "zone-pressure",
|
||||||
|
admin: {
|
||||||
|
group: "Operations",
|
||||||
|
useAsTitle: "operationId",
|
||||||
|
defaultColumns: ["zone", "faction", "operationId", "delta", "createdAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
timestamps: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "zone",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "map-zones",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: { description: "The zone this pressure applies to." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "faction",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "factions",
|
||||||
|
index: true,
|
||||||
|
admin: { description: "The faction whose activity drives this pressure." },
|
||||||
|
},
|
||||||
|
{ name: "operationId", type: "text", required: true, index: true },
|
||||||
|
{
|
||||||
|
name: "mission",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "missions",
|
||||||
|
index: true,
|
||||||
|
admin: { description: "The mission the operation was scoped to (causal AAR link)." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sourceEffect",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "operation-effects",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
admin: { description: "The settled operation effect that produced this delta (idempotency key)." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delta",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
admin: { description: "Signed pressure delta: hostile gains positive, friendly control negative." },
|
||||||
|
},
|
||||||
|
{ name: "note", type: "textarea" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
||||||
|
import { processBoxEvent } from "@/lib/arma-bridge/crates";
|
||||||
|
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
||||||
|
import { processOperationEvent } from "@/lib/operations/process";
|
||||||
|
|
||||||
export const ArmaSyncEvents: CollectionConfig = {
|
export const ArmaSyncEvents: CollectionConfig = {
|
||||||
slug: "arma-sync-events",
|
slug: "arma-sync-events",
|
||||||
|
|
@ -9,11 +12,27 @@ export const ArmaSyncEvents: CollectionConfig = {
|
||||||
afterChange: [
|
afterChange: [
|
||||||
async ({ doc, req, operation }) => {
|
async ({ doc, req, operation }) => {
|
||||||
if (operation !== "create") return;
|
if (operation !== "create") return;
|
||||||
|
// Ledger first: every raw event is consumed into the operation ledger
|
||||||
|
// before any downstream projection runs. Evidence is always stored;
|
||||||
|
// only derived effects are gated by the kill switch.
|
||||||
|
if (isOperationLedgerEnabled()) {
|
||||||
|
await processOperationEvent(req.payload, doc, req);
|
||||||
|
}
|
||||||
if (doc.type === "player.kill") {
|
if (doc.type === "player.kill") {
|
||||||
processPlayerKill(req.payload, doc).catch((err) => {
|
processPlayerKill(req.payload, doc).catch((err) => {
|
||||||
req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`);
|
req.payload.logger.error(`[KillTracker] Failed to process kill event ${doc.eventId}: ${err}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (doc.type.startsWith("box.")) {
|
||||||
|
// Awaited (not fire-and-forget) so the crate writes join the raw-event
|
||||||
|
// transaction; processBoxEvent never throws, so ingestion cannot be
|
||||||
|
// rolled back by crate sync.
|
||||||
|
try {
|
||||||
|
await processBoxEvent(req.payload, doc, req);
|
||||||
|
} catch (err) {
|
||||||
|
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-player respawn ticket balances. Tickets are awarded automatically when a
|
* Per-player respawn ticket balances. Tickets are awarded automatically when a
|
||||||
|
|
@ -8,17 +9,17 @@ import { CollectionConfig } from "payload";
|
||||||
* missions and are served to the Arma 3 mod via the arma-bridge tickets
|
* missions and are served to the Arma 3 mod via the arma-bridge tickets
|
||||||
* route, which loads them into the game at mission start.
|
* route, which loads them into the game at mission start.
|
||||||
*
|
*
|
||||||
* Written internally by the settlement lib (overrideAccess); the admin panel
|
* Written internally by the settlement lib (overrideAccess); GMs can also
|
||||||
* is read-only so balance changes always have a settlement behind them.
|
* adjust balances manually in the admin panel.
|
||||||
*/
|
*/
|
||||||
export const RespawnTickets: CollectionConfig = {
|
export const RespawnTickets: CollectionConfig = {
|
||||||
slug: "respawn-tickets",
|
slug: "respawn-tickets",
|
||||||
admin: { group: "Server", useAsTitle: "user", defaultColumns: ["user", "balance"] },
|
admin: { group: "Server", useAsTitle: "user", defaultColumns: ["user", "balance"] },
|
||||||
access: {
|
access: {
|
||||||
read: ({ req }) => !!req.user,
|
read: requirePermission("respawn-tickets:read"),
|
||||||
create: () => false,
|
create: requirePermission("respawn-tickets:create"),
|
||||||
update: () => false,
|
update: requirePermission("respawn-tickets:update"),
|
||||||
delete: () => false,
|
delete: requirePermission("respawn-tickets:delete"),
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "user", type: "relationship", relationTo: "users", required: true, unique: true, index: true },
|
{ name: "user", type: "relationship", relationTo: "users", required: true, unique: true, index: true },
|
||||||
|
|
@ -28,7 +29,7 @@ export const RespawnTickets: CollectionConfig = {
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Accumulated respawn tickets (1 per yes-attended op, 0.5 tentative, 0.25 no). Maintained by settlement; read-only.",
|
"Accumulated respawn tickets (1 per yes-attended op, 0.5 tentative, 0.25 no). Settlement adjusts automatically; GMs can adjust manually.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
73
src/collections/server/SupplyBoxes.ts
Normal file
73
src/collections/server/SupplyBoxes.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply crates synced from the Arma bridge mod (`box.*` event family).
|
||||||
|
*
|
||||||
|
* Rows are written only by the crate-sync handler (`src/lib/arma-bridge/crates.ts`)
|
||||||
|
* via `overrideAccess`; the admin panel and REST API are read-only for logged-in
|
||||||
|
* users. `box.deleted` never hard-deletes: it marks the row `status: deleted`
|
||||||
|
* and a later `box.register`/`box.sync` reactivates it. Every row links to the
|
||||||
|
* raw `arma-sync-events` row that produced its current state (`sourceEvent`).
|
||||||
|
*/
|
||||||
|
export const SupplyBoxes: CollectionConfig = {
|
||||||
|
slug: "supply-boxes",
|
||||||
|
admin: {
|
||||||
|
group: "Server",
|
||||||
|
useAsTitle: "boxId",
|
||||||
|
defaultColumns: ["boxId", "boxClass", "status", "server", "lastSyncedAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: "boxId", type: "text", required: true, unique: true, index: true },
|
||||||
|
{
|
||||||
|
name: "sessionId",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Bridge session generation id (worldName + systemTime) extracted from the boxId; used to detect stale generations after a mission restart.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "game-servers",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Owning game server, resolved from the authenticated ingestion event. Never populated from payload content.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "boxClass", type: "text" },
|
||||||
|
{ name: "displayName", type: "text" },
|
||||||
|
{ name: "location", type: "json" },
|
||||||
|
{ name: "contents", type: "json" },
|
||||||
|
{ name: "lastSyncedAt", type: "date", required: true },
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "active",
|
||||||
|
options: ["active", "deleted"],
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sourceEvent",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "arma-sync-events",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Raw arma-sync-events row that produced the current state; provenance back to the exact mod event.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
requirePermission,
|
requirePermission,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
isSuperuser,
|
isSuperuser,
|
||||||
|
canAccessAdminPanel,
|
||||||
} from "@/utils/access-control/hasPermission";
|
} from "@/utils/access-control/hasPermission";
|
||||||
import { ensurePersonalAccount } from "@/lib/banking/index";
|
import { ensurePersonalAccount } from "@/lib/banking/index";
|
||||||
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
||||||
|
|
@ -88,7 +89,10 @@ export const Users: CollectionConfig = {
|
||||||
},
|
},
|
||||||
slug: "users",
|
slug: "users",
|
||||||
access: {
|
access: {
|
||||||
admin: requirePermission("system:admin-access"),
|
// Admin panel entry: superusers, plus anyone holding a page-manage
|
||||||
|
// permission (division roles). Per-page visibility is filtered separately
|
||||||
|
// by requireAdminPageAccess (read + admin:<slug>:manage per collection).
|
||||||
|
admin: async ({ req }) => canAccessAdminPanel(req.payload, req.user),
|
||||||
unlock: requirePermission("users:unlock"),
|
unlock: requirePermission("users:unlock"),
|
||||||
create: requirePermission("users:create"),
|
create: requirePermission("users:create"),
|
||||||
update: async ({ req, id, data }) => {
|
update: async ({ req, id, data }) => {
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,11 @@ export const MapZones: CollectionConfig = {
|
||||||
{ label: "Land", value: "land" },
|
{ label: "Land", value: "land" },
|
||||||
{ label: "Territory", value: "territory" },
|
{ label: "Territory", value: "territory" },
|
||||||
{ label: "Country Border", value: "border" },
|
{ label: "Country Border", value: "border" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
|
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational). Other is for generic areas such as named compounds.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -79,7 +80,7 @@ export const MapZones: CollectionConfig = {
|
||||||
max: 1,
|
max: 1,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
|
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border, 0.08 other).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,81 @@ export const Maps: CollectionConfig = {
|
||||||
description: "Inactive maps are hidden from the map page and cannot receive placements.",
|
description: "Inactive maps are hidden from the map page and cannot receive placements.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "worldMap",
|
||||||
|
type: "checkbox",
|
||||||
|
label: "World Map Entry",
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Show on the World Map screen. Leave off for mission-only maps (e.g. side op maps without imagery).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campaign",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "campaigns",
|
||||||
|
label: "Campaign",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Campaign this map belongs to. Maps tied to custom (side) campaigns are excluded from the World Map screen even when flagged as world map entries.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
type: "textarea",
|
||||||
|
label: "Description",
|
||||||
|
admin: {
|
||||||
|
description: "Optional map blurb shown on the World Map picker cards.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weather",
|
||||||
|
type: "text",
|
||||||
|
label: "General Weather",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Optional freeform weather condition for intel display (e.g. Clear, Monsoon, Ashfall).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latitude",
|
||||||
|
type: "number",
|
||||||
|
label: "Real-World Latitude",
|
||||||
|
admin: {
|
||||||
|
description: "Optional real-world reference coordinate (degrees).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "longitude",
|
||||||
|
type: "number",
|
||||||
|
label: "Real-World Longitude",
|
||||||
|
admin: {
|
||||||
|
description: "Optional real-world reference coordinate (degrees).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "easeOfAccess",
|
||||||
|
type: "select",
|
||||||
|
label: "Ease of Access",
|
||||||
|
options: [
|
||||||
|
{ label: "Easy", value: "easy" },
|
||||||
|
{ label: "Moderate", value: "moderate" },
|
||||||
|
{ label: "Difficult", value: "difficult" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "Optional: how hard the area is to reach for intel display.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "factionControl",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "factions",
|
||||||
|
label: "Faction Control",
|
||||||
|
admin: {
|
||||||
|
description: "Optional faction associated with or controlling this map.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "World Size (meters)",
|
label: "World Size (meters)",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
@ -71,7 +146,8 @@ export const Maps: CollectionConfig = {
|
||||||
{ label: "XYZ Tile Layer", value: "tiles" },
|
{ label: "XYZ Tile Layer", value: "tiles" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
|
description:
|
||||||
|
"Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
import { validatePosition } from "@/lib/map/points";
|
import { validatePosition } from "@/lib/map/points";
|
||||||
|
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map resource nodes (deposits, forests, wells, ...). Display + placement
|
* Map resource nodes (deposits, forests, wells, ...). Display + placement
|
||||||
|
|
@ -46,9 +47,21 @@ export const ResourceNodes: CollectionConfig = {
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
relationTo: "resources",
|
relationTo: "resources",
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
required: true,
|
|
||||||
admin: {
|
admin: {
|
||||||
description: "Every resource this node yields (e.g. copper AND oil).",
|
description:
|
||||||
|
"Every resource this node yields (e.g. copper AND oil). Leave empty for a plain marker node.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "icon",
|
||||||
|
type: "select",
|
||||||
|
options: NODE_ICON_OPTIONS.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
})),
|
||||||
|
defaultValue: "marker",
|
||||||
|
admin: {
|
||||||
|
description: "Marker icon shown on the map.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||||
|
|
||||||
const accountTypeMeta = {
|
const accountTypeMeta = {
|
||||||
|
|
@ -85,10 +86,10 @@ export function AccountCard({
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="text-lg font-semibold font-mono">
|
<span className="text-lg font-semibold font-mono">
|
||||||
{formatAmount(
|
<CurrencyAmount
|
||||||
account.balance ?? 0,
|
value={account.balance ?? 0}
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
currency={currencyConfig.resourceName ? currencyConfig : currency}
|
||||||
)}
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import Link from "next/link";
|
||||||
import { ArrowLeftIcon } from "lucide-react";
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import type { BankAccount, LedgerEntry } from "@/payload-types";
|
import type { BankAccount, LedgerEntry } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||||
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
||||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||||
|
|
@ -72,10 +73,10 @@ export function AccountDetail({
|
||||||
Balance
|
Balance
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-semibold font-mono">
|
<p className="text-2xl font-semibold font-mono">
|
||||||
{formatAmount(
|
<CurrencyAmount
|
||||||
account.balance ?? 0,
|
value={account.balance ?? 0}
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
currency={currencyConfig.resourceName ? currencyConfig : currency}
|
||||||
)}
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { AccountCard } from "@/components/frontend/banking/AccountCard";
|
||||||
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
||||||
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
||||||
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
|
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
|
|
||||||
interface BankingOverviewProps {
|
interface BankingOverviewProps {
|
||||||
|
|
@ -126,7 +127,7 @@ export function BankingOverview({
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
|
||||||
UNIT FUNDS
|
UNIT FUNDS
|
||||||
{formatAmount(unitFundsTotal, currencyConfig)}
|
<CurrencyAmount value={unitFundsTotal} currency={currencyConfig} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
27
src/components/frontend/banking/CurrencyAmount.tsx
Normal file
27
src/components/frontend/banking/CurrencyAmount.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { formatAmount, isCurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import type { CurrencyDisplay } from "@/lib/banking/format";
|
||||||
|
import { CurrencySymbol } from "./CurrencySymbol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the currency emblem inline with the formatted amount. Delegates
|
||||||
|
* symbol rendering to <CurrencySymbol> and formatting to formatAmount. When an
|
||||||
|
* emblem is configured the unit label (e.g. "credits") is dropped so the icon
|
||||||
|
* sits beside the number without a trailing word; renders nothing when no
|
||||||
|
* emblem is configured.
|
||||||
|
*/
|
||||||
|
export function CurrencyAmount({
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
readonly value: number;
|
||||||
|
readonly currency?: CurrencyDisplay | null;
|
||||||
|
}) {
|
||||||
|
const showSymbol =
|
||||||
|
currency != null && typeof currency === "object" && isCurrencyConfig(currency) && !!currency.symbol;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CurrencySymbol currency={currency} />
|
||||||
|
<span>{formatAmount(value, currency, showSymbol ? { hideLabel: true } : undefined)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/frontend/banking/CurrencySymbol.tsx
Normal file
43
src/components/frontend/banking/CurrencySymbol.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { CurrencyDisplay } from "@/lib/banking/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isCurrencyConfig } from "@/lib/banking/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the currency emblem (an uploaded media image) inline with the text it
|
||||||
|
* sits beside. The image height is tied to the surrounding font size and capped
|
||||||
|
* just below the line height, so it scales with the text and never floats larger
|
||||||
|
* than the numbers next to it. Renders nothing when no emblem is configured.
|
||||||
|
*/
|
||||||
|
export function CurrencySymbol({
|
||||||
|
currency,
|
||||||
|
className,
|
||||||
|
ariaHidden,
|
||||||
|
}: {
|
||||||
|
readonly currency?: CurrencyDisplay | null;
|
||||||
|
readonly className?: string;
|
||||||
|
readonly ariaHidden?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!currency || typeof currency !== "object") return null;
|
||||||
|
const config = isCurrencyConfig(currency) ? currency : null;
|
||||||
|
const symbolUrl = config?.symbol;
|
||||||
|
if (!symbolUrl) return null;
|
||||||
|
|
||||||
|
const alt =
|
||||||
|
config?.name?.trim() ||
|
||||||
|
config?.singular?.trim() ||
|
||||||
|
config?.plural?.trim() ||
|
||||||
|
"currency symbol";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={symbolUrl}
|
||||||
|
alt={alt}
|
||||||
|
aria-hidden={ariaHidden}
|
||||||
|
loading="lazy"
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-[0.85em] align-middle max-h-[1.15em] object-contain",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,8 @@ import Link from "next/link";
|
||||||
import { ChevronRightIcon, WalletIcon } from "lucide-react";
|
import { ChevronRightIcon, WalletIcon } from "lucide-react";
|
||||||
import type { BankAccount, Resource } from "@/payload-types";
|
import type { BankAccount, Resource } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import {
|
import {
|
||||||
type AccountStatus,
|
type AccountStatus,
|
||||||
accountStatusBase,
|
accountStatusBase,
|
||||||
|
|
@ -120,10 +121,10 @@ export function WalletHero({
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
|
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
|
||||||
{formatAmount(
|
<CurrencyAmount
|
||||||
account.balance ?? 0,
|
value={account.balance ?? 0}
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
currency={currencyConfig.resourceName ? currencyConfig : currency}
|
||||||
)}
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
||||||
LAST ACTIVITY {lastActivity}
|
LAST ACTIVITY {lastActivity}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface ConstructionPanelProps {
|
||||||
completesAt: string | null;
|
completesAt: string | null;
|
||||||
durationMinutes: number | null;
|
durationMinutes: number | null;
|
||||||
materials: ConstructionMaterial[];
|
materials: ConstructionMaterial[];
|
||||||
|
crew?: { required: number; effective: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
|
@ -48,6 +49,7 @@ export function ConstructionPanel({
|
||||||
completesAt,
|
completesAt,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
materials,
|
materials,
|
||||||
|
crew,
|
||||||
}: ConstructionPanelProps) {
|
}: ConstructionPanelProps) {
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
|
@ -62,6 +64,30 @@ export function ConstructionPanel({
|
||||||
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
||||||
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
||||||
|
|
||||||
|
const crewRequired = crew?.required ?? 0;
|
||||||
|
const crewEffective = crew?.effective ?? 0;
|
||||||
|
const crewFactor = crewRequired > 0 ? Math.min(1, crewEffective / crewRequired) : 1;
|
||||||
|
const crewLine =
|
||||||
|
crewRequired > 0
|
||||||
|
? crewEffective <= 0
|
||||||
|
? {
|
||||||
|
text:
|
||||||
|
status === "building"
|
||||||
|
? "No crew assigned: construction is crawling at minimum speed."
|
||||||
|
: "No crew assigned: construction cannot start once materials arrive.",
|
||||||
|
className: "text-amber-300",
|
||||||
|
}
|
||||||
|
: crewFactor < 1
|
||||||
|
? {
|
||||||
|
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: understaffed, building at ${Math.round(crewFactor * 100)}% speed.`,
|
||||||
|
className: "text-amber-300",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: full build speed.`,
|
||||||
|
className: "text-emerald-400",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
@ -82,10 +108,7 @@ export function ConstructionPanel({
|
||||||
<span>{pct}%</span>
|
<span>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div className="h-full rounded-full bg-blue-400" style={{ width: `${pct}%` }} />
|
||||||
className="h-full rounded-full bg-blue-400"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
|
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
|
||||||
<ConstructionEta completesAt={completesAt} />
|
<ConstructionEta completesAt={completesAt} />
|
||||||
|
|
@ -100,6 +123,8 @@ export function ConstructionPanel({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{materials.map((material) => {
|
{materials.map((material) => {
|
||||||
const met = material.delivered >= material.required;
|
const met = material.delivered >= material.required;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BadgeCheckIcon } from "lucide-react";
|
import { BadgeCheckIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
|
|
@ -51,7 +52,7 @@ export function EmploymentCard({
|
||||||
{employment.positionType} · {employment.structureName}
|
{employment.positionType} · {employment.structureName}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-sm tabular-nums">
|
<p className="font-mono text-sm tabular-nums">
|
||||||
{formatAmount(employment.salary, currencyConfig)}/tick
|
<CurrencyAmount value={employment.salary} currency={currencyConfig} />/tick
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : canManage && hiringEnabled ? (
|
) : canManage && hiringEnabled ? (
|
||||||
|
|
|
||||||
148
src/components/frontend/baseManagement/ProductionPanel.tsx
Normal file
148
src/components/frontend/baseManagement/ProductionPanel.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Item, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
|
import { toggleProduction } from "@/app/(frontend)/logistics/base/actions";
|
||||||
|
|
||||||
|
export interface ProductionRecipeLine {
|
||||||
|
readonly name: string;
|
||||||
|
readonly amountPerHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionPanelProps {
|
||||||
|
readonly gameStructureId: number;
|
||||||
|
readonly complete: boolean;
|
||||||
|
readonly paused: boolean;
|
||||||
|
readonly stallReason: string | null;
|
||||||
|
readonly inputs: readonly ProductionRecipeLine[];
|
||||||
|
readonly outputs: readonly ProductionRecipeLine[];
|
||||||
|
readonly canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALL_LABELS: Record<string, string> = {
|
||||||
|
no_labor: "No active labor crew",
|
||||||
|
missing_inputs: "Missing input materials",
|
||||||
|
storage_full: "Storage is full",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductionPanel({
|
||||||
|
gameStructureId,
|
||||||
|
complete,
|
||||||
|
paused,
|
||||||
|
stallReason,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
canManage,
|
||||||
|
}: ProductionPanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
useGameTick(() => router.refresh());
|
||||||
|
|
||||||
|
if (!complete) return null;
|
||||||
|
if (inputs.length === 0 && outputs.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await toggleProduction(gameStructureId, !paused);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(!paused ? "Production resumed." : "Production paused.");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Could not change production.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = paused
|
||||||
|
? "Paused"
|
||||||
|
: stallReason
|
||||||
|
? (STALL_LABELS[stallReason] ?? stallReason)
|
||||||
|
: "Running";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item className="rounded-none">
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
Production
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
paused || stallReason
|
||||||
|
? "text-xs font-normal normal-case text-amber-400"
|
||||||
|
: "text-xs font-normal normal-case text-emerald-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
Runs per game tick while staffed; outputs land in void storage.
|
||||||
|
</ItemDescription>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label={paused ? "Resume production" : "Pause production"}
|
||||||
|
>
|
||||||
|
{paused ? <Play className="size-4" /> : <Pause className="size-4" />}
|
||||||
|
{paused ? "Resume" : "Pause"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stallReason === "missing_inputs" && (
|
||||||
|
<p className="text-xs text-amber-400/80">
|
||||||
|
Deliver the input materials below to resume the recipe.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/50">
|
||||||
|
Inputs / hour
|
||||||
|
</p>
|
||||||
|
{inputs.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">None</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{inputs.map((line) => (
|
||||||
|
<li key={line.name} className="tabular-nums">
|
||||||
|
{line.amountPerHour.toLocaleString()} {line.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/50">
|
||||||
|
Outputs / hour
|
||||||
|
</p>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">None</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{outputs.map((line) => (
|
||||||
|
<li key={line.name} className="tabular-nums">
|
||||||
|
{line.amountPerHour.toLocaleString()} {line.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/frontend/baseManagement/SlotsPanel.tsx
Normal file
151
src/components/frontend/baseManagement/SlotsPanel.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Blocks, Plus, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Item, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
|
import { installModule, uninstallModule } from "@/app/(frontend)/logistics/base/actions";
|
||||||
|
|
||||||
|
export interface ModuleEffectLine {
|
||||||
|
readonly type: string;
|
||||||
|
readonly value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleOptionLine {
|
||||||
|
readonly name: string;
|
||||||
|
readonly category: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly effects: readonly ModuleEffectLine[];
|
||||||
|
readonly materials: readonly { name: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotsPanelProps {
|
||||||
|
readonly gameStructureId: number;
|
||||||
|
readonly slots: number;
|
||||||
|
readonly installed: readonly { name: string; installedAt: string | null }[];
|
||||||
|
readonly options: readonly ModuleOptionLine[];
|
||||||
|
readonly canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFECT_LABELS: Record<string, (value: number) => string> = {
|
||||||
|
capacity_mass: (value) => `+${value.toLocaleString()} kg storage`,
|
||||||
|
storage_slots: (value) => `+${value} grid slots`,
|
||||||
|
production_multiplier: (value) => `+${Math.round(value * 100)}% production`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function effectLabel(effect: ModuleEffectLine): string {
|
||||||
|
const formatter = EFFECT_LABELS[effect.type];
|
||||||
|
return formatter ? formatter(effect.value) : `${effect.type} ${effect.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlotsPanel({
|
||||||
|
gameStructureId,
|
||||||
|
slots,
|
||||||
|
installed,
|
||||||
|
options,
|
||||||
|
canManage,
|
||||||
|
}: SlotsPanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
useGameTick(() => router.refresh());
|
||||||
|
|
||||||
|
if (slots <= 0 && options.length === 0 && installed.length === 0) return null;
|
||||||
|
|
||||||
|
const run = async (action: typeof installModule, moduleName: string, successMessage: string) => {
|
||||||
|
setLoading(moduleName);
|
||||||
|
try {
|
||||||
|
const result = await action(gameStructureId, moduleName);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(successMessage);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Could not change modules.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installedNames = new Set(installed.map((entry) => entry.name));
|
||||||
|
const available = slots - installed.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item className="rounded-none">
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<Blocks className="size-4 text-muted-foreground" />
|
||||||
|
Modules
|
||||||
|
</ItemTitle>
|
||||||
|
<span
|
||||||
|
className={`text-xs tabular-nums ${available > 0 ? "text-muted-foreground" : "text-amber-400"}`}
|
||||||
|
>
|
||||||
|
{installed.length} / {slots} slots
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installed.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{installed.map((entry) => (
|
||||||
|
<Badge key={entry.name} variant="outline" className="gap-1 pr-1">
|
||||||
|
{entry.name}
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Uninstall ${entry.name}`}
|
||||||
|
disabled={loading !== null}
|
||||||
|
onClick={() => run(uninstallModule, entry.name, `${entry.name} uninstalled.`)}
|
||||||
|
className="ml-0.5 rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && available > 0 && options.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{options
|
||||||
|
.filter((option) => !installedNames.has(option.name))
|
||||||
|
.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.name}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md bg-muted/40 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm">{option.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{option.effects.map(effectLabel).join(", ") || "No effects"}
|
||||||
|
{option.materials.length > 0 &&
|
||||||
|
` | needs ${option.materials
|
||||||
|
.map((material) => `${material.amount} ${material.name}`)
|
||||||
|
.join(", ")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading !== null}
|
||||||
|
onClick={() => run(installModule, option.name, `${option.name} installed.`)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slots > 0 && installed.length === 0 && options.length === 0 && (
|
||||||
|
<ItemDescription>No modules are defined for this structure type.</ItemDescription>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,8 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
||||||
|
|
||||||
|
|
@ -239,7 +240,7 @@ export function StaffRoster({
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{structure.maintenanceCost > 0 && (
|
{structure.maintenanceCost > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Maintenance: {formatAmount(structure.maintenanceCost, currencyConfig)}/tick
|
Maintenance: <CurrencyAmount value={structure.maintenanceCost} currency={currencyConfig} />/tick
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -262,7 +263,7 @@ export function StaffRoster({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="shrink-0 font-mono text-sm tabular-nums">
|
<span className="shrink-0 font-mono text-sm tabular-nums">
|
||||||
{formatAmount(member.salary, currencyConfig)}/tick
|
<CurrencyAmount value={member.salary} currency={currencyConfig} />/tick
|
||||||
</span>
|
</span>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -333,12 +334,8 @@ export function StaffRoster({
|
||||||
{crew.requiredHeadcount} required · max {crew.maxHeadcount}
|
{crew.requiredHeadcount} required · max {crew.maxHeadcount}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-sm tabular-nums text-muted-foreground">
|
<p className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||||
{formatAmount(crew.unitSalary, currencyConfig)}/tick each ·{" "}
|
<CurrencyAmount value={crew.unitSalary} currency={currencyConfig} />/tick each ·{" "}
|
||||||
{formatAmount(
|
<CurrencyAmount value={crew.unitSalary * crew.headcount} currency={currencyConfig} />/tick total
|
||||||
crew.unitSalary * crew.headcount,
|
|
||||||
currencyConfig,
|
|
||||||
)}
|
|
||||||
/tick total
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export function AppSidebar({
|
||||||
adminUrl,
|
adminUrl,
|
||||||
hasIntelligence = true,
|
hasIntelligence = true,
|
||||||
hasLogistics = true,
|
hasLogistics = true,
|
||||||
|
statisticsEnabled = true,
|
||||||
|
helpdeskEnabled = true,
|
||||||
showCallsign = false,
|
showCallsign = false,
|
||||||
canImpersonate = false,
|
canImpersonate = false,
|
||||||
preferences,
|
preferences,
|
||||||
|
|
@ -86,6 +88,8 @@ export function AppSidebar({
|
||||||
adminUrl?: string;
|
adminUrl?: string;
|
||||||
hasIntelligence?: boolean;
|
hasIntelligence?: boolean;
|
||||||
hasLogistics?: boolean;
|
hasLogistics?: boolean;
|
||||||
|
statisticsEnabled?: boolean;
|
||||||
|
helpdeskEnabled?: boolean;
|
||||||
showCallsign?: boolean;
|
showCallsign?: boolean;
|
||||||
canImpersonate?: boolean;
|
canImpersonate?: boolean;
|
||||||
preferences?: NonNullable<User["preferences"]>;
|
preferences?: NonNullable<User["preferences"]>;
|
||||||
|
|
@ -119,6 +123,19 @@ export function AppSidebar({
|
||||||
}
|
}
|
||||||
}, [isMiniMode, preferences]);
|
}, [isMiniMode, preferences]);
|
||||||
|
|
||||||
|
// Game-flags: hide the Helpdesk group and the Personnel -> Statistics entry
|
||||||
|
// when their feature is toggled off (data preserved, front-end only). An
|
||||||
|
// empty filtered list makes NavCore skip the group entirely, label included.
|
||||||
|
const filteredNavLogistics = data.navLogistics.map((section) =>
|
||||||
|
section.title === "Personnel"
|
||||||
|
? {
|
||||||
|
...section,
|
||||||
|
items: section.items.filter((item) => item.title === "Contacts" || statisticsEnabled),
|
||||||
|
}
|
||||||
|
: section,
|
||||||
|
);
|
||||||
|
const filteredNavHelpdesk = helpdeskEnabled ? data.navHelpdesk : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" {...props}>
|
<Sidebar variant="inset" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
|
|
@ -153,11 +170,12 @@ export function AppSidebar({
|
||||||
{hasIntelligence && (
|
{hasIntelligence && (
|
||||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||||
)}
|
)}
|
||||||
|
<NavCore label="Operations" items={data.navOperations} />
|
||||||
{hasLogistics && (
|
{hasLogistics && (
|
||||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
|
||||||
)}
|
)}
|
||||||
<NavCore label="Chronicle" items={data.navStory} />
|
<NavCore label="Chronicle" items={data.navStory} />
|
||||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
<NavCore label="Helpdesk" items={filteredNavHelpdesk} />
|
||||||
<NavCore label="Commerce" items={data.navCommerce} />
|
<NavCore label="Commerce" items={data.navCommerce} />
|
||||||
<NavCore label="Personal" items={data.navPersonal} />
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
export function NavCore({
|
export function NavCore({
|
||||||
label,
|
label,
|
||||||
items,
|
items,
|
||||||
|
expandDefaultOpen = true,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
items: {
|
items: {
|
||||||
|
|
@ -25,12 +26,14 @@ export function NavCore({
|
||||||
url: string;
|
url: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
items?: {
|
items?: {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
|
expandDefaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -44,7 +47,7 @@ export function NavCore({
|
||||||
<Collapsible
|
<Collapsible
|
||||||
key={item.title}
|
key={item.title}
|
||||||
asChild
|
asChild
|
||||||
defaultOpen={item.isActive}
|
defaultOpen={item.isActive || (item.defaultOpen === true && expandDefaultOpen !== false)}
|
||||||
className="group/collapsible"
|
className="group/collapsible"
|
||||||
>
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,16 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
ClipboardList,
|
||||||
Car,
|
Car,
|
||||||
Contact,
|
Contact,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Flag,
|
Flag,
|
||||||
Kanban,
|
Kanban,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
Map,
|
||||||
Package,
|
Package,
|
||||||
|
Radar,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
|
@ -23,10 +26,13 @@ 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
|
navMap: [
|
||||||
// functionality stay live for direct-URL access. Restore with:
|
{
|
||||||
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import).
|
title: "World Map",
|
||||||
navMap: [],
|
url: "/map",
|
||||||
|
icon: Map,
|
||||||
|
},
|
||||||
|
],
|
||||||
navHelpdesk: [
|
navHelpdesk: [
|
||||||
{
|
{
|
||||||
title: "Helpdesk",
|
title: "Helpdesk",
|
||||||
|
|
@ -66,11 +72,29 @@ export const sidebarData = {
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
navOperations: [
|
||||||
|
{
|
||||||
|
title: "Ops Overview",
|
||||||
|
url: "/operations",
|
||||||
|
icon: Radar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ops Ledger",
|
||||||
|
url: "/operations/ledger",
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reservations",
|
||||||
|
url: "/operations/reservations",
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
|
],
|
||||||
navLogistics: [
|
navLogistics: [
|
||||||
{
|
{
|
||||||
title: "Assets",
|
title: "Assets",
|
||||||
url: "",
|
url: "",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
|
defaultOpen: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Structures",
|
title: "Structures",
|
||||||
|
|
@ -93,6 +117,7 @@ export const sidebarData = {
|
||||||
title: "Operations",
|
title: "Operations",
|
||||||
url: "",
|
url: "",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
|
defaultOpen: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Shipments",
|
title: "Shipments",
|
||||||
|
|
@ -104,12 +129,18 @@ export const sidebarData = {
|
||||||
url: "/logistics/game-vehicles",
|
url: "/logistics/game-vehicles",
|
||||||
icon: Car,
|
icon: Car,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Supply Boxes",
|
||||||
|
url: "/logistics/supply-boxes",
|
||||||
|
icon: Boxes,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Personnel",
|
title: "Personnel",
|
||||||
url: "",
|
url: "",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
defaultOpen: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Contacts",
|
title: "Contacts",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import Link from "next/link";
|
||||||
import type { MarketListing, MarketNegotiation } from "@/payload-types";
|
import type { MarketListing, MarketNegotiation } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { ChevronRightIcon, HandshakeIcon } from "lucide-react";
|
import { ChevronRightIcon, HandshakeIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
|
|
||||||
interface OpenNegotiationsProps {
|
interface OpenNegotiationsProps {
|
||||||
readonly negotiations: MarketNegotiation[];
|
readonly negotiations: MarketNegotiation[];
|
||||||
|
|
@ -36,7 +37,7 @@ export function OpenNegotiations({
|
||||||
// NegotiationPanel on the market page).
|
// NegotiationPanel on the market page).
|
||||||
if (negotiations.length === 0) return null;
|
if (negotiations.length === 0) return null;
|
||||||
|
|
||||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
const fmt = (n: number) => <CurrencyAmount value={n} currency={currencyConfig} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import Link from "next/link";
|
||||||
import type { BankAccount } from "@/payload-types";
|
import type { BankAccount } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { WalletIcon } from "lucide-react";
|
import { WalletIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
|
import { MyWalletCard } from "@/components/frontend/banking/MyWalletCard";
|
||||||
|
|
||||||
interface WalletWidgetProps {
|
interface WalletWidgetProps {
|
||||||
|
|
@ -44,7 +45,7 @@ export function WalletWidget({ account, currencyConfig }: WalletWidgetProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 font-mono text-xl font-semibold tabular-nums">
|
<span className="shrink-0 font-mono text-xl font-semibold tabular-nums">
|
||||||
{formatAmount(account.balance ?? 0, currencyConfig)}
|
<CurrencyAmount value={account.balance ?? 0} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
|
||||||
106
src/components/frontend/dev/DevDashboardGate.tsx
Normal file
106
src/components/frontend/dev/DevDashboardGate.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only guard for routes whose server render is pathologically slow under
|
||||||
|
* the Next.js 16 dev runtime (see payloadcms/payload#14487): the dashboard
|
||||||
|
* takes roughly 100s per render in dev while the browser gives up around 60s,
|
||||||
|
* so clicking any link to it looks like a hang. This component intercepts
|
||||||
|
* plain left-clicks on anchors pointing at those routes, shows a confirm
|
||||||
|
* dialog, and only navigates when the user insists.
|
||||||
|
*
|
||||||
|
* Gaps by design: direct URL entry, page refresh, modifier-clicks (Ctrl/Cmd +
|
||||||
|
* click), and programmatic router.push calls (e.g. the command palette) are
|
||||||
|
* not gated. Anchors inside a [data-dev-gate-bypass] ancestor are also
|
||||||
|
* exempt, so a specific link can opt out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GATED_PATHS = new Set<string>(["/"]);
|
||||||
|
|
||||||
|
function resolveTarget(href: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
if (url.origin !== window.location.origin) return null;
|
||||||
|
if (!GATED_PATHS.has(url.pathname)) return null;
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevDashboardGate() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [target, setTarget] = useState("/");
|
||||||
|
const proceedRef = useRef<() => void>(() => undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "development") return undefined;
|
||||||
|
|
||||||
|
function onClick(event: MouseEvent) {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchor = (event.target as Element | null)?.closest?.("a");
|
||||||
|
if (!anchor) return;
|
||||||
|
if (anchor.hasAttribute("download")) return;
|
||||||
|
const targetAttr = anchor.getAttribute("target");
|
||||||
|
if (targetAttr && targetAttr !== "_self") return;
|
||||||
|
if (anchor.closest("[data-dev-gate-bypass]")) return;
|
||||||
|
const next = resolveTarget(anchor.getAttribute("href") ?? "");
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
proceedRef.current = () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(next);
|
||||||
|
};
|
||||||
|
setTarget(next);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", onClick, { capture: true });
|
||||||
|
return () => document.removeEventListener("click", onClick, { capture: true });
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "development") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Open the dashboard in dev mode?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
In dev, <span className="font-mono text-xs">{target}</span> renders the dashboard
|
||||||
|
server-side in roughly 100 seconds (a known Next.js 16 dev-runtime regression) and the
|
||||||
|
browser usually gives up around 60s with an empty response. Production serves the same
|
||||||
|
page in a few seconds. Stay here, or open anyway and wait.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Stay here</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => proceedRef.current()}>Open anyway</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,8 +43,8 @@ const officialStatusLabels: Record<Campaign["status"], string> = {
|
||||||
|
|
||||||
const statusTextColors: Record<Campaign["status"], string> = {
|
const statusTextColors: Record<Campaign["status"], string> = {
|
||||||
concept: "text-muted-foreground",
|
concept: "text-muted-foreground",
|
||||||
inProgress: "text-blue-400",
|
inProgress: "text-emerald-400",
|
||||||
completed: "text-emerald-400",
|
completed: "text-blue-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) {
|
export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||||
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
|
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
|
||||||
|
import { getShipmentStatus, isShipmentActive } from "@/lib/shipping/client";
|
||||||
|
|
||||||
interface ShipmentCardProps {
|
interface ShipmentCardProps {
|
||||||
shipment: Shipment;
|
shipment: Shipment;
|
||||||
|
|
@ -47,7 +48,8 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
||||||
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
|
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
|
||||||
|
|
||||||
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
|
const effectiveStatus = getShipmentStatus(shipment);
|
||||||
|
const isActive = isShipmentActive(shipment);
|
||||||
|
|
||||||
const cargoCount = shipment.cargo?.length ?? 0;
|
const cargoCount = shipment.cargo?.length ?? 0;
|
||||||
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
||||||
|
|
@ -71,11 +73,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[shipment.status] ?? "bg-muted text-muted-foreground"}`}
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[effectiveStatus] ?? "bg-muted text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{shipment.status === "in_transit"
|
{effectiveStatus === "in_transit"
|
||||||
? "In Transit"
|
? "In Transit"
|
||||||
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
|
: effectiveStatus === "arrived"
|
||||||
|
? "Arriving"
|
||||||
|
: effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
|
@ -106,11 +110,15 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemDescription>
|
<ItemDescription>
|
||||||
{shipment.status === "completed"
|
{effectiveStatus === "completed"
|
||||||
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
|
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
|
||||||
: shipment.status === "cancelled"
|
: effectiveStatus === "cancelled"
|
||||||
? "Cancelled"
|
? "Cancelled"
|
||||||
: (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))}
|
: effectiveStatus === "arrived"
|
||||||
|
? "Arriving"
|
||||||
|
: effectiveStatus === "in_transit" || effectiveStatus === "dispatched"
|
||||||
|
? "In transit"
|
||||||
|
: effectiveStatus}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
||||||
View details <ChevronRightIcon className="size-3" />
|
View details <ChevronRightIcon className="size-3" />
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,8 @@ import type { Shipment } from "@/payload-types";
|
||||||
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
|
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import {
|
import { CheckCircleIcon, ClockIcon, ListFilterIcon, TruckIcon, XCircleIcon } from "lucide-react";
|
||||||
CheckCircleIcon,
|
import { isShipmentActive } from "@/lib/shipping/client";
|
||||||
ClockIcon,
|
|
||||||
ListFilterIcon,
|
|
||||||
TruckIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ label: "All", value: null },
|
{ label: "All", value: null },
|
||||||
|
|
@ -21,8 +16,6 @@ const statusFilters = [
|
||||||
{ label: "Stranded", value: "stranded" },
|
{ label: "Stranded", value: "stranded" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeStatuses = ["dispatched", "in_transit", "pending"];
|
|
||||||
|
|
||||||
interface ShipmentsListProps {
|
interface ShipmentsListProps {
|
||||||
shipments: Shipment[];
|
shipments: Shipment[];
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +69,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!filter) return shipments;
|
if (!filter) return shipments;
|
||||||
if (filter === "active") {
|
if (filter === "active") {
|
||||||
return shipments.filter((s) => activeStatuses.includes(s.status));
|
return shipments.filter((s) => isShipmentActive(s));
|
||||||
}
|
}
|
||||||
return shipments.filter((s) => s.status === filter);
|
return shipments.filter((s) => s.status === filter);
|
||||||
}, [shipments, filter]);
|
}, [shipments, filter]);
|
||||||
|
|
|
||||||
207
src/components/frontend/logistics/SupplyBoxActions.tsx
Normal file
207
src/components/frontend/logistics/SupplyBoxActions.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
queueCrateCommand,
|
||||||
|
type ActionResult,
|
||||||
|
type CrateCommand,
|
||||||
|
} from "@/app/(frontend)/logistics/supply-boxes/actions";
|
||||||
|
|
||||||
|
export interface SupplyBoxContentLine {
|
||||||
|
class: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplyBoxActionsProps {
|
||||||
|
boxId: string;
|
||||||
|
boxClass: string | null;
|
||||||
|
location: { x: number; y: number; z: number } | null;
|
||||||
|
contents: SupplyBoxContentLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingAction = CrateCommand | null;
|
||||||
|
|
||||||
|
export function SupplyBoxActions({ boxId, boxClass, location, contents }: SupplyBoxActionsProps) {
|
||||||
|
const [lines, setLines] = useState<SupplyBoxContentLine[]>(contents);
|
||||||
|
const [confirm, setConfirm] = useState<PendingAction>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function updateLine(index: number, field: "class" | "count", value: string) {
|
||||||
|
setLines((prev) =>
|
||||||
|
prev.map((line, i) =>
|
||||||
|
i === index
|
||||||
|
? field === "class"
|
||||||
|
? { ...line, class: value }
|
||||||
|
: { ...line, count: Number.parseInt(value, 10) || 0 }
|
||||||
|
: line,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, { class: "", count: 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(index: number) {
|
||||||
|
setLines((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirm(action: Exclude<PendingAction, null>) {
|
||||||
|
setError(null);
|
||||||
|
setConfirm(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch() {
|
||||||
|
if (confirm === null) return;
|
||||||
|
setError(null);
|
||||||
|
let result: ActionResult<{ commandId: string; status: string }>;
|
||||||
|
if (confirm === "apply") {
|
||||||
|
const clean = lines.filter((line) => line.class.trim() !== "" && line.count > 0);
|
||||||
|
result = await queueCrateCommand(
|
||||||
|
boxId,
|
||||||
|
"apply",
|
||||||
|
[clean.map((line) => [line.class.trim(), line.count])],
|
||||||
|
);
|
||||||
|
} else if (confirm === "despawn") {
|
||||||
|
result = await queueCrateCommand(boxId, "despawn", []);
|
||||||
|
} else {
|
||||||
|
if (boxClass === null || location === null) return;
|
||||||
|
result = await queueCrateCommand(boxId, "spawn", [boxClass, location.x, location.y, location.z]);
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Failed to queue command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirm(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyLineCount = lines.filter((line) => line.class.trim() !== "" && line.count > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-slot="supply-box-actions" className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||||
|
Contents to apply
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={line.class}
|
||||||
|
onChange={(e) => updateLine(index, "class", e.target.value)}
|
||||||
|
placeholder="itemClass"
|
||||||
|
className="min-w-0 flex-1 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={String(line.count)}
|
||||||
|
onChange={(e) => updateLine(index, "count", e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-20 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeLine(index)}
|
||||||
|
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/60 hover:border-rose-500/40 hover:text-rose-300"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addLine}
|
||||||
|
className="self-start border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Add line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="supply-box-apply"
|
||||||
|
onClick={() => openConfirm("apply")}
|
||||||
|
disabled={applyLineCount === 0}
|
||||||
|
className="border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 transition-colors hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Queue apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="supply-box-despawn"
|
||||||
|
onClick={() => openConfirm("despawn")}
|
||||||
|
className="border border-rose-500/30 bg-rose-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-rose-300 transition-colors hover:border-rose-500/50 hover:text-rose-200"
|
||||||
|
>
|
||||||
|
Despawn
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="supply-box-respawn"
|
||||||
|
onClick={() => openConfirm("spawn")}
|
||||||
|
disabled={boxClass === null || location === null}
|
||||||
|
className="border border-blue-500/30 bg-blue-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-blue-300 transition-colors hover:border-blue-500/50 hover:text-blue-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Respawn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p data-slot="supply-box-actions-error" className="min-w-0 truncate text-xs text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={confirm !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirm(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirm crate command</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{confirm === "apply"
|
||||||
|
? `Queue crate.apply with ${applyLineCount} item line${applyLineCount === 1 ? "" : "s"} to the game server?`
|
||||||
|
: confirm === "despawn"
|
||||||
|
? "Remove this crate from the game server? The stored row stays as a deleted snapshot."
|
||||||
|
: "Spawn this crate at its last known location?"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirm(null)}
|
||||||
|
className="border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="supply-box-confirm"
|
||||||
|
onClick={() => startTransition(dispatch)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="border border-emerald-500/30 bg-emerald-500/5 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Queuing..." : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
saveMapZone,
|
saveMapZone,
|
||||||
} from "@/app/(frontend)/logistics/map/actions";
|
} from "@/app/(frontend)/logistics/map/actions";
|
||||||
import type { MapPoint } from "@/lib/map/points";
|
import type { MapPoint } from "@/lib/map/points";
|
||||||
|
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
|
||||||
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
|
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
|
||||||
|
|
||||||
export type AuthoringKind = "road" | "zone" | "node" | null;
|
export type AuthoringKind = "road" | "zone" | "node" | null;
|
||||||
|
|
@ -62,8 +63,8 @@ export interface MapAuthoring {
|
||||||
setZoneName: (value: string) => void;
|
setZoneName: (value: string) => void;
|
||||||
zoneDescription: string;
|
zoneDescription: string;
|
||||||
setZoneDescription: (value: string) => void;
|
setZoneDescription: (value: string) => void;
|
||||||
zoneType: "water" | "land" | "territory" | "border";
|
zoneType: "water" | "land" | "territory" | "border" | "other";
|
||||||
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
|
setZoneType: (value: "water" | "land" | "territory" | "border" | "other") => void;
|
||||||
zoneLabelVisible: boolean;
|
zoneLabelVisible: boolean;
|
||||||
setZoneLabelVisible: (value: boolean) => void;
|
setZoneLabelVisible: (value: boolean) => void;
|
||||||
fillColor: string;
|
fillColor: string;
|
||||||
|
|
@ -80,6 +81,8 @@ export interface MapAuthoring {
|
||||||
setNodeDescription: (value: string) => void;
|
setNodeDescription: (value: string) => void;
|
||||||
resourceIds: number[];
|
resourceIds: number[];
|
||||||
toggleResourceId: (id: number) => void;
|
toggleResourceId: (id: number) => void;
|
||||||
|
nodeIcon: string;
|
||||||
|
setNodeIcon: (value: string) => void;
|
||||||
zoneResourceIds: number[];
|
zoneResourceIds: number[];
|
||||||
toggleZoneResourceId: (id: number) => void;
|
toggleZoneResourceId: (id: number) => void;
|
||||||
richness: string;
|
richness: string;
|
||||||
|
|
@ -112,7 +115,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
||||||
const [zoneName, setZoneName] = useState("");
|
const [zoneName, setZoneName] = useState("");
|
||||||
const [zoneDescription, setZoneDescription] = useState("");
|
const [zoneDescription, setZoneDescription] = useState("");
|
||||||
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
|
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border" | "other">(
|
||||||
|
"water",
|
||||||
|
);
|
||||||
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
|
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
|
||||||
const [fillColor, setFillColor] = useState("");
|
const [fillColor, setFillColor] = useState("");
|
||||||
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||||
|
|
@ -121,6 +126,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
const [nodeName, setNodeName] = useState("");
|
const [nodeName, setNodeName] = useState("");
|
||||||
const [nodeDescription, setNodeDescription] = useState("");
|
const [nodeDescription, setNodeDescription] = useState("");
|
||||||
const [resourceIds, setResourceIds] = useState<number[]>([]);
|
const [resourceIds, setResourceIds] = useState<number[]>([]);
|
||||||
|
const [nodeIcon, setNodeIcon] = useState("marker");
|
||||||
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
|
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
|
||||||
const [richness, setRichness] = useState("100");
|
const [richness, setRichness] = useState("100");
|
||||||
|
|
||||||
|
|
@ -210,6 +216,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
setNodeName(node.name);
|
setNodeName(node.name);
|
||||||
setNodeDescription(node.description ?? "");
|
setNodeDescription(node.description ?? "");
|
||||||
setResourceIds(node.resources.map((entry) => entry.id));
|
setResourceIds(node.resources.map((entry) => entry.id));
|
||||||
|
setNodeIcon(node.icon ?? "marker");
|
||||||
setRichness(String(node.richness ?? 100));
|
setRichness(String(node.richness ?? 100));
|
||||||
setEditing({ kind: "node", id: node.id, label: node.name });
|
setEditing({ kind: "node", id: node.id, label: node.name });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -270,6 +277,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
id: editing?.kind === "node" ? editing.id : undefined,
|
id: editing?.kind === "node" ? editing.id : undefined,
|
||||||
mapId,
|
mapId,
|
||||||
name: nodeName.trim(),
|
name: nodeName.trim(),
|
||||||
|
icon: nodeIcon,
|
||||||
description: nodeDescription.trim() || null,
|
description: nodeDescription.trim() || null,
|
||||||
resourceIds,
|
resourceIds,
|
||||||
position: draftPoints[0],
|
position: draftPoints[0],
|
||||||
|
|
@ -328,6 +336,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
nodeName,
|
nodeName,
|
||||||
nodeDescription,
|
nodeDescription,
|
||||||
resourceIds,
|
resourceIds,
|
||||||
|
nodeIcon,
|
||||||
zoneResourceIds,
|
zoneResourceIds,
|
||||||
richness,
|
richness,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -335,9 +344,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
|
|
||||||
const roadReady = kind !== "road" || draftPoints.length >= 2;
|
const roadReady = kind !== "road" || draftPoints.length >= 2;
|
||||||
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
|
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
|
||||||
const nodeReady =
|
const nodeReady = kind !== "node" || (draftPoints.length === 1 && nodeName.trim().length > 0);
|
||||||
kind !== "node" ||
|
|
||||||
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
|
|
||||||
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
|
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -385,6 +392,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
||||||
setNodeDescription,
|
setNodeDescription,
|
||||||
resourceIds,
|
resourceIds,
|
||||||
toggleResourceId,
|
toggleResourceId,
|
||||||
|
nodeIcon,
|
||||||
|
setNodeIcon,
|
||||||
zoneResourceIds,
|
zoneResourceIds,
|
||||||
toggleZoneResourceId,
|
toggleZoneResourceId,
|
||||||
richness,
|
richness,
|
||||||
|
|
@ -711,7 +720,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
||||||
<Select
|
<Select
|
||||||
value={fields.zoneType}
|
value={fields.zoneType}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
fields.setZoneType(value as "water" | "land" | "territory" | "border")
|
fields.setZoneType(value as "water" | "land" | "territory" | "border" | "other")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-24 text-xs">
|
<SelectTrigger className="h-8 w-24 text-xs">
|
||||||
|
|
@ -722,6 +731,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
||||||
<SelectItem value="land">Land</SelectItem>
|
<SelectItem value="land">Land</SelectItem>
|
||||||
<SelectItem value="territory">Territory</SelectItem>
|
<SelectItem value="territory">Territory</SelectItem>
|
||||||
<SelectItem value="border">Country Border</SelectItem>
|
<SelectItem value="border">Country Border</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -819,6 +829,21 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
||||||
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
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>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-[10px] uppercase text-white/60">Icon</Label>
|
||||||
|
<Select value={fields.nodeIcon} onValueChange={fields.setNodeIcon}>
|
||||||
|
<SelectTrigger className="h-8 w-28 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{NODE_ICON_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<ResourcePicker
|
<ResourcePicker
|
||||||
label="Resources"
|
label="Resources"
|
||||||
selected={fields.resourceIds}
|
selected={fields.resourceIds}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,14 @@ import {
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Hammer, Pencil, RefreshCw } from "lucide-react";
|
import { Hammer, Pencil, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useGameTick } from "@/hooks/useGameTick";
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
|
import {
|
||||||
|
distanceSquared,
|
||||||
|
interpolateAlong,
|
||||||
|
zoneLabelPoint,
|
||||||
|
polylineLength,
|
||||||
|
type MapPoint,
|
||||||
|
} from "@/lib/map/points";
|
||||||
import {
|
import {
|
||||||
findSnap,
|
findSnap,
|
||||||
segmentMidpoints,
|
segmentMidpoints,
|
||||||
|
|
@ -50,9 +49,10 @@ import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/l
|
||||||
import { PlacementDialog } from "./PlacementDialog";
|
import { PlacementDialog } from "./PlacementDialog";
|
||||||
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
|
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
|
||||||
import { StructureDetailDrawer } from "./StructureDetailDrawer";
|
import { StructureDetailDrawer } from "./StructureDetailDrawer";
|
||||||
|
import { nodeIconOption } from "@/lib/map/nodeIcons";
|
||||||
|
|
||||||
export interface MapClientProps {
|
export interface MapClientProps {
|
||||||
maps: { id: number; name: string }[];
|
map: { id: number; name: string };
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canPlace: boolean;
|
canPlace: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -75,11 +75,15 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
|
||||||
// Layer styling is hoisted to module constants: react-leaflet re-applies
|
// Layer styling is hoisted to module constants: react-leaflet re-applies
|
||||||
// pathOptions by reference, and a fresh object literal every render forces a
|
// pathOptions by reference, and a fresh object literal every render forces a
|
||||||
// canvas redraw that glitches mid-zoom-animation.
|
// canvas redraw that glitches mid-zoom-animation.
|
||||||
const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
|
const ZONE_PATH_OPTIONS: Record<
|
||||||
|
"water" | "land" | "territory" | "border" | "other",
|
||||||
|
L.PathOptions
|
||||||
|
> = {
|
||||||
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
|
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
|
||||||
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
|
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
|
||||||
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
|
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
|
||||||
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
|
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
|
||||||
|
other: { color: "#94a3b8", fillColor: "#94a3b8", fillOpacity: 0.08, weight: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
|
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
|
||||||
|
|
@ -258,12 +262,23 @@ function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeIcon = L.divIcon({
|
const nodeIconCache = new Map<string, L.DivIcon>();
|
||||||
className: "map-structure-icon",
|
|
||||||
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(NODE_SVG_PATH, "#22d3ee")}</div>`,
|
function nodeIconFor(icon: string | null | undefined): L.DivIcon {
|
||||||
iconSize: [20, 20],
|
const key = icon ?? "marker";
|
||||||
iconAnchor: [10, 10],
|
let cached = nodeIconCache.get(key);
|
||||||
});
|
if (!cached) {
|
||||||
|
const option = nodeIconOption(key);
|
||||||
|
cached = L.divIcon({
|
||||||
|
className: "map-structure-icon",
|
||||||
|
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(option.path, "#22d3ee")}</div>`,
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10],
|
||||||
|
});
|
||||||
|
nodeIconCache.set(key, cached);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
const shipmentIconCache = new Map<number, L.DivIcon>();
|
const shipmentIconCache = new Map<number, L.DivIcon>();
|
||||||
|
|
||||||
|
|
@ -705,6 +720,14 @@ function StructurePopup({
|
||||||
? `${Math.round(structure.storageUsedKg).toLocaleString()} / ${structure.storageCapacityKg.toLocaleString()} kg`
|
? `${Math.round(structure.storageUsedKg).toLocaleString()} / ${structure.storageCapacityKg.toLocaleString()} kg`
|
||||||
: `${Math.round(structure.storageUsedKg).toLocaleString()} kg`,
|
: `${Math.round(structure.storageUsedKg).toLocaleString()} kg`,
|
||||||
},
|
},
|
||||||
|
...(building || !(structure.compoundChildren != null && structure.compoundChildren > 0)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: "Compound",
|
||||||
|
value: `${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="border-t border-white/15 px-3 py-2">
|
<div className="border-t border-white/15 px-3 py-2">
|
||||||
|
|
@ -872,6 +895,8 @@ const StructureLayer = memo(function StructureLayer({
|
||||||
ribbonMeta = first
|
ribbonMeta = first
|
||||||
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
|
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
|
||||||
: "Awaiting materials";
|
: "Awaiting materials";
|
||||||
|
} else if ((structure.compoundChildren ?? 0) > 0) {
|
||||||
|
ribbonMeta = `Compound: ${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`;
|
||||||
} else {
|
} else {
|
||||||
ribbonMeta = structure.blueprintName ?? "Structure";
|
ribbonMeta = structure.blueprintName ?? "Structure";
|
||||||
}
|
}
|
||||||
|
|
@ -1125,8 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
|
||||||
const [selectedMapId, setSelectedMapId] = useState<number | null>(maps[0]?.id ?? null);
|
const selectedMapId = map.id;
|
||||||
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
|
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [toggles, setToggles] = useState<LayerToggles>({
|
const [toggles, setToggles] = useState<LayerToggles>({
|
||||||
|
|
@ -1284,16 +1309,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
];
|
];
|
||||||
}, [mode, authoring.kind, features]);
|
}, [mode, authoring.kind, features]);
|
||||||
|
|
||||||
if (maps.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 items-center justify-center p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No active maps yet. Maps are configured in the admin panel (World: Maps).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = (key: keyof LayerToggles) =>
|
const toggle = (key: keyof LayerToggles) =>
|
||||||
setToggles((current) => ({ ...current, [key]: !current[key] }));
|
setToggles((current) => ({ ...current, [key]: !current[key] }));
|
||||||
|
|
||||||
|
|
@ -1487,7 +1502,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
zone.name && zone.labelVisible !== false ? (
|
zone.name && zone.labelVisible !== false ? (
|
||||||
<Marker
|
<Marker
|
||||||
key={`zone-label-${zone.id}`}
|
key={`zone-label-${zone.id}`}
|
||||||
position={toLatLng(polygonCentroid(zone.points))}
|
position={toLatLng(zoneLabelPoint(zone.points) ?? polygonCentroid(zone.points))}
|
||||||
icon={zoneLabelIcon(zone.name)}
|
icon={zoneLabelIcon(zone.name)}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1499,7 +1514,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
{features && toggles.nodes && (
|
{features && toggles.nodes && (
|
||||||
<>
|
<>
|
||||||
{nodeDots.map(({ node, position }) => (
|
{nodeDots.map(({ node, position }) => (
|
||||||
<Marker key={`node-${node.id}`} position={position} icon={nodeIcon}>
|
<Marker key={`node-${node.id}`} position={position} icon={nodeIconFor(node.icon)}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
direction="top"
|
direction="top"
|
||||||
offset={[0, -14]}
|
offset={[0, -14]}
|
||||||
|
|
@ -1518,13 +1533,15 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-semibold">{node.name}</div>
|
<div className="font-semibold">{node.name}</div>
|
||||||
<div className="text-xs text-white/60">
|
{node.resources.length > 0 ? (
|
||||||
{node.resources.map((entry) => entry.name).join(", ") || "No resources"}
|
<div className="text-xs text-white/60">
|
||||||
</div>
|
Resources: {node.resources.map((entry) => entry.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{node.description && (
|
{node.description && (
|
||||||
<div className="mt-1 text-xs text-white/70">{node.description}</div>
|
<div className="mt-1 text-xs text-white/70">{node.description}</div>
|
||||||
)}
|
)}
|
||||||
{node.richness != null && (
|
{node.richness != null && node.resources.length > 0 && (
|
||||||
<div className="text-xs text-white/60">Richness {node.richness}</div>
|
<div className="text-xs text-white/60">Richness {node.richness}</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && mode === "edit" && (
|
{canEdit && mode === "edit" && (
|
||||||
|
|
@ -1621,7 +1638,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
<Polygon
|
<Polygon
|
||||||
positions={authoring.draftPoints.map(toLatLng)}
|
positions={authoring.draftPoints.map(toLatLng)}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: authoring.fields.zoneType === "water" ? "#38bdf8" : "#4ade80",
|
color: ZONE_PATH_OPTIONS[authoring.fields.zoneType].color ?? "#4ade80",
|
||||||
fillOpacity: 0.1,
|
fillOpacity: 0.1,
|
||||||
dashArray: "4 6",
|
dashArray: "4 6",
|
||||||
}}
|
}}
|
||||||
|
|
@ -1647,26 +1664,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 p-2">
|
<div className="flex flex-wrap items-center gap-2 p-2">
|
||||||
<Select
|
|
||||||
value={selectedMapId != null ? String(selectedMapId) : undefined}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
authoring.setKind(null);
|
|
||||||
setPreviewId(null);
|
|
||||||
setSelectedMapId(Number(value));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
|
||||||
<SelectValue placeholder="Select map" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{maps.map((map) => (
|
|
||||||
<SelectItem key={map.id} value={String(map.id)}>
|
|
||||||
{map.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{layerToggleKeys.map(({ key, label }) => (
|
{layerToggleKeys.map(({ key, label }) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,22 @@ export function PlacementDialog({
|
||||||
const [blueprintId, setBlueprintId] = useState<string>("");
|
const [blueprintId, setBlueprintId] = useState<string>("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [factionId, setFactionId] = useState<string>("none");
|
const [factionId, setFactionId] = useState<string>("none");
|
||||||
|
const [parentStructureId, setParentStructureId] = useState<string>("none");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Compound hubs: operational top-level structures only (children cannot be hubs).
|
||||||
|
const hubOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
structures
|
||||||
|
.filter(
|
||||||
|
(structure) =>
|
||||||
|
(structure.constructionStatus ?? "complete") === "complete" &&
|
||||||
|
structure.parentId === null,
|
||||||
|
)
|
||||||
|
.map((structure) => ({ id: structure.id, name: structure.name })),
|
||||||
|
[structures],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -196,6 +210,7 @@ export function PlacementDialog({
|
||||||
setBlueprintId("");
|
setBlueprintId("");
|
||||||
setName("");
|
setName("");
|
||||||
setFactionId("none");
|
setFactionId("none");
|
||||||
|
setParentStructureId("none");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|
@ -207,6 +222,7 @@ export function PlacementDialog({
|
||||||
blueprintId: blueprint.id,
|
blueprintId: blueprint.id,
|
||||||
name,
|
name,
|
||||||
factionId: factionId === "none" ? null : Number(factionId),
|
factionId: factionId === "none" ? null : Number(factionId),
|
||||||
|
parentStructureId: parentStructureId === "none" ? null : Number(parentStructureId),
|
||||||
x: point[0],
|
x: point[0],
|
||||||
y: point[1],
|
y: point[1],
|
||||||
});
|
});
|
||||||
|
|
@ -287,6 +303,30 @@ export function PlacementDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hubOptions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="place-parent">Compound (optional)</Label>
|
||||||
|
<Combobox
|
||||||
|
id="place-parent"
|
||||||
|
value={parentStructureId}
|
||||||
|
onValueChange={setParentStructureId}
|
||||||
|
searchPlaceholder="Search compounds..."
|
||||||
|
emptyMessage="No compound hubs available."
|
||||||
|
options={[
|
||||||
|
{ value: "none", label: "Standalone building" },
|
||||||
|
...hubOptions.map((hub) => ({
|
||||||
|
value: String(hub.id),
|
||||||
|
label: hub.name,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Child buildings pool their storage into the hub once operational; the hub carries
|
||||||
|
the compound's map label.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{blueprint && (
|
{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 className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,12 @@ import { StructureStorageView } from "@/components/frontend/storage/StructureSto
|
||||||
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 { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
|
||||||
|
import { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
|
||||||
|
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
|
||||||
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
|
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
|
||||||
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
|
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
|
||||||
import { useGameTick } from "@/hooks/useGameTick";
|
import { useGameTick } from "@/hooks/useGameTick";
|
||||||
import {
|
import { BoxIcon, CoinsIcon, MapPinIcon, WrenchIcon } from "lucide-react";
|
||||||
BoxIcon,
|
|
||||||
CoinsIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
|
|
@ -92,7 +89,11 @@ export function StructureDetailDrawer({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{data.imageUrl && (
|
{data.imageUrl && (
|
||||||
<div className="relative overflow-hidden rounded-none border border-white/15">
|
<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" />
|
<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 className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -145,7 +146,9 @@ export function StructureDetailDrawer({
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold">{data.derived.totalValue.toLocaleString()}</span>
|
<span className="text-xl font-bold">
|
||||||
|
{data.derived.totalValue.toLocaleString()}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">base value</span>
|
<span className="text-xs text-muted-foreground">base value</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
@ -213,6 +216,25 @@ export function StructureDetailDrawer({
|
||||||
completesAt={structure.constructionCompletesAt ?? null}
|
completesAt={structure.constructionCompletesAt ?? null}
|
||||||
durationMinutes={blueprint.constructionDurationMinutes ?? null}
|
durationMinutes={blueprint.constructionDurationMinutes ?? null}
|
||||||
materials={data.derived.constructionMaterials}
|
materials={data.derived.constructionMaterials}
|
||||||
|
crew={data.derived.constructionCrew}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProductionPanel
|
||||||
|
gameStructureId={structure.id}
|
||||||
|
complete={(structure.constructionStatus ?? "complete") === "complete"}
|
||||||
|
paused={data.production.paused}
|
||||||
|
stallReason={data.production.stallReason}
|
||||||
|
inputs={data.production.inputs}
|
||||||
|
outputs={data.production.outputs}
|
||||||
|
canManage={data.gates.canManage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlotsPanel
|
||||||
|
gameStructureId={structure.id}
|
||||||
|
slots={data.modules.slots}
|
||||||
|
installed={data.modules.installed}
|
||||||
|
options={data.modules.options}
|
||||||
|
canManage={data.gates.canManage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StaffRoster
|
<StaffRoster
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ import {
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { getAssetTypeLabel } from "@/lib/locker";
|
import { getAssetTypeLabel } from "@/lib/locker";
|
||||||
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import type { CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
|
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
|
||||||
import { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
|
import { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
|
||||||
import { Countdown } from "@/components/frontend/market/Countdown";
|
import { Countdown } from "@/components/frontend/market/Countdown";
|
||||||
|
|
@ -156,7 +157,7 @@ export function ListingCard({
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<div className="flex items-center justify-between gap-2 w-full">
|
<div className="flex items-center justify-between gap-2 w-full">
|
||||||
<span className="text-sm font-semibold font-mono">
|
<span className="text-sm font-semibold font-mono">
|
||||||
{formatAmount(listing.price ?? 0, currencyConfig)} / unit
|
<CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /> / unit
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{stock > 1 && (
|
{stock > 1 && (
|
||||||
|
|
@ -164,7 +165,7 @@ export function ListingCard({
|
||||||
{selectedQty}/{stock} ·{" "}
|
{selectedQty}/{stock} ·{" "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
total {formatAmount(selectedTotal, currencyConfig)}
|
total <CurrencyAmount value={selectedTotal} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import {
|
import {
|
||||||
acceptMarketOffer,
|
acceptMarketOffer,
|
||||||
cancelMarketOffer,
|
cancelMarketOffer,
|
||||||
|
|
@ -239,7 +240,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
<DialogTitle>Negotiate: {assetName}</DialogTitle>
|
<DialogTitle>Negotiate: {assetName}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "}
|
{isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "}
|
||||||
<span className="font-mono">{fmt(listing.price ?? 0)}</span> per unit.
|
<span className="font-mono"><CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /></span> per unit.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -282,7 +283,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{stock}× in stock · total{" "}
|
{stock}× in stock · total{" "}
|
||||||
<span className="font-mono">{fmt((Number(amount) || 0) * clampedQty)}</span>
|
<span className="font-mono"><CurrencyAmount value={(Number(amount) || 0) * clampedQty} currency={currencyConfig} /></span>
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
</>
|
</>
|
||||||
|
|
@ -291,7 +292,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
{outcome?.kind === "pending" && (
|
{outcome?.kind === "pending" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each for{" "}
|
Offer of <span className="font-mono"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span> each for{" "}
|
||||||
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
|
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
|
||||||
{isVendor ? "vendor" : "seller"} to respond.
|
{isVendor ? "vendor" : "seller"} to respond.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -303,10 +304,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
The {isVendor ? "vendor" : "seller"} counteroffers{" "}
|
The {isVendor ? "vendor" : "seller"} counteroffers{" "}
|
||||||
<span className="font-mono font-semibold">{fmt(negotiation?.amount ?? 0)}</span>{" "}
|
<span className="font-mono font-semibold"><CurrencyAmount value={negotiation?.amount ?? 0} currency={currencyConfig} /></span>{" "}
|
||||||
each for {negotiation?.quantity ?? stock}×, for a total of{" "}
|
each for {negotiation?.quantity ?? stock}×, for a total of{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{fmt((negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock))}
|
<CurrencyAmount value={(negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -330,10 +331,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
Deal closed
|
Deal closed
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Sold at <span className="font-mono">{fmt(outcome.price)}</span> each for{" "}
|
Sold at <span className="font-mono"><CurrencyAmount value={outcome.price} currency={currencyConfig} /></span> each for{" "}
|
||||||
{negotiation?.quantity ?? stock}×, for a total of{" "}
|
{negotiation?.quantity ?? stock}×, for a total of{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{fmt(outcome.price * (negotiation?.quantity ?? stock))}
|
<CurrencyAmount value={outcome.price * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
. The items are in your locker.
|
. The items are in your locker.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import { buildChatBubbles } from "@/lib/market/chatBubbles";
|
import { buildChatBubbles } from "@/lib/market/chatBubbles";
|
||||||
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
|
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
|
||||||
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
|
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
|
||||||
|
|
@ -79,7 +80,7 @@ function HistoryRow({
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const listing = listingOf(negotiation);
|
const listing = listingOf(negotiation);
|
||||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
const fmt = (n: number) => <CurrencyAmount value={n} currency={currencyConfig} />;
|
||||||
|
|
||||||
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
|
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
|
||||||
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");
|
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
|
||||||
import {
|
import {
|
||||||
acceptMarketOffer,
|
acceptMarketOffer,
|
||||||
cancelMarketOffer,
|
cancelMarketOffer,
|
||||||
|
|
@ -131,7 +132,7 @@ function NegotiationRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-mono">{fmt(negotiation.amount ?? 0)}</span>
|
<span className="font-mono"><CurrencyAmount value={negotiation.amount ?? 0} currency={currencyConfig} /></span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
|
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
|
||||||
{theirProposal
|
{theirProposal
|
||||||
|
|
@ -143,7 +144,7 @@ function NegotiationRow({
|
||||||
<>
|
<>
|
||||||
{" · total "}
|
{" · total "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{fmt((negotiation.amount ?? 0) * negotiation.quantity)}
|
<CurrencyAmount value={(negotiation.amount ?? 0) * negotiation.quantity} currency={currencyConfig} />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
162
src/components/frontend/operations/AarView.tsx
Normal file
162
src/components/frontend/operations/AarView.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||||
|
import { NoticeState } from "./NoticeState";
|
||||||
|
import { ProvenanceChip } from "./ProvenanceChip";
|
||||||
|
import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens";
|
||||||
|
import type { AarResult } from "@/lib/operations/surfaceQuery";
|
||||||
|
|
||||||
|
const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50";
|
||||||
|
const cell = "px-3 py-2 align-middle text-xs";
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section data-slot="aar-section" className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countsRow(aar: AarResult): ReadoutCell[] {
|
||||||
|
return [
|
||||||
|
{ label: "Events", value: String(aar.counts.events), mono: true },
|
||||||
|
{ label: "Validated", value: String(aar.counts.validated), mono: true },
|
||||||
|
{ label: "Rejected", value: String(aar.counts.rejected), mono: true },
|
||||||
|
{ label: "Applied", value: String(aar.counts.applied), mono: true },
|
||||||
|
{ label: "Failed", value: String(aar.counts.failed), mono: true },
|
||||||
|
{ label: "Pending", value: String(aar.counts.pending), mono: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AarView({ aar }: { aar: AarResult }) {
|
||||||
|
const failedEffects = aar.effects.filter((e) => e.status === "failed" || e.status === "reversed");
|
||||||
|
const appliedEffects = aar.effects.filter((e) => e.status === "applied");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6" data-slot="operations-aar-view">
|
||||||
|
<LedgerRow cells={countsRow(aar)} />
|
||||||
|
|
||||||
|
<Section title="Timeline">
|
||||||
|
{aar.events.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No events recorded for this operation." />
|
||||||
|
) : (
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10">
|
||||||
|
<th className={headCell}>Event</th>
|
||||||
|
<th className={headCell}>Type</th>
|
||||||
|
<th className={headCell}>Status</th>
|
||||||
|
<th className={headCell}>Occurred</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aar.events.map((ev) => (
|
||||||
|
<tr key={ev.id} className="border-b border-white/5 last:border-0">
|
||||||
|
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
|
||||||
|
<td className={`${cell} font-mono`}>{ev.type}</td>
|
||||||
|
<td className={cell}>
|
||||||
|
<span
|
||||||
|
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
|
||||||
|
>
|
||||||
|
{ev.status}
|
||||||
|
</span>
|
||||||
|
{ev.errorCode && (
|
||||||
|
<span className="ml-2 text-[10px] text-rose-300">
|
||||||
|
{ev.errorCode}: {ev.errorMessage ?? "see notes"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Applied effects">
|
||||||
|
{appliedEffects.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No applied effects yet for this operation." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{appliedEffects.map((eff) => (
|
||||||
|
<li
|
||||||
|
key={eff.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">{eff.effectKey}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||||
|
{eff.effectType}
|
||||||
|
</span>
|
||||||
|
<ProvenanceChip label={eff.status} href="/operations/ledger" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Failed or reversed effects">
|
||||||
|
{failedEffects.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No failed or reversed effects. Clean run." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{failedEffects.map((eff) => (
|
||||||
|
<li key={eff.id} className="border border-rose-500/30 px-2 py-1.5">
|
||||||
|
<p className="font-mono text-xs text-rose-300">{eff.effectKey}</p>
|
||||||
|
<p className="text-xs text-white/70">
|
||||||
|
{eff.notes ?? "Effect did not apply; see the operations ledger for reconciliation state."}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{aar.canSeePressure ? (
|
||||||
|
<Section title="Zone pressure">
|
||||||
|
{aar.pressure.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{aar.pressure.map((p, i) => (
|
||||||
|
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
||||||
|
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
|
||||||
|
>
|
||||||
|
{p.delta >= 0 ? `+${p.delta}` : p.delta}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
) : (
|
||||||
|
<NoticeState variant="empty" message="Zone pressure is currently disabled." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Reservations">
|
||||||
|
{aar.reservations.length === 0 ? (
|
||||||
|
<NoticeState variant="empty" message="No reservations linked to this operation." />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{aar.reservations.map((r) => (
|
||||||
|
<li key={r.reservationKey} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
||||||
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">{r.reservationKey}</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">{r.status}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<p className="text-xs text-white/50">
|
||||||
|
<Link href="/operations/ledger" className="underline-offset-4 hover:underline">
|
||||||
|
Open the full operations ledger
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/frontend/operations/ClassificationStrip.tsx
Normal file
14
src/components/frontend/operations/ClassificationStrip.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { DOSSIER } from "./tokens";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width classification strip: a label at both ends in mono uppercase.
|
||||||
|
* Carries real information (origin + reference), so it is not aria-hidden.
|
||||||
|
*/
|
||||||
|
export function ClassificationStrip({ left, right }: { left: string; right: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`${DOSSIER.strip} flex items-center justify-between`} data-slot="classification-strip">
|
||||||
|
<span className={DOSSIER.stripLabel}>{left}</span>
|
||||||
|
<span className={DOSSIER.stripLabel}>{right}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/frontend/operations/CornerBrackets.tsx
Normal file
15
src/components/frontend/operations/CornerBrackets.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Four corner L-marks, inset from the edges. Decorative only.
|
||||||
|
* Brightens on the PARENT's group-hover; all motion is transition-only and
|
||||||
|
* disabled under prefers-reduced-motion.
|
||||||
|
*/
|
||||||
|
export function CornerBrackets() {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-2 z-30" data-slot="corner-brackets">
|
||||||
|
<span className="absolute left-0 top-0 size-3 border-l border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||||
|
<span className="absolute right-0 top-0 size-3 border-r border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||||
|
<span className="absolute bottom-0 left-0 size-3 border-b border-l border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||||
|
<span className="absolute bottom-0 right-0 size-3 border-b border-r border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/frontend/operations/LedgerRow.tsx
Normal file
32
src/components/frontend/operations/LedgerRow.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { DOSSIER } from "./tokens";
|
||||||
|
|
||||||
|
export interface ReadoutCell {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent single-line readout footer: cells separated by hairlines, each a
|
||||||
|
* mono tabular value plus a small uppercase label. Presentational only.
|
||||||
|
*/
|
||||||
|
export function LedgerRow({ cells }: { cells: readonly ReadoutCell[] }) {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
data-slot="ledger-row"
|
||||||
|
className={`flex items-stretch ${DOSSIER.readout} ${cells.length ? "" : "hidden"}`}
|
||||||
|
>
|
||||||
|
{cells.map((cell, index) => (
|
||||||
|
<div
|
||||||
|
key={cell.label}
|
||||||
|
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 border-r border-white/15 px-1 last:border-r-0"
|
||||||
|
>
|
||||||
|
<span className={`truncate text-xs text-white/80 ${cell.mono ? "font-mono tabular-nums" : ""}`}>
|
||||||
|
{cell.value}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 ${DOSSIER.readoutLabel}`}>{cell.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/frontend/operations/LedgerTable.tsx
Normal file
137
src/components/frontend/operations/LedgerTable.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||||
|
import { NoticeState } from "./NoticeState";
|
||||||
|
import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens";
|
||||||
|
import type { LedgerResult } from "@/lib/operations/surfaceQuery";
|
||||||
|
|
||||||
|
const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50";
|
||||||
|
const cell = "px-3 py-2 align-middle text-xs";
|
||||||
|
|
||||||
|
export interface LedgerTableFilters {
|
||||||
|
operationId?: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHref(filters: LedgerTableFilters, page: number): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.operationId) params.set("operationId", filters.operationId);
|
||||||
|
if (filters.status) params.set("status", filters.status);
|
||||||
|
if (filters.type) params.set("type", filters.type);
|
||||||
|
if (page > 1) params.set("page", String(page));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `/operations/ledger?${qs}` : "/operations/ledger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LedgerTable({
|
||||||
|
result,
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
result: LedgerResult;
|
||||||
|
filters: LedgerTableFilters;
|
||||||
|
}) {
|
||||||
|
const countCells: ReadoutCell[] = Object.entries(result.effectCounts).map(([status, count]) => ({
|
||||||
|
label: status,
|
||||||
|
value: String(count),
|
||||||
|
mono: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3" data-slot="operations-ledger-table">
|
||||||
|
{countCells.length > 0 && <LedgerRow cells={countCells} />}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["", "validated", "rejected", "dead-letter"] as const).map((status) => (
|
||||||
|
<Link
|
||||||
|
key={status || "all"}
|
||||||
|
href={filterHref({ ...filters, status: status || undefined }, 1)}
|
||||||
|
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-widest ${
|
||||||
|
(filters.status ?? "") === status
|
||||||
|
? "border-white/40 bg-white/10 text-white"
|
||||||
|
: "border-white/15 text-white/60 hover:border-white/30 hover:text-white/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status || "all statuses"}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{result.events.length === 0 ? (
|
||||||
|
<NoticeState
|
||||||
|
variant="empty"
|
||||||
|
message="No operation events match this view."
|
||||||
|
hint="Adjust the filters, or wait for the next operation to report in."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10">
|
||||||
|
<th className={headCell}>Event</th>
|
||||||
|
<th className={headCell}>Operation</th>
|
||||||
|
<th className={headCell}>Type</th>
|
||||||
|
<th className={headCell}>Status</th>
|
||||||
|
<th className={headCell}>Occurred</th>
|
||||||
|
<th className={headCell}>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.events.map((ev) => (
|
||||||
|
<tr key={ev.id} className="border-b border-white/5 last:border-0">
|
||||||
|
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
|
||||||
|
<td className={cell}>
|
||||||
|
<Link
|
||||||
|
href={`/operations/${encodeURIComponent(ev.operationId)}`}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{ev.operationId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className={`${cell} font-mono`}>{ev.type}</td>
|
||||||
|
<td className={cell}>
|
||||||
|
<span
|
||||||
|
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
|
||||||
|
>
|
||||||
|
{ev.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
|
||||||
|
<td className={cell}>
|
||||||
|
{ev.errorCode || ev.errorMessage ? (
|
||||||
|
<span className="text-rose-300">
|
||||||
|
{ev.errorCode ?? "error"}: {ev.errorMessage ?? "see reconciliation notes"}
|
||||||
|
</span>
|
||||||
|
) : ev.notes ? (
|
||||||
|
<span className="text-white/60">{ev.notes}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/30">n/a</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||||
|
Page {result.page} of {Math.max(1, result.totalPages)} ({result.totalDocs} events)
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
{result.page > 1 && (
|
||||||
|
<Link
|
||||||
|
href={filterHref(filters, result.page - 1)}
|
||||||
|
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{result.page < result.totalPages && (
|
||||||
|
<Link
|
||||||
|
href={filterHref(filters, result.page + 1)}
|
||||||
|
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/frontend/operations/NoticeState.tsx
Normal file
37
src/components/frontend/operations/NoticeState.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty/error/loading notice states for operations surfaces.
|
||||||
|
* `error` renders with role="alert" (assertive); empty/loading render with
|
||||||
|
* role="status" (polite). Text is the message; optional action passes through.
|
||||||
|
*/
|
||||||
|
export function NoticeState({
|
||||||
|
variant,
|
||||||
|
message,
|
||||||
|
hint,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
variant: "empty" | "error" | "loading";
|
||||||
|
message: string;
|
||||||
|
hint?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const role = variant === "error" ? "alert" : "status";
|
||||||
|
const tone =
|
||||||
|
variant === "error"
|
||||||
|
? "border-rose-500/30 bg-rose-500/5 text-rose-300"
|
||||||
|
: "border-border/60 bg-card/50 text-muted-foreground";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={role}
|
||||||
|
data-slot="notice-state"
|
||||||
|
data-variant={variant}
|
||||||
|
aria-busy={variant === "loading" || undefined}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1.5 rounded-none border border-dashed px-6 py-10 text-center ${tone}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
{hint ? <p className="max-w-prose text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
|
{action ? <div className="mt-2">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue