1
0
Fork 0

Compare commits

...

39 commits

Author SHA1 Message Date
b6a3226e3d v0.2.48 - test database isolation, persistent tiles volume, deploy docs refresh 2026-09-22 17:58:48 -04:00
8cb6590ce6 chore(deploy): mount a persistent tiles volume and refresh deploy docs
Create /app/public/tiles in the runner image, bind-mount it in compose, and document the Coolify volume plus upload steps; restructure .gitignore (test artifacts, /public/tiles, backups).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-22 17:58:31 -04:00
8135c9850b feat(test): run tests against a dedicated truncated database
Bootstrap a per-run test database (ensure, push schema, truncate, seed baselines, repair sequences), wire vitest/playwright globals, and gate schema push behind DB_PUSH. Serial test files share the one database.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-22 17:58:17 -04:00
18bb6712cd v0.2.47 - approval tier enforcement, admin panel entry fix, game feature flags, GM respawn tickets, currency emblem display, hidden plugin collections 2026-09-22 14:53:38 -04:00
89a04b4435 chore: ignore personal .zinc alias notes 2026-09-22 14:53:38 -04:00
858cce4c40 docs: record permission tier, panel gate, and field access gotchas 2026-09-22 14:52:48 -04:00
529a9573b2 feat(admin): hide compose settings and API keys from non-superusers 2026-09-22 14:52:48 -04:00
239e95c462 feat(server): allow GM respawn ticket adjustments with permission-gated access 2026-09-22 14:52:44 -04:00
a6b5b19807 fix(operations): move page container responsibility out of PageShell 2026-09-22 14:52:44 -04:00
213a8c106f feat(banking): inline currency emblem display with optional label hiding 2026-09-22 14:52:44 -04:00
ae4ed8402d feat(game): admin feature flags for zone pressure, statistics, and helpdesk 2026-09-22 14:52:40 -04:00
8213d76816 chore(technologies): describe research cost resource logistics requirement 2026-09-22 14:50:46 -04:00
0617794da7 fix(access): reserve approval final states for the superuser tier, gate admin panel entry on page permissions 2026-09-22 14:50:40 -04:00
fda3fc9642 v0.2.46 - world map intel fields, node icons, currency emblem, division admin access, dev dashboard gate 2026-09-21 20:48:28 -04:00
85bd1b0659 docs: sync AGENTS.md and map system docs
- AGENTS.md documents world map eligibility, node icons, currency emblem
  wiring, division access and the dev dashboard gate
- docs/map-system.md covers the picker intel fields and marker nodes
2026-09-21 20:48:23 -04:00
5883990577 feat(dev): confirm gate before navigating to the dashboard in dev
- DevDashboardGate intercepts left-clicks on links resolving to / and asks
  before navigating; the dev-mode dashboard render takes ~100s while the
  browser aborts at ~60s, so links otherwise look like a hang
- mounted only when NODE_ENV is development and a user is logged in;
  modifier-clicks, new-tab and download anchors bypass the gate
- layout now imports canAccessAdminPanel from divisionAccess
2026-09-21 20:48:17 -04:00
4dde790aa8 feat(access): division-scoped admin page access
- scopedAdminPageAccess replaces requireAdminPageAccess: technologies pass for
  intelligence division members, assets/resources/vehicles for logistics;
  everyone else still needs admin:<slug>:manage
- technology approval is stripped from create/update below admin so division
  members can never self-approve; technologies access moves to the
  intelligence permission
- assets/resources/vehicles move to logistics division permissions
2026-09-21 20:48:08 -04:00
c177f2486f test(int): scope reservation vehicle and respawn balance assertions to the run
- vehicle fixtures are named to sort first so the 50-item name-sorted options
  window includes them even on a DB crowded with older idle vehicles
- the respawn bridge assertion filters to this run's steam ids so leaked rows
  from aborted runs cannot break exact equality
2026-09-21 20:48:02 -04:00
5fb619a5cf fix(operations): move page data helpers to surface modules
Satisfies the Next 16 rule that page files only export page fields:
getSupplyBoxDetail/getSupplyBoxList and getReservationList/getReservationDetail
move to reservations/surface.ts and pages import from it.
2026-09-21 20:47:57 -04:00
8c10719124 feat(banking): currency emblem image with CurrencyAmount display wrapper
- GameRules currencySymbol switches from text to a media upload
- new CurrencyAmount/CurrencySymbol components; render sites across banking,
  base management, dashboard, market, campaigns and profiles are wired to
  show the emblem inline with amounts
- migration 20260921_140000 (text column out, media fk in); registers the
  five pending migrations in the migrations index
2026-09-21 20:47:52 -04:00
9bbe44d38d feat(map): world map picker entry, intel fields, node icons, generic markers
- Maps gain worldMap flag, description, freeform weather, latitude/longitude,
  easeOfAccess and factionControl; picker cards render them as stat tags
- resource-nodes gain a shared icon set and may exist with no resources as
  plain marker nodes (placement ignores marker-only nodes)
