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
# 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).
# 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
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
.DS_Store
.env
.env.*
.env.development
.env*.local
.env.prd
.env.stg
# generated version info (regenerated by predev/prebuild/postversion)
/.idea/*
!/.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
.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
/.logs/
/logs/
/media
# misc
/.next/
next-env.d.ts
# next.js
/node_modules
node_modules/
npm-debug.log*
/.omo/
/.opencode/
/.openchamber/
/.opencode/
/out/
*.pem
# personal shell alias notes
# Playwright
/playwright/.cache/
/.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
- **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`.
- **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).
- **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.
- **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 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`.
- **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.
- **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"`).
@ -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.
- **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).
- **Gate**: Game Rules globals `staffHiringEnabled` + `staffHiringDisabledMessage` disable all hiring when off.
- **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]`).
- **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`.
## Game Map System
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (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).
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
- **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` 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`.
- **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"`.
@ -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`.
- 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`.
- **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
@ -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.
**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.
**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.
- `.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`).
- **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

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) │
│ │ file) │ │ + volume │ │
│ │ + media │ └─────────────┘ │
│ │ volume │ │
│ │ volume │ │
│ └─────┬──────┘ │
│ │ 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.
- **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.
- **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).
> ⚠️ **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.
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.
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
@ -77,25 +78,25 @@ The Dockerfile pins Bun 1.3.11 (**build-time only**) and Node 22 alpine (runtime
## 5. Environment variables (per environment)
| 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>` |
| `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` |
| `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_NAME` | — | Display name (e.g. `Arma`) |
| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) |
| `EMAIL_PORT` | — | `587` |
| `EMAIL_USERNAME` | — | SMTP user |
| `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_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_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. |
| `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). |
| `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. |
| `DISCORD_NOTIFICATION_POLL_MS` | — | Notification bridge poll interval; default `20000`. |
| 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>` |
| `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` |
| `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_NAME` | — | Display name (e.g. `Arma`) |
| `EMAIL_HOST` | — | SMTP host (e.g. `smtp-relay.brevo.com`) |
| `EMAIL_PORT` | — | `587` |
| `EMAIL_USERNAME` | — | SMTP user |
| `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_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_ANNOUNCE_CHANNEL_ID` | — | Channel for `/announce`. |
| `DISCORD_STAFF_ROLE_IDS` | — | Comma-separated staff role IDs (staff-only `/announce`). |
| `DISCORD_ATTENDANCE_POLL_MS` | — | Attendance reconcile poll interval; default `60000`. |
| `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.
@ -121,13 +122,13 @@ The `game-tick` and `market-tick` Payload bins are registered on `payload.config
### 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 |
|---|---|---|
| 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` |
| Server presence | `* * * * *` (every minute) | `node node_modules/payload/bin.js server-tick` |
| 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` |
| 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` |
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
Each tick:
- 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.
- 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`).
In Coolify:
1. On the app service → **Persistent Storage → Add Path**.
2. Host path or named volume → mapped to `/app/media` in the container.
3. Coolify preserves this volume across deploys, **including rollbacks**.
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.
---
@ -193,7 +208,13 @@ Without this, every redeploy wipes user uploads.
Response shape:
```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.
> **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.
>
@ -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.
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).
- 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
| 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. |
| 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. |
| 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. |
| 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`. |
| 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. |
| 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. |
| 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. |
| 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`. |
---

View file

@ -144,7 +144,7 @@ ENV NODE_ENV=production \
HOSTNAME=0.0.0.0
# 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
# 1) Next.js standalone runtime (server.js + traced node_modules + .next).

View file

@ -32,6 +32,8 @@ services:
volumes:
# Persist Payload uploads between rebuilds locally.
- ./media:/app/media
# Map tile pyramids (gitignored; generated locally, uploaded in prod).
- ./public/tiles:/app/public/tiles
depends_on:
postgres:
condition: service_healthy
@ -63,4 +65,4 @@ services:
restart: unless-stopped
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
`constructionCompletesAt` has passed flip to `complete` and emit
`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`
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
close zoom, 4 m/px or finer); Save.
- **New zone**: click at least 3 vertices; set name, description, type
(water/land/territory/border), and styling (fill color, edge color, fill
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
Border are informational political boundaries (they never gate placement;
only water does) and default to amber 0.1 / violet 0.06 fill.
(water/land/territory/border/other), and styling (fill color, edge color,
fill opacity 0-1, edge width, name-label toggle); Save. Territory and
Country Border are informational political boundaries and Other is for
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,
resource, richness; Save.
- 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`
0.25 fill, land `#4ade80` 0.08 fill). Invalid hex is rejected at save time
(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
@ -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`) |
| `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]` =
[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",
"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.",
"license": "MIT",
"type": "module",

View file

@ -6,6 +6,15 @@ import { defineConfig, devices } from "@playwright/test";
*/
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.
*/
@ -53,5 +62,10 @@ export default defineConfig({
command: "pnpm dev",
reuseExistingServer: true,
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",
};
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() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
@ -25,6 +41,11 @@ export default async function HelpdeskPage() {
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
}
const { helpdeskEnabled } = await payload.findGlobal({ slug: "game-rules" });
if (!helpdeskEnabled) {
return <HelpdeskDisabled />;
}
const ticketsRes = await payload.find({
collection: "tickets",
sort: "-updatedAt",

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
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 { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
@ -16,7 +16,8 @@ import {
} from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
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 {
params: Promise<{ id: string }>;
@ -118,10 +119,9 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
})
.catch(() => ({ docs: [] }));
const bankAccount = (bankRes.docs[0] as BankAccount | undefined) ?? null;
const bankCurrency =
bankAccount && typeof bankAccount.currency === "object"
? (bankAccount.currency as Resource)
: null;
// Currency emblem/label is shared across all accounts (from Game Rules), so
// the bank balance renders the same emblem the banking surfaces show.
const currencyConfig = await getCurrencyConfig(payload);
// Missions involving this faction
const missionsRes = await payload
@ -365,7 +365,7 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
<ItemContent>
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold font-mono">
{formatAmount(bankAccount.balance ?? 0, bankCurrency)}
<CurrencyAmount value={bankAccount.balance ?? 0} currency={currencyConfig} />
</span>
<span className="text-xs text-muted-foreground">{bankAccount.name}</span>
{bankAccount.status !== "open" && (

View file

@ -257,7 +257,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
return (
<div className="flex flex-col">
<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
href="/intelligence/missions"
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 { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
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 { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
@ -29,6 +29,7 @@ import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
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)/).
// 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"]>;
let isAdmin = false;
let statisticsEnabled = true;
let helpdeskEnabled = true;
if (user) {
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
rank: true,
progression: {
awards: true,
scoreboard: true,
xp: true,
minigames: true,
},
const [
intelligenceQual,
logisticsQual,
profileRes,
experienceRes,
personalAccountRes,
gameRulesRes,
] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
rank: true,
progression: {
awards: true,
scoreboard: true,
xp: true,
minigames: true,
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
description: true,
requiredPoints: true,
},
}),
payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
},
limit: 1,
}),
]);
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
description: true,
requiredPoints: true,
},
}),
payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
},
limit: 1,
}),
payload.findGlobal({ slug: "game-rules" }),
]);
isIntelligence = intelligenceQual;
isLogistics = logisticsQual;
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
statisticsEnabled = gameRulesRes.statisticsEnabled ?? true;
helpdeskEnabled = gameRulesRes.helpdeskEnabled ?? true;
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs);
@ -149,6 +161,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
statisticsEnabled={statisticsEnabled}
helpdeskEnabled={helpdeskEnabled}
showCallsign={user.preferences?.display?.showCallsign ?? false}
canImpersonate={await isSuperuser(payload, user)}
preferences={user.preferences}
@ -184,6 +198,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
)}
{user && <GameTickRealtime />}
{user && <SessionWatchdog sessionExp={sessionExp} />}
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
{user && <MissionReminderToasts />}
{isLogistics && <ShipmentToasts />}
<Toaster />