- sidebar gains the World Map entry; Infrastructure section gets defaultOpen
- migrations 20260921_120000/123000/130500/134000; includes the generated
  types/schema sweep (currency emblem fields land in the next commit)
2026-09-21 20:47:46 -04:00
679a0f7879 feat(logistics): supply box management surface 2026-09-21 08:19:33 -04:00
f895f75317 feat(operations): reservation actions, surfaces, and cancel flow 2026-09-21 07:46:27 -04:00
1f7d1e47ef docs(arma-bridge): add crate-sync contract 2026-09-21 07:46:27 -04:00
f2bd5418b2 feat(operations): supply-box sync store and handler 2026-09-21 07:40:44 -04:00
42066e9d13 feat(operations): reservation creation flow
Add the create-reservation dialog and its option sources for todo 3 of
ops-reservations-and-crate-sync.

- src/lib/operations/reservationOptions.ts: server-only option pool
  (listReservationOptions) returning upcoming unit-visible missions, idle
  unreserved vehicles, operational structures, budget accounts (no
  balances), per-origin stock, and a capped roster.
- src/components/frontend/operations/reservations/CreateReservationDialog.tsx:
  client dialog with core fields, personnel/vehicle multi-picks, cargo
  line repeater, budget + expiry; client pre-validation mirrors the
  server validator; engine errors render verbatim.
- Mount the dialog on the reservations list page, server-gated by
  operation-reservations:create + hasLogisticsQualification.
- Integration tests for the option scopes; e2e spec written (run in F3).

tsc clean; 5/5 integration tests pass.
2026-09-21 04:39:09 -04:00
b483cf910b v0.2.45 - operation ledger, reservations, settlement, economy repair, readiness, zone pressure, ops surfaces, rollout controls, base production slices 2026-09-20 02:45:08 -04:00
c0563dae30 chore(generated): regenerate types and schema, register operation migrations
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:45:00 -04:00
37d28893de chore(operations): rollout controls, reconciliation, and bridge recovery
Env kill switch with raw-evidence retention, idempotent backfill, in-place dead-letter replay with reconciliation notes, command lease recovery on pull, ack terminal-state guards, and an operations runbook.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:45:00 -04:00
c19d0d05ee feat(ui): tactical operation primitives and role surfaces
Reusable dossier primitives (classification strips, brackets, readouts, provenance chips, notice states) plus server-rendered /operations overview, ledger drill-down, and AAR with permission-gated sections and an authorized reconciliation panel. Realtime refresh is enhancement only.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:44:53 -04:00
3c70160dfc feat(operations): readiness and zone pressure aggregates
Deterministic, explainable, advisory readiness aggregates per subject behind a permission gate, and mission-scoped zone pressure with read-time decay yielding route-risk and yield multipliers.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:53 -04:00
67adaa301c feat(operations): bounded economy effects and market repair
Link operation loot to the supply/demand ledger with faucet/drain caps and provenance-marked notes; repair buyListing to preflight locker credit before payment with a compensating refund on credit failure.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:53 -04:00
67c993ca10 feat(operations): mission reservations and deterministic settlement
Reserve personnel, vehicles, cargo, and budget with idempotent keys and exactly-once settlement; extraction effects preflight before any write so validated HQ deposits and locker credits are final and survive mission failure. Failure sweeps touch only unextracted reservations.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:47 -04:00
cc23742dd8 feat(operations): versioned operation event and effect ledger
Consume raw arma-sync-events into a validated, idempotent operation ledger: contract v1, reject/dead-letter with zero effects, effect keys with unique-constraint race handling, and a staged-activation flag hook. Registers the operations permission domains.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:40 -04:00
f35da021da docs: document production, modules, and compound slices
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:35 -04:00
d719c6b455 fix(intelligence): raise mission back-link above the hero overlay
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:35 -04:00
7b82da58e7 feat(map): zone label placement, political zone type, and authoring polish
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:35 -04:00
f8ce3f3dcc feat(logistics): timestamp shipment lifecycle with client relay
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:29 -04:00
7a0f997f70 feat(base): production, modules, adjacency auras, compounds, and crew speed
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-20 02:43:23 -04:00
235 changed files with 322998 additions and 704 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

@ -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 &quot;World Map Entry&quot; 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"
>
&bull;
</span>
)}
<span
className={`font-mono text-[10px] uppercase tracking-wider ${stat.cls}`}
>
{stat.label}
</span>
</Fragment>
))}
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div> </div>
); );
} }

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

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

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

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

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

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

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

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

View file

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

View file

@ -3576,3 +3576,4 @@
[data-radix-popper-content-wrapper] { [data-radix-popper-content-wrapper] {
z-index: 1300 !important; z-index: 1300 !important;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

@ -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.)',

View file

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

View file

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

View file

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

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

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

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&nbsp; UNIT FUNDS&nbsp;
{formatAmount(unitFundsTotal, currencyConfig)} <CurrencyAmount value={unitFundsTotal} currency={currencyConfig} />
</p> </p>
)} )}
</div> </div>

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View 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