View file

@ -9,6 +9,12 @@ import {
setLaborHeadcount as setLaborHeadcountAssignment,
upgradeStructure as upgradeStructureService,
} 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 { hasPermission } from "@/utils/access-control/hasPermission";
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(
structureId: number,
classificationId: number,

View file

@ -17,6 +17,8 @@ import {
import { maybeStartConstruction } from "@/lib/map/construction";
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
import { validateHexColor } from "@/lib/map/style";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
import type { ResourceNode } from "@/payload-types";
interface ActionResult {
success: boolean;
@ -46,6 +48,7 @@ export async function placeStructure(params: {
blueprintId: number;
name: string;
factionId?: number | null;
parentStructureId?: number | null;
x: number;
y: number;
}): 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([
payload.find({
collection: "map-zones",
@ -184,6 +208,7 @@ export async function placeStructure(params: {
map: mapId,
coordinates: point,
constructionStatus: "awaiting_materials",
parentStructure: parentStructureId,
},
});
@ -240,7 +265,7 @@ export interface MapZoneInput {
mapId: number;
name?: string | null;
description?: string | null;
type: "water" | "land" | "territory" | "border";
type: "water" | "land" | "territory" | "border" | "other";
resourceIds?: number[];
fillColor?: string | null;
strokeColor?: string | null;
@ -258,6 +283,7 @@ export interface MapNodeInput {
resourceIds: number[];
position: MapPoint;
richness?: number | null;
icon?: string | null;
}
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." };
}
const resourceIds = [...new Set(input.resourceIds)];
if (resourceIds.length === 0) {
return { success: false, error: "A node requires at least one resource." };
if (resourceIds.length > 0) {
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({
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." };
// An empty resource list makes the node a plain marker: no resource
// validation, and placement's resourcesInRange never matches it.
const icon = input.icon ?? "marker";
if (!NODE_ICON_OPTIONS.some((option) => option.value === icon)) {
return { success: false, error: "Unknown node icon." };
}
const nodeData = {
@ -446,6 +478,7 @@ export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
resources: resourceIds,
position: input.position,
richness: input.richness ?? 100,
icon: icon as ResourceNode["icon"],
};
if (editing) {

View file

@ -15,6 +15,8 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
import { advanceShipment } from "@/lib/shipping/lifecycle";
import type { Shipment } from "@/payload-types";
interface ShipmentDetailPageProps {
params: Promise<{ id: string }>;
@ -35,7 +37,7 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
const { id } = await params;
const payload = await getPayload({ config });
const shipment = await payload
let shipment = await payload
.findByID({
collection: "shipments",
id: Number(id),
@ -47,6 +49,27 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
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 =
typeof shipment.origin === "object" && shipment.origin !== null
? (shipment.origin as GameStructure)

View file

@ -9,6 +9,7 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance";
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { effectiveMaxMass } from "@/lib/base/modules";
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
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." };
}
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 =
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) {
return {
@ -301,10 +305,11 @@ export async function createShipment(params: {
}
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
const destMaxMass = effectiveMaxMass(destType, destination.installedModules);
if (destMaxMass && destMass > destMaxMass) {
return {
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 type { Faction, GameStructure, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
calculateStoredMass,
calculateUtilization,
getGridSlots,
} from "@/lib/logistics";
import { calculateStoredMass, calculateUtilization, getGridSlots } from "@/lib/logistics";
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 { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -22,11 +20,29 @@ export interface StructureDetailData {
gridSlots: number | null;
totalValue: number;
constructionMaterials: { name: string; required: number; delivered: number }[];
constructionCrew: { required: number; effective: number };
};
upgrade: {
targetName: string;
materials: { name: string; needed: number; have: number }[];
} | 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: {
structureStaffing: StructureStaffingOption;
staff: {
@ -76,8 +92,16 @@ export async function fetchStructureDetail(
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const moduleEffects = installedModuleEffects(
structureType,
(structure.installedModules ?? []).map((entry) => entry.name),
);
const utilization = calculateUtilization(
structureType,
storedResources,
moduleEffects.capacityMass,
);
const gridSlots = getGridSlots(structureType, moduleEffects.storageSlots);
const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
: null;
@ -194,6 +218,9 @@ export async function fetchStructureDetail(
limit: 100,
depth: 0,
});
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id,
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 {
structure,
blueprint: structureType,
description,
imageUrl,
derived: { currentMass, utilization, gridSlots, totalValue, constructionMaterials },
derived: {
currentMass,
utilization,
gridSlots,
totalValue,
constructionMaterials,
constructionCrew,
},
upgrade,
production,
modules,
staffing: {
structureStaffing,
staff,

View file

@ -10,6 +10,8 @@ import { StorageRules } from "@/components/frontend/storage/StorageRules";
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import { ProductionPanel } from "@/components/frontend/baseManagement/ProductionPanel";
import { SlotsPanel } from "@/components/frontend/baseManagement/SlotsPanel";
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
import {
calculateStoredMass,
@ -20,6 +22,8 @@ import {
} from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
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 { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
@ -71,9 +75,33 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const structureType = structure.type;
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 utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const moduleEffects = installedModuleEffects(
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 description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
@ -193,6 +221,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
limit: 100,
depth: 0,
});
const buildEfficiencyById = new Map(
classificationsRes.docs.map((doc) => [doc.id, doc.buildEfficiency ?? 1]),
);
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
id: doc.id,
name: doc.name,
@ -200,6 +231,20 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
defaultUnitSalary: doc.defaultUnitSalary ?? null,
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({
slug: "game-rules",
@ -434,6 +479,45 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)}
</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} />
<ConstructionPanel
@ -442,6 +526,55 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={structureType.constructionDurationMinutes ?? null}
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

View file

@ -7,6 +7,7 @@ import { isSuperuser } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { effectiveMaxMass } from "@/lib/base/modules";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { maybeStartConstruction } from "@/lib/map/construction";
@ -105,7 +106,7 @@ export async function addResource(
}
}
async function addResourceInternal(
export async function addResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
@ -150,7 +151,7 @@ async function addResourceInternal(
// Check capacity
const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass;
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
if (maxMass && currentMass + addedMass > maxMass) {
return {
@ -511,7 +512,7 @@ export async function transferResource(
// Check destination capacity
const destMass = calculateMass(toStructure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const destMaxMass = toStructure.type.maxCapacityMass;
const destMaxMass = effectiveMaxMass(toStructure.type, toStructure.installedModules);
if (destMaxMass && destMass + addedMass > destMaxMass) {
return {
@ -620,7 +621,7 @@ export async function placeResourceOnGrid(
const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass;
const maxMass = effectiveMaxMass(structureType, structure.installedModules);
if (maxMass && currentMass + addedMass > maxMass) {
return {
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 { getPayload } from "payload";
import { headers } from "next/headers";
import { Fragment } from "react";
import Link from "next/link";
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";
import { Map as MapIcon, MapPinIcon } from "lucide-react";
import type { Campaign, Faction, Map as MapDoc, Media } from "@/payload-types";
import {
easeStatClass,
isWorldMapEntry,
weatherStatClass,
weatherStatLabel,
worldMapScreenQuery,
} from "@/lib/map/screen";
export const metadata: Metadata = {
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 { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
const [mapsRes, canEdit, canPlace] = await Promise.all([
payload.find({
collection: "maps",
where: { isActive: { equals: true } },
sort: "name",
limit: 100,
depth: 0,
overrideAccess: true,
}),
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
]);
const mapsRes = await payload.find({
collection: "maps",
where: worldMapScreenQuery(),
sort: "name",
limit: 100,
depth: 1,
overrideAccess: true,
});
const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name }));
const maps = mapsRes.docs.filter((map) => isWorldMapEntry(map));
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 gap-2">
<MapIcon className="size-4 text-muted-foreground" />
@ -44,7 +51,104 @@ export default async function MapPage() {
</span>
</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>
);
}

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;
}
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() {
const payload = await getPayload({ config });
const { statisticsEnabled } = await payload.findGlobal({ slug: "game-rules" });
if (!statisticsEnabled) {
return <StatisticsDisabled />;
}
const [npcsRes, listingsRes] = await Promise.all([
payload.find({
collection: "game-npcs",

View file

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

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { asJson } from "@/lib/arma-bridge/json";
import { requireArmaServer } from "@/lib/arma-bridge/server";
import { acknowledgeCommand } from "@/lib/arma-bridge/commands";
export const runtime = "nodejs";
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 });
}
const commands = await payload.find({
collection: "arma-commands",
where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] },
limit: 1,
overrideAccess: true,
});
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,
const outcome = await acknowledgeCommand(payload, server.id, {
id: body.id,
success: body.success,
result: asJson(body.result, null),
error: typeof body.error === "string" ? body.error : undefined,
});
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 { requireArmaServer } from "@/lib/arma-bridge/server";
import { recoverExpiredCommands } from "@/lib/arma-bridge/commands";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
if (auth instanceof NextResponse) return auth;
const { payload, server } = auth;
// Expired leases (delivered, never acked) rejoin the queued pool.
await recoverExpiredCommands(payload, server.id);
const commands = await payload.find({
collection: "arma-commands",
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 { getPayload } from "payload";
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 type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
@ -132,9 +138,11 @@ export async function GET(req: NextRequest) {
constructionCompletesAt: structure.constructionCompletesAt ?? null,
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
storageUsedKg,
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
storageCapacityKg: blueprint ? effectiveMaxMass(blueprint, structure.installedModules) : null,
labelVisible: structure.labelVisible ?? null,
labelPriority: structure.labelPriority ?? null,
parentId: compoundParentId(structure),
compoundChildren: 0,
blueprintId: blueprint?.id ?? null,
blueprintName: blueprint?.name ?? 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 }[] =>
(Array.isArray(value) ? value : []).map((entry) =>
typeof entry === "object" && entry !== null && "id" in entry
@ -174,6 +202,7 @@ export async function GET(req: NextRequest) {
description: node.description ?? null,
resources: relEntries(node.resources),
richness: node.richness ?? null,
icon: node.icon ?? null,
}));
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.
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
- `Structures` `beforeChange`: emits `structure:resize`; blueprint "Staffing Defaults" group via `staffingFields.ts`

View file

@ -37,9 +37,11 @@ export const GameRules: GlobalConfig = {
},
{
name: "currencySymbol",
type: "text",
type: "upload",
relationTo: "media",
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 { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
import { createStaffingFields } from "./staffingFields";
import { createProductionFields } from "./productionFields";
import { createModuleFields } from "./moduleFields";
import { productionDefaultsFromBlueprint } from "@/lib/base/productionDefaults";
export const GameStructures: CollectionConfig = {
slug: "game-structures",
@ -28,6 +31,7 @@ export const GameStructures: CollectionConfig = {
overrideAccess: true,
});
const defaults = staffingDefaultsFromBlueprint(blueprint);
const productionDefaults = productionDefaultsFromBlueprint(blueprint);
return {
...data,
@ -35,6 +39,12 @@ export const GameStructures: CollectionConfig = {
? { laborRequirements: defaults.laborRequirements }
: {}),
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
...(data.productionInputs === undefined
? { productionInputs: productionDefaults.productionInputs }
: {}),
...(data.productionOutputs === undefined
? { productionOutputs: productionDefaults.productionOutputs }
: {}),
...(data.maintenanceCost === undefined
? { maintenanceCost: defaults.maintenanceCost }
: {}),
@ -127,7 +137,8 @@ export const GameStructures: CollectionConfig = {
type: "date",
label: "Construction Started At",
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"),
...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",
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.",
},
},
{
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",
type: "array",

View file

@ -52,6 +52,17 @@ export const LaborClassifications: CollectionConfig = {
type: "textarea",
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",
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 { 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 = {
slug: "technologies",
@ -9,9 +15,9 @@ export const Technologies: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: requirePermission("technologies:create"),
update: requirePermission("technologies:update"),
delete: requirePermission("technologies:delete"),
create: requireIntelligencePermission("technologies:create"),
update: requireIntelligencePermission("technologies:update"),
delete: requireIntelligencePermission("technologies:delete"),
read: requirePermission("technologies:read"),
},
fields: [
@ -24,6 +30,7 @@ export const Technologies: CollectionConfig = {
name: "approvalStatus",
type: "select",
label: "Approval Status",
access: approvalStatusFieldAccessConfig,
options: [
{ label: "In Progress", value: "in_progress" },
{ label: "Ready for Review", value: "ready_for_review" },
@ -32,18 +39,6 @@ export const Technologies: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" },
],
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,
admin: {
description:
@ -137,6 +132,10 @@ export const Technologies: CollectionConfig = {
{
name: "resources",
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: [
{
name: "resource",

View file

@ -1,6 +1,16 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
import {
approvalStatusFieldAccess,
requireApprovalPermission,
requireLogisticsPermission,
} from "@/utils/access-control/divisionAccess";
const approvalFieldAccess = {
create: requireApprovalPermission(),
update: requireApprovalPermission(),
};
const approvalStatusFieldAccessConfig = approvalStatusFieldAccess("assets");
export const Assets: CollectionConfig = {
slug: "assets",
@ -8,6 +18,11 @@ export const Assets: CollectionConfig = {
group: "Logistics",
useAsTitle: "name",
},
access: {
create: requireLogisticsPermission("assets:create"),
update: requireLogisticsPermission("assets:update"),
delete: requireLogisticsPermission("assets:delete"),
},
fields: [
{
name: "name",
@ -18,6 +33,7 @@ export const Assets: CollectionConfig = {
name: "approvalStatus",
type: "select",
label: "Approval Status",
access: approvalStatusFieldAccessConfig,
options: [
{ label: "In Progress", value: "in_progress" },
{ label: "Ready for Review", value: "ready_for_review" },
@ -26,15 +42,6 @@ export const Assets: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" },
],
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,
admin: {
description:
@ -643,14 +650,15 @@ export const Assets: CollectionConfig = {
label: "Auditing",
description: "Auditing and moderation for this Asset.",
access: {
read: requirePermission("assets:read"),
create: requirePermission("assets:create"),
update: requirePermission("assets:update"),
read: requireLogisticsPermission("assets:read"),
create: requireLogisticsPermission("assets:create"),
update: requireLogisticsPermission("assets:update"),
},
fields: [
{
name: "isLive",
type: "checkbox",
access: approvalFieldAccess,
admin: {
description:
'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 {
approvalStatusFieldAccess,
requireApprovalPermission,
requireLogisticsPermission,
} from "@/utils/access-control/divisionAccess";
const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
if (isDeveloper({ req })) {
@ -31,6 +37,18 @@ const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
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 = {
slug: "resources",
admin: {
@ -39,8 +57,9 @@ export const Resources: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
update: onlyIfNotPrimaryCurrency,
delete: onlyIfNotPrimaryCurrency,
create: requireLogisticsPermission("resources:create"),
update: logisticsWriteWithCurrencyGuard("resources:update"),
delete: logisticsWriteWithCurrencyGuard("resources:delete"),
},
fields: [
{
@ -118,6 +137,7 @@ export const Resources: CollectionConfig = {
name: "approvalStatus",
type: "select",
label: "Approval Status",
access: approvalStatusFieldAccessConfig,
options: [
{ label: "In Progress", value: "in_progress" },
{ label: "Ready for Review", value: "ready_for_review" },
@ -126,15 +146,6 @@ export const Resources: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" },
],
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,
admin: {
description:

View file

@ -3,6 +3,9 @@ import { isDeveloper } from "@/utils/access-control/isRole";
import type { Resource } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
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 = {
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.",
},
},
{
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",
type: "relationship",
@ -498,6 +512,17 @@ export const Structures: CollectionConfig = {
type: "group",
fields: createStaffingFields("defaults"),
},
{
label: "Production Recipe Defaults",
type: "group",
fields: createProductionFields("defaults"),
},
{
label: "Modules",
type: "group",
fields: createModuleFields("defaults"),
},
...createAuraFields(),
{
label: "Production & Function",

View file

@ -1,5 +1,11 @@
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 = {
slug: "vehicles",
@ -7,6 +13,11 @@ export const Vehicles: CollectionConfig = {
useAsTitle: "name",
group: "Logistics",
},
access: {
create: requireLogisticsPermission("vehicles:create"),
update: requireLogisticsPermission("vehicles:update"),
delete: requireLogisticsPermission("vehicles:delete"),
},
fields: [
{
label: "Identity",
@ -29,6 +40,7 @@ export const Vehicles: CollectionConfig = {
name: "approvalStatus",
type: "select",
label: "Approval Status",
access: approvalStatusFieldAccessConfig,
options: [
{ label: "In Progress", value: "in_progress" },
{ label: "Ready for Review", value: "ready_for_review" },
@ -37,15 +49,6 @@ export const Vehicles: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" },
],
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,
admin: {
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 { 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 = {
slug: "arma-sync-events",
@ -9,11 +12,27 @@ export const ArmaSyncEvents: CollectionConfig = {
afterChange: [
async ({ doc, req, operation }) => {
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") {
processPlayerKill(req.payload, doc).catch((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 { requirePermission } from "@/utils/access-control/hasPermission";
/**
* 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
* route, which loads them into the game at mission start.
*
* Written internally by the settlement lib (overrideAccess); the admin panel
* is read-only so balance changes always have a settlement behind them.
* Written internally by the settlement lib (overrideAccess); GMs can also
* adjust balances manually in the admin panel.
*/
export const RespawnTickets: CollectionConfig = {
slug: "respawn-tickets",
admin: { group: "Server", useAsTitle: "user", defaultColumns: ["user", "balance"] },
access: {
read: ({ req }) => !!req.user,
create: () => false,
update: () => false,
delete: () => false,
read: requirePermission("respawn-tickets:read"),
create: requirePermission("respawn-tickets:create"),
update: requirePermission("respawn-tickets:update"),
delete: requirePermission("respawn-tickets:delete"),
},
fields: [
{ name: "user", type: "relationship", relationTo: "users", required: true, unique: true, index: true },
@ -28,7 +29,7 @@ export const RespawnTickets: CollectionConfig = {
defaultValue: 0,
admin: {
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,
hasPermission,
isSuperuser,
canAccessAdminPanel,
} from "@/utils/access-control/hasPermission";
import { ensurePersonalAccount } from "@/lib/banking/index";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
@ -88,7 +89,10 @@ export const Users: CollectionConfig = {
},
slug: "users",
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"),
create: requirePermission("users:create"),
update: async ({ req, id, data }) => {

View file

@ -50,10 +50,11 @@ export const MapZones: CollectionConfig = {
{ label: "Land", value: "land" },
{ label: "Territory", value: "territory" },
{ label: "Country Border", value: "border" },
{ label: "Other", value: "other" },
],
admin: {
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,
admin: {
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.",
},
},
{
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)",
type: "group",
@ -71,7 +146,8 @@ export const Maps: CollectionConfig = {
{ label: "XYZ Tile Layer", value: "tiles" },
],
admin: {
description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.",
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 { requirePermission } from "@/utils/access-control/hasPermission";
import { validatePosition } from "@/lib/map/points";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
/**
* Map resource nodes (deposits, forests, wells, ...). Display + placement
@ -46,9 +47,21 @@ export const ResourceNodes: CollectionConfig = {
type: "relationship",
relationTo: "resources",
hasMany: true,
required: true,
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,
} from "@/components/ui/item";
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";
const accountTypeMeta = {
@ -85,10 +86,10 @@ export function AccountCard({
</ItemDescription>
<div className="mt-1">
<span className="text-lg font-semibold font-mono">
{formatAmount(
account.balance ?? 0,
currencyConfig.resourceName ? currencyConfig : currency,
)}
<CurrencyAmount
value={account.balance ?? 0}
currency={currencyConfig.resourceName ? currencyConfig : currency}
/>
</span>
</div>
</ItemContent>

View file

@ -4,7 +4,8 @@ import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import type { BankAccount, LedgerEntry } from "@/payload-types";
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 { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
@ -72,10 +73,10 @@ export function AccountDetail({
Balance
</p>
<p className="text-2xl font-semibold font-mono">
{formatAmount(
account.balance ?? 0,
currencyConfig.resourceName ? currencyConfig : currency,
)}
<CurrencyAmount
value={account.balance ?? 0}
currency={currencyConfig.resourceName ? currencyConfig : currency}
/>
</p>
</div>
</div>

View file

@ -8,7 +8,8 @@ import { AccountCard } from "@/components/frontend/banking/AccountCard";
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
import { WalletHero } from "@/components/frontend/banking/WalletHero";
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";
interface BankingOverviewProps {
@ -126,7 +127,7 @@ export function BankingOverview({
{isManager && (
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
UNIT FUNDS&nbsp;
{formatAmount(unitFundsTotal, currencyConfig)}
<CurrencyAmount value={unitFundsTotal} currency={currencyConfig} />
</p>
)}
</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 type { BankAccount, Resource } from "@/payload-types";
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 {
type AccountStatus,
accountStatusBase,
@ -120,10 +121,10 @@ export function WalletHero({
<div className="flex flex-col gap-1">
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
{formatAmount(
account.balance ?? 0,
currencyConfig.resourceName ? currencyConfig : currency,
)}
<CurrencyAmount
value={account.balance ?? 0}
currency={currencyConfig.resourceName ? currencyConfig : currency}
/>
</p>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
LAST ACTIVITY {lastActivity}

View file

@ -15,6 +15,7 @@ interface ConstructionPanelProps {
completesAt: string | null;
durationMinutes: number | null;
materials: ConstructionMaterial[];
crew?: { required: number; effective: number } | null;
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
@ -48,6 +49,7 @@ export function ConstructionPanel({
completesAt,
durationMinutes,
materials,
crew,
}: ConstructionPanelProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
@ -62,6 +64,30 @@ export function ConstructionPanel({
const allDelivered = materials.every((material) => material.delivered >= material.required);
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 (
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
@ -82,10 +108,7 @@ export function ConstructionPanel({
<span>{pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-400"
style={{ width: `${pct}%` }}
/>
<div className="h-full rounded-full bg-blue-400" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
<ConstructionEta completesAt={completesAt} />
@ -100,6 +123,8 @@ export function ConstructionPanel({
</p>
)}
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
<div className="flex flex-col gap-1.5">
{materials.map((material) => {
const met = material.delivered >= material.required;

View file

@ -1,7 +1,8 @@
"use client";
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 { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
@ -51,7 +52,7 @@ export function EmploymentCard({
{employment.positionType} · {employment.structureName}
</p>
<p className="font-mono text-sm tabular-nums">
{formatAmount(employment.salary, currencyConfig)}/tick
<CurrencyAmount value={employment.salary} currency={currencyConfig} />/tick
</p>
</div>
) : 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 { Label } from "@/components/ui/label";
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 { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
@ -239,7 +240,7 @@ export function StaffRoster({
</CardDescription>
{structure.maintenanceCost > 0 && (
<p className="text-sm text-muted-foreground">
Maintenance: {formatAmount(structure.maintenanceCost, currencyConfig)}/tick
Maintenance: <CurrencyAmount value={structure.maintenanceCost} currency={currencyConfig} />/tick
</p>
)}
</CardHeader>
@ -262,7 +263,7 @@ export function StaffRoster({
</div>
<div className="flex items-center gap-3">
<span className="shrink-0 font-mono text-sm tabular-nums">
{formatAmount(member.salary, currencyConfig)}/tick
<CurrencyAmount value={member.salary} currency={currencyConfig} />/tick
</span>
{canManage && (
<Button
@ -333,12 +334,8 @@ export function StaffRoster({
{crew.requiredHeadcount} required · max {crew.maxHeadcount}
</p>
<p className="font-mono text-sm tabular-nums text-muted-foreground">
{formatAmount(crew.unitSalary, currencyConfig)}/tick each ·{" "}
{formatAmount(
crew.unitSalary * crew.headcount,
currencyConfig,
)}
/tick total
<CurrencyAmount value={crew.unitSalary} currency={currencyConfig} />/tick each ·{" "}
<CurrencyAmount value={crew.unitSalary * crew.headcount} currency={currencyConfig} />/tick total
</p>
</div>
</div>

View file

@ -72,6 +72,8 @@ export function AppSidebar({
adminUrl,
hasIntelligence = true,
hasLogistics = true,
statisticsEnabled = true,
helpdeskEnabled = true,
showCallsign = false,
canImpersonate = false,
preferences,
@ -86,6 +88,8 @@ export function AppSidebar({
adminUrl?: string;
hasIntelligence?: boolean;
hasLogistics?: boolean;
statisticsEnabled?: boolean;
helpdeskEnabled?: boolean;
showCallsign?: boolean;
canImpersonate?: boolean;
preferences?: NonNullable<User["preferences"]>;
@ -119,6 +123,19 @@ export function AppSidebar({
}
}, [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 (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
@ -153,11 +170,12 @@ export function AppSidebar({
{hasIntelligence && (
<NavCore label="Intelligence" items={data.navIntelligence} />
)}
<NavCore label="Operations" items={data.navOperations} />
{hasLogistics && (
<NavCore label="Infrastructure" items={data.navLogistics} />
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
)}
<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="Personal" items={data.navPersonal} />
{/*<NavProjects projects={data.projects} />*/}

View file

@ -18,6 +18,7 @@ import {
export function NavCore({
label,
items,
expandDefaultOpen = true,
}: {
label: string;
items: {
@ -25,12 +26,14 @@ export function NavCore({
url: string;
icon: LucideIcon;
isActive?: boolean;
defaultOpen?: boolean;
items?: {
title: string;
url: string;
icon?: LucideIcon;
}[];
}[];
expandDefaultOpen?: boolean;
}) {
if (items.length === 0) return null;
@ -44,7 +47,7 @@ export function NavCore({
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
defaultOpen={item.isActive || (item.defaultOpen === true && expandDefaultOpen !== false)}
className="group/collapsible"
>
<SidebarMenuItem>

View file

@ -5,13 +5,16 @@ import {
BookOpen,
Boxes,
Building2,
ClipboardList,
Car,
Contact,
Crosshair,
Flag,
Kanban,
LifeBuoy,
Map,
Package,
Radar,
ScrollText,
Search,
ShoppingBag,
@ -23,10 +26,13 @@ import {
} from "lucide-react";
export const sidebarData = {
// World Map hidden until the feature ships; the /map route and all of its
// functionality stay live for direct-URL access. Restore with:
// [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import).
navMap: [],
navMap: [
{
title: "World Map",
url: "/map",
icon: Map,
},
],
navHelpdesk: [
{
title: "Helpdesk",
@ -66,11 +72,29 @@ export const sidebarData = {
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: [
{
title: "Assets",
url: "",
icon: Boxes,
defaultOpen: true,
items: [
{
title: "Structures",
@ -93,6 +117,7 @@ export const sidebarData = {
title: "Operations",
url: "",
icon: Package,
defaultOpen: true,
items: [
{
title: "Shipments",
@ -104,12 +129,18 @@ export const sidebarData = {
url: "/logistics/game-vehicles",
icon: Car,
},
{
title: "Supply Boxes",
url: "/logistics/supply-boxes",
icon: Boxes,
},
],
},
{
title: "Personnel",
url: "",
icon: Users,
defaultOpen: true,
items: [
{
title: "Contacts",

View file

@ -2,7 +2,8 @@ import Link from "next/link";
import type { MarketListing, MarketNegotiation } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
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 {
readonly negotiations: MarketNegotiation[];
@ -36,7 +37,7 @@ export function OpenNegotiations({
// NegotiationPanel on the market page).
if (negotiations.length === 0) return null;
const fmt = (n: number) => formatAmount(n, currencyConfig);
const fmt = (n: number) => <CurrencyAmount value={n} currency={currencyConfig} />;
return (
<Item variant="outline">

View file

@ -2,7 +2,8 @@ import Link from "next/link";
import type { BankAccount } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
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";
interface WalletWidgetProps {
@ -44,7 +45,7 @@ export function WalletWidget({ account, currencyConfig }: WalletWidgetProps) {
</span>
</div>
<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>
</Link>
</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> = {
concept: "text-muted-foreground",
inProgress: "text-blue-400",
completed: "text-emerald-400",
inProgress: "text-emerald-400",
completed: "text-blue-400",
};
export function CampaignCard({ campaign, size = "lg" }: CampaignCardProps) {

View file

@ -12,6 +12,7 @@ import {
} from "@/components/ui/item";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
import { getShipmentStatus, isShipmentActive } from "@/lib/shipping/client";
interface ShipmentCardProps {
shipment: Shipment;
@ -47,7 +48,8 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : 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 totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
@ -71,11 +73,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
</ItemTitle>
</div>
<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"
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
: effectiveStatus === "arrived"
? "Arriving"
: effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
</span>
</ItemHeader>
<ItemContent>
@ -106,11 +110,15 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
</div>
</ItemContent>
<ItemDescription>
{shipment.status === "completed"
{effectiveStatus === "completed"
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
: shipment.status === "cancelled"
: effectiveStatus === "cancelled"
? "Cancelled"
: (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))}
: effectiveStatus === "arrived"
? "Arriving"
: effectiveStatus === "in_transit" || effectiveStatus === "dispatched"
? "In transit"
: effectiveStatus}
</ItemDescription>
<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 file

@ -5,13 +5,8 @@ import type { Shipment } from "@/payload-types";
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
import { Button } from "@/components/ui/button";
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
import {
CheckCircleIcon,
ClockIcon,
ListFilterIcon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
import { CheckCircleIcon, ClockIcon, ListFilterIcon, TruckIcon, XCircleIcon } from "lucide-react";
import { isShipmentActive } from "@/lib/shipping/client";
const statusFilters = [
{ label: "All", value: null },
@ -21,8 +16,6 @@ const statusFilters = [
{ label: "Stranded", value: "stranded" },
];
const activeStatuses = ["dispatched", "in_transit", "pending"];
interface ShipmentsListProps {
shipments: Shipment[];
}
@ -76,7 +69,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) {
const filtered = useMemo(() => {
if (!filter) return shipments;
if (filter === "active") {
return shipments.filter((s) => activeStatuses.includes(s.status));
return shipments.filter((s) => isShipmentActive(s));
}
return shipments.filter((s) => s.status === 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,
} from "@/app/(frontend)/logistics/map/actions";
import type { MapPoint } from "@/lib/map/points";
import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons";
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
export type AuthoringKind = "road" | "zone" | "node" | null;
@ -62,8 +63,8 @@ export interface MapAuthoring {
setZoneName: (value: string) => void;
zoneDescription: string;
setZoneDescription: (value: string) => void;
zoneType: "water" | "land" | "territory" | "border";
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
zoneType: "water" | "land" | "territory" | "border" | "other";
setZoneType: (value: "water" | "land" | "territory" | "border" | "other") => void;
zoneLabelVisible: boolean;
setZoneLabelVisible: (value: boolean) => void;
fillColor: string;
@ -80,6 +81,8 @@ export interface MapAuthoring {
setNodeDescription: (value: string) => void;
resourceIds: number[];
toggleResourceId: (id: number) => void;
nodeIcon: string;
setNodeIcon: (value: string) => void;
zoneResourceIds: number[];
toggleZoneResourceId: (id: number) => void;
richness: string;
@ -112,7 +115,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
const [zoneName, setZoneName] = 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 [fillColor, setFillColor] = useState("");
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
@ -121,6 +126,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [nodeName, setNodeName] = useState("");
const [nodeDescription, setNodeDescription] = useState("");
const [resourceIds, setResourceIds] = useState<number[]>([]);
const [nodeIcon, setNodeIcon] = useState("marker");
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
const [richness, setRichness] = useState("100");
@ -210,6 +216,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setNodeName(node.name);
setNodeDescription(node.description ?? "");
setResourceIds(node.resources.map((entry) => entry.id));
setNodeIcon(node.icon ?? "marker");
setRichness(String(node.richness ?? 100));
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,
mapId,
name: nodeName.trim(),
icon: nodeIcon,
description: nodeDescription.trim() || null,
resourceIds,
position: draftPoints[0],
@ -328,6 +336,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
nodeName,
nodeDescription,
resourceIds,
nodeIcon,
zoneResourceIds,
richness,
onSaved,
@ -335,9 +344,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const roadReady = kind !== "road" || draftPoints.length >= 2;
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
const nodeReady =
kind !== "node" ||
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
const nodeReady = kind !== "node" || (draftPoints.length === 1 && nodeName.trim().length > 0);
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
return {
@ -385,6 +392,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setNodeDescription,
resourceIds,
toggleResourceId,
nodeIcon,
setNodeIcon,
zoneResourceIds,
toggleZoneResourceId,
richness,
@ -711,7 +720,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<Select
value={fields.zoneType}
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">
@ -722,6 +731,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<SelectItem value="land">Land</SelectItem>
<SelectItem value="territory">Territory</SelectItem>
<SelectItem value="border">Country Border</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</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"
/>
</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
label="Resources"
selected={fields.resourceIds}

View file

@ -21,15 +21,14 @@ import {
import Link from "next/link";
import { Hammer, Pencil, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 {
findSnap,
segmentMidpoints,
@ -50,9 +49,10 @@ import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/l
import { PlacementDialog } from "./PlacementDialog";
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
import { StructureDetailDrawer } from "./StructureDetailDrawer";
import { nodeIconOption } from "@/lib/map/nodeIcons";
export interface MapClientProps {
maps: { id: number; name: string }[];
map: { id: number; name: string };
canEdit: 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
// pathOptions by reference, and a fresh object literal every render forces a
// 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 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, 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> = {
@ -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>`;
}
const nodeIcon = 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>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
});
const nodeIconCache = new Map<string, L.DivIcon>();
function nodeIconFor(icon: string | null | undefined): L.DivIcon {
const key = icon ?? "marker";
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>();
@ -705,6 +720,14 @@ function StructurePopup({
? `${Math.round(structure.storageUsedKg).toLocaleString()} / ${structure.storageCapacityKg.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">
@ -872,6 +895,8 @@ const StructureLayer = memo(function StructureLayer({
ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials";
} else if ((structure.compoundChildren ?? 0) > 0) {
ribbonMeta = `Compound: ${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`;
} else {
ribbonMeta = structure.blueprintName ?? "Structure";
}
@ -1125,8 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) {
);
}
export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
const [selectedMapId, setSelectedMapId] = useState<number | null>(maps[0]?.id ?? null);
export function MapClient({ map, canEdit, canPlace }: MapClientProps) {
const selectedMapId = map.id;
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [toggles, setToggles] = useState<LayerToggles>({
@ -1284,16 +1309,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
];
}, [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) =>
setToggles((current) => ({ ...current, [key]: !current[key] }));
@ -1487,7 +1502,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
zone.name && zone.labelVisible !== false ? (
<Marker
key={`zone-label-${zone.id}`}
position={toLatLng(polygonCentroid(zone.points))}
position={toLatLng(zoneLabelPoint(zone.points) ?? polygonCentroid(zone.points))}
icon={zoneLabelIcon(zone.name)}
interactive={false}
/>
@ -1499,7 +1514,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
{features && toggles.nodes && (
<>
{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
direction="top"
offset={[0, -14]}
@ -1518,13 +1533,15 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
<Popup>
<div className="text-sm">
<div className="font-semibold">{node.name}</div>
<div className="text-xs text-white/60">
{node.resources.map((entry) => entry.name).join(", ") || "No resources"}
</div>
{node.resources.length > 0 ? (
<div className="text-xs text-white/60">
Resources: {node.resources.map((entry) => entry.name).join(", ")}
</div>
) : null}
{node.description && (
<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>
)}
{canEdit && mode === "edit" && (
@ -1621,7 +1638,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
<Polygon
positions={authoring.draftPoints.map(toLatLng)}
pathOptions={{
color: authoring.fields.zoneType === "water" ? "#38bdf8" : "#4ade80",
color: ZONE_PATH_OPTIONS[authoring.fields.zoneType].color ?? "#4ade80",
fillOpacity: 0.1,
dashArray: "4 6",
}}
@ -1647,26 +1664,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
</span>
</div>
<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 }) => (
<Button
key={key}

View file

@ -66,8 +66,22 @@ export function PlacementDialog({
const [blueprintId, setBlueprintId] = useState<string>("");
const [name, setName] = useState("");
const [factionId, setFactionId] = useState<string>("none");
const [parentStructureId, setParentStructureId] = useState<string>("none");
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(() => {
if (!open) return;
let cancelled = false;
@ -196,6 +210,7 @@ export function PlacementDialog({
setBlueprintId("");
setName("");
setFactionId("none");
setParentStructureId("none");
};
const submit = async () => {
@ -207,6 +222,7 @@ export function PlacementDialog({
blueprintId: blueprint.id,
name,
factionId: factionId === "none" ? null : Number(factionId),
parentStructureId: parentStructureId === "none" ? null : Number(parentStructureId),
x: point[0],
y: point[1],
});
@ -287,6 +303,30 @@ export function PlacementDialog({
/>
</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 && (
<div className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
<div>

View file

@ -10,15 +10,12 @@ import { StructureStorageView } from "@/components/frontend/storage/StructureSto
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
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 type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
import { useGameTick } from "@/hooks/useGameTick";
import {
BoxIcon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import { BoxIcon, CoinsIcon, MapPinIcon, WrenchIcon } from "lucide-react";
import {
Item,
ItemContent,
@ -92,7 +89,11 @@ export function StructureDetailDrawer({
<div className="flex flex-col gap-2">
{data.imageUrl && (
<div className="relative overflow-hidden rounded-none border border-white/15">
<img src={data.imageUrl} alt={structure.name} className="h-36 w-full object-cover" />
<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>
)}
@ -145,7 +146,9 @@ export function StructureDetailDrawer({
</ItemHeader>
<ItemContent>
<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>
</div>
</ItemContent>
@ -213,6 +216,25 @@ export function StructureDetailDrawer({
completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={blueprint.constructionDurationMinutes ?? null}
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

View file

@ -18,7 +18,8 @@ import {
} from "@/components/ui/item";
import { getAssetTypeLabel } from "@/lib/locker";
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 { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
import { Countdown } from "@/components/frontend/market/Countdown";
@ -156,7 +157,7 @@ export function ListingCard({
</ItemDescription>
<div className="flex items-center justify-between gap-2 w-full">
<span className="text-sm font-semibold font-mono">
{formatAmount(listing.price ?? 0, currencyConfig)} / unit
<CurrencyAmount value={listing.price ?? 0} currency={currencyConfig} /> / unit
</span>
<span className="text-xs text-muted-foreground">
{stock > 1 && (
@ -164,7 +165,7 @@ export function ListingCard({
{selectedQty}/{stock} ·{" "}
</>
)}
total {formatAmount(selectedTotal, currencyConfig)}
total <CurrencyAmount value={selectedTotal} currency={currencyConfig} />
</span>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}

View file

@ -17,6 +17,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import {
acceptMarketOffer,
cancelMarketOffer,
@ -239,7 +240,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
<DialogTitle>Negotiate: {assetName}</DialogTitle>
<DialogDescription>
{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>
</DialogHeader>
@ -282,7 +283,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
)}
<p className="text-xs text-muted-foreground">
{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>
{error && <p className="text-sm text-red-400">{error}</p>}
</>
@ -291,7 +292,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
{outcome?.kind === "pending" && (
<div className="flex flex-col gap-2">
<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{" "}
{isVendor ? "vendor" : "seller"} to respond.
</p>
@ -303,10 +304,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
<div className="flex flex-col gap-3">
<p className="text-sm">
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{" "}
<span className="font-mono">
{fmt((negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock))}
<CurrencyAmount value={(negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
</span>
.
</p>
@ -330,10 +331,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
Deal closed
</Badge>
<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{" "}
<span className="font-mono">
{fmt(outcome.price * (negotiation?.quantity ?? stock))}
<CurrencyAmount value={outcome.price * (negotiation?.quantity ?? stock)} currency={currencyConfig} />
</span>
. The items are in your locker.
</p>

View file

@ -14,7 +14,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
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 { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
@ -79,7 +80,7 @@ function HistoryRow({
}) {
const [expanded, setExpanded] = useState(false);
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 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 { Input } from "@/components/ui/input";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
import { CurrencyAmount } from "@/components/frontend/banking/CurrencyAmount";
import {
acceptMarketOffer,
cancelMarketOffer,
@ -131,7 +132,7 @@ function NegotiationRow({
</div>
<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">
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
{theirProposal
@ -143,7 +144,7 @@ function NegotiationRow({
<>
{" · total "}
<span className="font-mono">
{fmt((negotiation.amount ?? 0) * negotiation.quantity)}
<CurrencyAmount value={(negotiation.amount ?? 0) * negotiation.quantity} currency={currencyConfig} />
</span>
</>
) : 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