1
0
Fork 0
polaris-task-force/AGENTS.md
Z8MB1E 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

80 KiB
Raw Permalink Blame History

AGENTS.md — Polaris Task Force

Sub-AGENTS.md files (read these for domain-specific context):

  • src/components/frontend/AGENTS.md — Frontend component patterns, server/client split, Item primitive
  • src/collections/AGENTS.md — Payload collection map, RBAC, hooks, relationship graph
  • src/lib/AGENTS.md — Shared business logic, domain services, dependency graph
  • src/utils/AGENTS.md — Access control layers, event log, utilities
  • src/bot/AGENTS.md — Discord bot architecture, commands, services

What this is

Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via @payloadcms/db-postgres + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, lucide icons). Dark-themed frontend.

Package manager

Bun is the primary package manager (bun.lock; bunfig.toml is empty). package.json declares packageManager: pnpm@9.15.4 and several scripts internally invoke pnpm (db, test, test:e2e) — always invoke them via bun run <script>, which still works; just don't edit those scripts to use bun directly without testing. pnpm-lock.yaml also exists.

Essential commands

bun install               # install deps
bun run dev               # dev server (webpack, localhost:3000)
bun run devsafe           # clears .next cache then dev
bun run build             # production build (--max-old-space-size=8000, webpack)
bun run lint              # BROKEN under Next 16 — see typecheck below
bun run test              # runs test:int then test:e2e sequentially
bun run test:int          # vitest integration tests only
bun run test:e2e          # playwright e2e tests only
bun run db                # drizzle-kit wrapper (e.g. bun run db migrate)
bun run generate:types    # regenerates payload-types.ts
bun run generate:importmap # regenerates payload admin importMap

Type checking / linting

bun run lint is broken under Next 16 — next lint was removed and errors out with Invalid project directory provided, no such directory: .../lint. Don't rely on it. The reliable typecheck is:

npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"

Known pre-existing errors (not yours, don't widen scope to fix them): src/collections/users/Users.ts:57, src/tools/seed/backfillProfiles.ts:68, src/tools/seed/seedProfiles.ts:19 — all the same Payload profiles-create overload mismatch. Any other error introduced by your changes is yours.

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

Dev server & testing protocol

  • If a dev server is already running when you want to test (port 3000 in use, or Next reports "Another next dev server is already running"), ask the user whether you should kill the existing server and start a fresh one before doing anything. A stale .next/dev/devserver.lock can also block startup — offer to clear it as part of the same question.
  • If the user says no, do not start a dev server and do not attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back.
  • Stale dev processes: killing the process is not always enough; remove .next/dev/devserver.lock before restarting.
  • For browser/UI verification, make a reasonable attempt with Playwright. If the browser tooling is unavailable or repeatedly unreliable, stop rather than spending excessive effort on it and defer the manual UI check to the user.

Agent debugging SOP (read before fixing any bug)

These rules were extracted from a real multi-failure debugging session — the full story, including every wrong turn, is in docs/case-studies/login-return-url.md. Follow them literally; each one exists because skipping it produced a wrong fix.

  1. Trace the real render path before writing any fix. State in your reply: which layout wraps the failing route, what each conditional renders, and which component actually owns the navigation or mutation you're changing. If a layout conditionally replaces {children} (the (frontend) guest gate renders <LandingPage /> instead of the page), then code inside those children — including redirect() calls — is dead code for that branch and never runs.
  2. Treat every framework API as a hypothesis. Before calling a header, hook, or helper, confirm it exists and returns what you expect — against the running app or current docs. An observed default value (e.g. ?next=%2F when you expected %2Fflappy) means the data source was empty and your fallback leaked through — not that the data got mangled.
  3. Two failed fixes = your mental model is wrong. Do not add a third fallback layer on top of a failing approach. Stop editing, re-read the flow, find the wrong assumption.
  4. Ask "which component actually knows this fact?" The current URL is known client-side (usePathname()), not in server layouts. Attach data where it is known, at the point the navigation happens — not where it is merely convenient to compute.
  5. Match producer and consumer. If you emit a query param (next), confirm the consumer reads that exact name (returnTo). Mismatches fail silently.
  6. Verify end-to-end before reporting done. curl the failing route as a guest, follow the redirect, hit the API, check the authed route (a copy-pasteable matrix is in the case study). "Should work" is not verification.
  7. Restate before acting. For non-trivial changes, output: assumptions → plan → verification command. Then implement.

Next.js 16 hard rules

Break these and you get runtime errors or silently dead code:

  • searchParams and params props are Promises in pages/layouts. await them before any property access. Error if violated: ``Route used searchParams.x. searchParams is a Promise and must be unwrapped with await or `React.use()```.
  • Server components (layouts/pages) cannot mutate cookies. cookies() from next/headers is read-only there; calling .set() throws Cookies can only be modified in a Server Action or Route Handler. Writing cookies is only legal in Server Actions and Route Handlers.
  • headers.get("x-invoke-path") does not reliably contain the current pathname in layouts. Never build redirect logic on it. Reliable sources of the current path: usePathname() (client components) and the request object (middleware / Route Handlers).
  • redirect() narrows poorly across control flow. After an if (!user) redirect(...) early exit, TypeScript may still see user as nullable when the narrowing crosses a closure boundary — keep an explicit truthy branch around later user usage.
  • Sanitize user-controllable redirect targets (open-redirect guard): accept only values starting with / and reject // (protocol-relative URLs). Working example: safeReturnTo in src/app/login/page.tsx.

Generated files — never edit manually

  • src/payload-types.ts — regenerated by bun run generate:types
  • src/payload-generated-schema.ts — regenerated by Payload db-schema generation
  • src/app/(payload)/admin/importMap.js — regenerated by bun run generate:importmap
  • src/app/(payload)/layout.tsx — auto-generated by Payload

Path aliases

  • @/* → ./src/*
  • @payload-config → ./src/payload.config.ts

App structure

  • src/app/(frontend)/ — public-facing pages (dashboard, logistics, home). Layout has sidebar + auth check (guests get LandingPage, no shell).
  • src/app/login/ — standalone login route, intentionally OUTSIDE the gated (frontend) group. Own dark <html> layout that reuses (frontend)/styles.css.
  • src/app/(payload)/ — Payload admin panel and API routes. Auto-generated layout.
  • src/app/my-route/ — example custom API route.

Collections (Payload CMS)

Organized by domain under src/collections/:

  • users/ — Users (auth, username login), Ranks, Profiles (incl. minigame stats), Awards, Qualifications, Assignments, AssignmentTransfers, Experience, Evaluations, Roles (dynamic RBAC), UserNotifications
  • intelligence/ — Missions, MissionAttendances, Campaigns, Factions, Technologies
  • logistics/ — Assets, Resources, Vehicles, Structures (with staffing defaults), Shipments
  • banking/ — BankAccounts, BankTransactions, LedgerEntries
  • market/ — MarketListings, MarketNegotiations, MarketState (supply/demand ledger, one doc per tradeable resource/asset)
  • locker/ — LockerStorages, Loadouts
  • world/ — Maps, NarrativeEvents
  • server/ — MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
  • game/ — GameRules (global, incl. currency display names + staff hiring flags), GameStructures, GameHardResources, GameEventLogs, GameVehicles, GameNpcs, NpcStaffing, LaborClassifications (+ shared staffingFields.ts factory)
  • projects/ — Projects, Labels, Releases, Sprints
  • tickets/ — Tickets, TicketVotes
  • wiki/ — WikiPages, WikiRevisions, WikiTemplates
  • top-level — Media.ts (image uploads, used by wiki), Shims.ts (audience-targeted content)

Access control helpers live in src/utils/access-control/ (isRole, hasRoles). Full RBAC lives in src/permissions/index.ts + the Roles collection. Roles: guest, user, admin, developer.

Event Log System

src/collections/game/GameEventLogs.ts — game-event-logs collection.

  • Schema: system (boolean, default true — system-generated vs GM/narrative), timestamp, type (text/indexed), message (human-readable), actor (→ users), structure (→ game-structures), targetCollection (select — pick from known collections), targetId (number, polymorphic ref), data (JSON blob).
  • Access: any logged-in user can read/create. Update restricted to admins/developers. Delete restricted to developers.
  • Emit: emitGameEvent(payload, { type, message, actor?, structure?, targetCollection?, targetId?, data? }) in src/utils/event-log/emit.ts. Silently catches errors (fire-and-forget). Always sets system: true.
  • Types: EventTypes constants in src/utils/event-log/eventTypes.ts — add new event categories there without touching the collection. Using these constants gives TypeScript narrowing on the type field.
  • Wiring: Server actions in actions.ts emit events after successful mutations. GameStructures afterChange hook catches admin-panel storage edits. Structures beforeChange hook emits structure:resize.
  • targetCollection options: game-structures, structures, resources, assets, vehicles, game-vehicles, game-npcs, npc-staffing, factions, missions, campaigns, users, technologies, maps, shipments, bank-accounts, bank-transactions, ledger-entries, market-listings, market-negotiations, locker-storages, loadouts, evaluations, tickets, wiki-pages, wiki-revisions, wiki-templates, game-servers, assignment-transfers. If a new collection is added that should be a valid target, add an option here.
  • Narrative events: admins/developers can create entries manually in the Payload admin panel with system: false for GM-written narrative events. These are visually distinct in the UI (amber accent, book icon).

UI

EventLedger component (src/components/frontend/storage/EventLedger.tsx):

  • Queries the REST API (/api/game-event-logs) for a given structureId and renders a scrollable feed.
  • Accepts optional refreshKey prop — parent passes an incrementing counter to trigger re-fetch after mutations.
  • Auto-polls every 30s for background updates.
  • Shows actor name prefix ("You" for current user, username for others).
  • Filter by event type via multi-select shadcn DropdownMenuCheckboxItem.
  • Narrative events (system: false) styled with amber border + book icon.
  • On re-fetch, preserves existing entries while loading to avoid flicker.

Adding event logging for new actions

  1. Add event type constant to src/utils/event-log/eventTypes.ts if it doesn't exist yet.
  2. If the target collection slug isn't in the TARGET_COLLECTIONS array in GameEventLogs.ts, add it.
  3. Call emitGameEvent(payload, { type: EventTypes.xxx, message: "...", ... }) after the successful mutation in the server action.
  4. EventLedger will pick it up automatically if it's scoped to the same structureId.

Non-structure events

The EventLedger currently filters by structure.id. For event logs scoped to other entities (e.g., faction finance ledger), a new ledger variant would need to be created that queries by targetCollection + targetId instead.

Shipments & game tick

Shipping simulation (src/collections/logistics/Shipments.ts, src/scripts/, src/lib/shipping.ts, src/lib/distance.ts):

  • 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").
  • Economy consumption (P1d): market-tick prices new NPC listings from the ledger — price = max(1, round(baseBuyPrice × priceModifier)) (neutral 1 when no market-state doc) then applyNpcPriceModifier (vendor's own modifier); the old autoPrice ±15% randomness is removed. Restock quantity scales down under shortage via scaleQuantityForShortage (bounded to a 0.25 factor floor, never below 1). buyListing ingests real demand: purchases of NPC/vendor stock (seller == null) call recordPurchase (fire-and-forget, src/lib/economy/consumption.ts — quantity-weighted realDemand increment). GM tools: resetEconomyState server action (permission market-state:update, rebuilds the doc from baselines, emits economy:reset; per-listing "Reset economy" button gated by canResetEconomy on the market page) and getReferencePrices (modifier-applied reference price shown in CreateListingDialog's tip when it differs from the resting valuation). Tests: tests/int/economy-consumption.int.spec.ts. Notifies clients via the same SSE path as the other ticks (source: "economy-tick").
  • Mission auto-completion: bun run payload mission-tick — bin registered like the others (needs external cron). Sweeps missions whose status is Scheduled/Active and whose scheduled date (classification.startDateTime) has fully passed — from the start of the following server-local day — and sets them to Completed, emitting a mission:auto-complete event per mission (logic: src/lib/intelligence/missionLifecycle.ts, test: tests/int/mission-lifecycle.int.spec.ts). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
  • Server presence: bun run payload server-tick — bin registered like the others (needs external cron, every minute). Flips game-servers docs that claim status: "online" but whose last heartbeat (lastSeenAt) is older than 180s to offline, including online docs with no lastSeenAt at all; emits a server:offline event per flipped server (logic: src/lib/arma-bridge/presence.ts, test: tests/int/server-presence.int.spec.ts). Idempotent. Notifies clients via the same SSE path as the other ticks.
  • Arrival handling (src/scripts/processShipmentTick.ts): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes failed, ShipmentFail event logged.
  • GameRules tuning: proximityThreshold and gameTickIntervalMinutes live on the global game-rules doc.
  • UI: src/app/(frontend)/logistics/shipments/ (list + [id] detail with ShipmentActions controls), src/app/(frontend)/logistics/game-vehicles/ (deployed vehicle views).

Realtime SSE pipeline (in-memory, single-instance only)

gameTick → POST /api/game-tick/notify (guarded by x-game-tick-secret header = GAME_TICK_NOTIFY_SECRET env) → in-process bus (src/lib/realtime/bus.ts, module-level subscriber Set) → SSE GET /api/realtime → GameTickRealtime (src/components/frontend/realtime/) calls router.refresh() and dispatches ptf:game-tick → consumers via src/hooks/useGameTick.ts (ShipmentToasts, EventLedger). Will not work across multiple server instances.

  • GameTickRealtime mounts only for logged-in users; ShipmentToasts only for logistics-qualified users.
  • hasLogisticsQualification(payload, user) (src/utils/access-control/hasLogisticsQualification.ts) queries Profiles progression.qualifications for "logistics" (case-insensitive); admin/developer always pass. Used to gate logistics-only UI.

Storage rules (logistics)

Structure collection has allowedStorage (per-item caps), prohibitedStorage, and restrictToAllowed (whitelist gate) under storage. Shared logic in src/lib/storageRules.ts (checkStorageDeposit, isResourceProhibited, isResourceWhitelisted, getResourceStorageCap, storageViolationMessage).

  • Enforcement order: prohibited → whitelist → per-item cap, counting grid + void storage.
  • Enforced in: structures/actions.ts (addResource, transferResource, placeResourceOnGrid), shipments/actions.ts (createShipment destination check), and processShipmentTick.ts on arrival.
  • UI: ManageStorageDialog caps deposits to min(mass, allowance) and filters non-whitelisted items; structure page shows an amber "whitelist mode" banner when restrictToAllowed is set.

Base Management (staffing & upgrades)

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

Personnel (NPC directory)

  • /personnel/contacts — NPC directory over game-npcs (src/components/frontend/personnel/: NpcDirectory, NpcCard); /personnel/contacts/[id] — NPC profile (faction, in-game vs generated badges, vendor listings, NpcProfile).
  • /personnel/statistics — theater-wide NPC stats: faction distribution, in-game vs generated counts, vendor stall count, top 5 vendors by active auto listings.
  • Sidebar entries live in src/components/frontend/blocks/sidebarData.ts (nav data extracted from AppSidebar).

Banking System

src/collections/banking/ — bank-accounts, bank-transactions, ledger-entries. Per-person money for a future market feature plus unit/faction treasuries. Admin group: Banking.

  • BankAccounts: name, accountType (treasury/faction/personal), ownerFaction/ownerUser (relationship, conditionally shown by type), currency (→ resources, defaults to the Game Rules main currency), balance (number, admin read-only — maintained by transactions), status (open/frozen/closed). Read: any logged-in user. Create/update: admin/developer. Delete: developer only.
  • BankTransactions: transactionNumber (unique, auto-generated), type (deposit/withdrawal/transfer/payment/fee/salary/adjustment), fromAccount/toAccount (→ bank-accounts, optional per type), amount, fee, memo, actor (→ users), status (completed/reversed), reference (reversal), timestamp. Access: developer create/update/delete, any logged-in read.
    • transactionNumber is auto-generated in a beforeValidate hook (TXN-<base36 ts>-<rand>) when empty. Do not require callers to pass it. Callers may pass "" to satisfy TS on the required field — the hook treats falsy as missing.
  • LedgerEntries: one per affected account per transaction. account, transaction, type, signed amount (positive = credit, negative = debit), balanceAfter, memo, timestamp. Read: any logged-in user. Create/update/delete: developer only.
  • Service lib: src/lib/banking/index.ts.
    • applyTransaction(payload, { type, fromAccountId?, toAccountId?, amount, fee?, memo?, actorId? }) — single source of truth for balance math. Validates accounts exist/open/frozen + sufficient funds, creates the bank-transactions doc, appends ledger entries (signed), updates both balances, returns the transaction. Throws descriptive Errors.
    • createAccount(payload, { name, accountType, ownerFactionId?, ownerUserId? }) — creates a zeroed account in the main currency; throws if no main currency is set in Game Rules.
    • ensurePersonalAccount(payload, userId) — finds-or-creates a personal account for a user (dedup).
    • getMainCurrencyId / getMainCurrencyName — resolve the Game Rules mainCurrency → resource.
    • src/lib/banking/format.ts — formatAmount, currencyLabel, formatDate. Takes a CurrencyConfig (threaded from Game Rules) — labels prefer the configured display names over the resource's name (e.g. "Gold") over codeName (e.g. "res_gold"), with pluralization.
  • Server actions: src/app/(frontend)/logistics/banking/actions.ts. createBankAccount (regular users may only create their own personal account; treasury/faction require a manager), ensureMyAccount, depositFunds/withdrawFunds/transferFunds. Permission model: personal accounts are owner- or manager-only; treasury/faction accounts are manager-only. Manager = admin/developer or logistics-qualified (hasLogisticsQualification). Emits finance:deposit / finance:withdraw / finance:transfer / bank:account-create events.
  • UI: src/app/(frontend)/logistics/banking/ (overview + [id] detail), components in src/components/frontend/banking/ (BankingOverview, AccountCard, AccountDetail, CreateAccountDialog, BankTransactionDialog, LedgerTable, MyWalletCard). Sidebar entry "Banking" under Logistics.
  • Event targets: bank-accounts, bank-transactions, ledger-entries added to GameEventLogs TARGET_COLLECTIONS and emit.ts targetCollection union. Finance event types in eventTypes.ts.
  • Gotchas: Payload's create TS overloads reject undefined on relationship/required fields — pass null for empty relationships and a concrete value ("" for transactionNumber) or TS falls through to the draft-variant and errors Property 'draft' is missing. No migration has been added for these collections yet (dev uses push: false; run bun run payload migrate after schema changes).

Trading Marketplace

src/collections/market/MarketListings.ts — market-listings collection. Tarkov-style flea market: users list locker items at a fixed price, buyers pay via banking, items transfer directly into the buyer's locker. Admin group: Market.

  • Schema: asset (→ assets), seller (→ users, null for auto-generated vendor entries), npc (→ game-npcs, set for auto-generated entries), quantity, price (per unit), currency (→ resources, defaults to main currency), status (active/sold/cancelled/expired), isAutoGenerated (checkbox), listedAt, expiresAt, soldAt, buyer. Negotiation pricing: desiredPrice (target during haggling, defaults to price) + minPrice (floor; when minPrice < price the listing is negotiable).
  • Access: read/create any logged-in user; update owner or admin/developer; delete developer only.
  • Service lib: src/lib/market/index.ts.
    • buyListing(payload, listing, buyer, { unitPrice?, quantity? }) — single source of truth for purchases: validates active, ensures buyer/seller personal accounts (ensurePersonalAccount), resolves treasury for vendor stock (getTreasuryAccountId), calls applyTransaction with type "payment" (fromAccountId buyer → toAccountId seller, or treasury for auto entries), credits buyer's locker, marks listing sold. Passing unitPrice buys at a negotiated price instead of the asking price. Passing quantity buys only that many units (defaults to the full listing.quantity): the stock is decremented and the listing stays active until the last unit, which marks it sold. quantity must be an integer in [1, stock] or buyListing throws "Quantity must be between 1 and {stock}.".
    • createMarketListing flow (in server actions): validates tradeable === true + isLive === true, checks clean stock, deductLockerQuantity from the seller's locker, then creates the listing.
    • "Clean" stock rule: only locker entries without attachments/skin can be sold (isEntryClean/countCleanQuantity) — listing or selling an entry with equipment would destroy the attachments.
    • creditLockerQuantity(payload, userId, assetId, quantity) — merges stackables / fills empty grid spots; throws if no space (this is how buyers receive items and how cancelled/expired listings return stock).
    • autoPrice(asset, kind) — base price ±15% deviation; autoQuantity(asset) — 1 (or 1–3 for stackables). USER_LISTING_DURATION_MS (30d) and AUTO_LISTING_DURATION_MS (7d) set expiresAt.
    • negotiationRange(listing) → { min, desired } (min = minPrice ?? desired); isNegotiable(listing) → min < desired.
  • Server actions: src/app/(frontend)/logistics/market/actions.ts. createMarketListing (accepts optional minPrice; sets desiredPrice = price), cancelMarketListing (returns stock to seller), buyMarketListing (re-fetches listing, verifies active, delegates to buyListing with an optional quantity), plus negotiation actions makeMarketOffer (accepts optional quantity, stored on the thread and used when the deal closes) / acceptMarketOffer / rejectMarketOffer / counterMarketOffer / cancelMarketOffer. Emits market:listing-create / market:listing-cancel / market:sale / market:offer* events.
  • Negotiations: src/collections/market/MarketNegotiations.ts — market-negotiations. One thread per listing+buyer. Fields: listing, buyer, status (open/accepted/rejected/closed/cancelled/expired), amount (per-unit price on the table), quantity (how many units the buyer is haggling for — set on the first offer, defaults to 1), proposedBy (buyer/seller), patience (NPC vendor meter 0–100), acceptedPrice, history[]. Access: read/update scoped to buyer or listing.seller (admin/developer bypass); delete developer only.
    • Flow: buyer makeMarketOffer → thread with proposedBy: buyer. For player listings the seller is notified and can accept/reject/counter. For NPC vendor listings (seller == null) the offer is auto-resolved immediately via npcResponseToOffer in src/lib/market/negotiations.ts. A party may only accept/reject/counter the other side's proposal.
    • Strictly-increasing buyer offers: the buyer's offers on a listing must keep rising — a repeat of an amount or a lower offer is rejected server-side in makeMarketOffer/counterMarketOffer (enforceHigherBuyerOffer, driven by lastBuyerOfferOf over thread history) with "Your offer must be higher than your previous offer of X." The vendor's own movedBackward firm-hold in npcResponseToOffer is a second line of defense.
    • NPC vendor pricing (NPC_ACCEPT constants: concedeRatio 0.3, marginRatio 0.02, chance 0.85): the vendor keeps a private stance starting at the asking price that only moves down. Offers ≥ stance are accepted outright; offers within 2% of the stance are accepted with 85% chance (else the vendor holds firm at the stance); well-below offers draw a concession — the stance moves 30% of the gap toward the buyer but never rises past its last counter and never drops below minPrice (counters are clamped to the vendor's floor), and the vendor holds firm if the buyer offers less than their previous offer (lastCounter/lastBuyerOffer from npcStateOf). Counter responses carry a reason (firm/lowball/backward/stall/concede) and accept responses a reason (good/overpay/accept) that drives the vendor's dialogue line.
    • NPC dialogue: npcDialogue(kind, amount?, asking?, rng?) in src/lib/market/npcDialogue.ts (pure module — safe to import client-side) produces vendor flavour lines from per-kind variant arrays (picked at random via the rng argument, default Math.random). resolveNpcOffer/finalizeNegotiation write these into the history note; the chat transcript is rebuilt by buildChatBubbles(negotiation, listing, currencyLabel) in src/lib/market/chatBubbles.ts (pure — vendor greeting, buyer "How does {amount} sound?", seller lines from history; accept/closed fallback lines appended only for vendor threads with no dialogue, picked deterministically from the thread id). Bubbles render via ChatBubbleList (src/components/frontend/market/ChatBubbleList.tsx, auto-scrolls, configurable buyer/seller labels). Old threads with generic notes fall back to "How about {amount}?".
    • Patience meter (NPC vendors): NPC_PATIENCE constants (roundCost 12, lowballPenalty 25, stallPenalty 35, stallRatio 0.01, goodOfferRelief 9, goodOfferRatio 0.85, cap 100). Every bargaining round advances the meter on the thread: lowballs (< minPrice) add roundCost + lowballPenalty, stalls (offers that move up by less than stallRatio × desired, e.g. +$1 on a $5,000 item) add roundCost + stallPenalty, offers at/above 0.85 × desired relieve goodOfferRelief, otherwise roundCost. At the cap the thread closes (status: closed) — the buyer can only buy at the asking price. One persistent thread per listing+buyer holds the meter (NPC path reuses/reopens it, never resets); makeMarketOffer blocks after accepted/closed. Meter UI: PatienceMeter (src/components/frontend/market/PatienceMeter.tsx, green→amber→red) in MakeOfferDialog + buyer-side rows of NegotiationPanel. Bounds (min/desired) stay server-side — never shown to buyers.
    • Completion: finalizeNegotiation (internal to actions) runs buyListing at the agreed unitPrice and the thread's quantity, marks the thread accepted with acceptedPrice, expires sibling open threads (expireNegotiationsForListing — notifies other interested buyers), notifies both parties, emits market:offer-accept + market:sale. Buying/cancelling/expiring a listing also expires its open threads.
    • UI: MakeOfferDialog (buyer-facing haggle flow on ListingCard — loads existing thread via REST on open, shows counter/accept/withdraw actions + NpcChat chat for vendor listings; after a deal closes it stays visible for a 3.5s minimum window so the result and vendor line stay readable, and the refresh is deferred until that window passes so a sold listing doesn't unmount the dialog early), NegotiationPanel on the market page (open threads with role-aware Accept/Counter/Reject/Withdraw + recent closed chips + a History button opening NegotiationHistoryDialog, which lists all of the user's past negotiations as expandable chat rows), Countdown live "expires in" timer on every card. CreateListingDialog has a "Allow offers below the asking price" section (min price) and only lets you select locker items that are tradeable and isLive (disabled otherwise; "not approved for trading yet" hint when nothing qualifies). Deal-closed feedback fires a sonner toast (accept in MakeOfferDialog/NegotiationPanel, plus NPC auto-accept) — <Toaster /> is mounted in src/app/(frontend)/layout.tsx via src/components/ui/sonner.tsx.
  • Market tick: bun run payload market-tick (bin market-tick in payload.config.ts, NOT npm script). Expires overdue listings (expireListing — user stock returned, auto entries just close; also expires open negotiations) then tops up auto-generated entries for any tradeable+live asset with a base buy price that has no active listing. Each entry is assigned an NPC via resolveVendorNpc; the price is multiplied by the NPC's vendor.priceModifier. Auto entries get desiredPrice = price, minPrice = round(price × 0.6). Emits market:listing-expire (per listing) + market:auto-generate (batch summary), then notifies clients via the same /api/game-tick/notify SSE path.
  • NPC attribution: auto entries belong to a game-npcs NPC instead of a user. src/lib/market/npcs.ts — resolveVendorNpc(payload, assetId) prefers an in-game vendor NPC that vendor.sells the asset, falls back to any enabled vendor NPC selling it, otherwise createGeneratedNpc (random name/occupation, isGenerated: true, isInGame: false, set up as a vendor for that asset). applyNpcPriceModifier(base, npc) applies vendor.priceModifier. GMs create real NPCs in the admin panel and tick isInGame on generated placeholders to promote them.
  • UI: src/app/(frontend)/logistics/market/ page + src/components/frontend/market/ (MarketView with search/rarity/type filters, ListingCard with Buy/Offer/Cancel, CreateListingDialog, NegotiationPanel, MakeOfferDialog, NpcChat, Countdown). Sidebar entry "Market" under Logistics. Uses formatAmount from src/lib/banking/format for prices. Auto entries show the NPC name + occupation in the "from" line (npcLabel), falling back to "Market vendor" for legacy entries with no NPC.
  • Event targets: market-listings + market-negotiations added to GameEventLogs TARGET_COLLECTIONS and emit.ts targetCollection union. Market event types in eventTypes.ts.
  • Gotchas: The market-tick bin must be run regularly (external cron) for expiry + vendor stock to update. buyListing credits the locker before clearing the payment — if the buyer's locker is full, the payment already moved and the purchase throws; consider escrow/refund handling in a later pass. When a seller accepts a buyer's offer, buyListing runs as the buyer (from negotiation.buyer), never the accepting actor. No migration added yet (dev uses push: false; run bun run payload migrate after schema changes). bun run db push fails with must be owner of table spatial_ref_sys — use Payload's migration runner instead.

Notifications

src/collections/users/UserNotifications.ts — user-notifications collection. In-app notification inbox for players (market offers, deal outcomes, etc.). Admin group: Users.

  • Schema: user (→ users), type (text, e.g. market:offer), title, message, link (optional internal path), read (checkbox), timestamps.
  • Access: users read/update only their own; create/delete developer only (creation happens via notifyUser, which uses overrideAccess).
  • Helper: notifyUser(payload, { userId, type?, title, message?, link? }) in src/lib/notifications/index.ts — fire-and-forget create. notificationLabel(type) maps types to short labels. Notification type strings: market:offer, market:counter, market:accept, market:reject, market:withdrawn, market:sold, market:expired.
  • UI: NotificationsBell (src/components/frontend/notifications/NotificationsBell.tsx) mounted in SiteHeader. Polls /api/user-notifications?limit=12&sort=-createdAt (cookie auth) every 30s, shows an unread-count badge, dropdown with unread highlight + relative time, click-to-open-link, per-row mark-as-read check button on unread rows (stopPropagation — marks read without navigating, dropdown stays open), "Mark all read". Mark-read server actions live in src/app/(frontend)/notifications/actions.ts (markNotificationsRead, markAllNotificationsRead).
  • Wiring: negotiation flows in market/actions.ts notify the counterparty at every step; expireNegotiationsForListing notifies interested buyers when a listing sells/cancels/expires.

Narrative Events System

src/collections/world/NarrativeEvents.ts — narrative-events collection. A flowchart-based narrative event editor using @xyflow/react (React Flow) in the Payload admin panel.

  • Schema: name (text, title), summary (textarea), flowData (JSON — React Flow nodes + edges), triggers (group with type select + optional condition JSON).
  • Access: developer-only (create/update/delete/read).
  • Flow editor: custom admin field component at src/components/admin/narrative-flow/NarrativeFlowEditor.tsx. Renders a React Flow canvas with a toolbar to add node types.
  • Node types (custom React Flow nodes in src/components/admin/narrative-flow/):
    • NarrativeBeat (NarrativeBeatNode.tsx) — blue, story exposition. One source handle (bottom) + one target handle (top). Fields: title, text.
    • Choice (ChoiceNode.tsx) — amber, player decision point. One target handle (top), one source handle per option (right side). Fields: question, options[] (each with id, label).
    • Outcome (OutcomeNode.tsx) — emerald, terminal result. Target handle only. Fields: title, text, effects[] (each with type, value).
  • Context menu: right-click any node/edge to open a Radix context menu with a "Delete" action. Deleting a node also removes connected edges.
  • Data flow: changes to nodes/edges sync to Payload's form state via useField().setValue(). Serialization uses JSON.stringify diffing to avoid loops.
  • Editing: double-click or click the edit icon on a node to edit its properties via node edit dialog (modal with type-specific fields).
  • Dependencies: @xyflow/react (v12), radix-ui (context-menu primitives), lucide-react (icons).
  • To add a new node type, create the component + register it in the nodeTypes object in NarrativeFlowEditor.tsx.

Wiki

src/collections/wiki/: wiki-pages, wiki-revisions, wiki-templates. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: Wiki.

  • WikiPages: title (text, required), slug (text, required, unique + indexed, generated by the service layer from the title), category (select, required: Campaign/World/Lore/Characters/Plot/Media/Guides/Meta), tags (text hasMany), body (textarea, required, markdown source), lockdown (checkbox, default false), lockedBy/lockedAt (→ users / date), lastEditor (→ users). Access: read/create/update any logged-in user; delete requires intelligence qualification (hasIntelligenceQualification).
  • WikiRevisions: page (→ wiki-pages), revisionNumber (number, min 1), titleSnapshot/contentSnapshot, editor (→ users), summary, type (create/edit/restore), restoredFromRevision. Access: read logged-in; create/update/delete false (written internally via overrideAccess).
  • WikiTemplates: name (unique), body (textarea, snippet with {{param}} placeholders), description. Read logged-in; write admin/developer.
  • Service lib: src/lib/wiki/.
    • slugify.ts: slugify(title) lowercases, collapses non-alphanumerics to hyphens, falls back to "page".
    • templates.ts: expandTemplates(source, map) expands {{Name}} at render time; nested templates expand up to 2 levels deep and a repeated name in the expansion chain is left literal (cycle guard). Pure module (client-safe).
    • wikilinks.ts: preprocessWikilinks turns [[Page]] into [Page](/wiki/<slug>) and [[Page|label]] into [label](/wiki/<slug>); extractWikilinks returns the raw target titles. Pure module.
    • prepare.ts: prepareMarkdown(source, templates) = template expansion first, then wikilink preprocessing. Single entry point the frontend uses before rendering.
    • categories.ts: WIKI_CATEGORIES (the 8 category values).
    • service.ts: createPage/updatePage/restoreRevision/setPageLockdown/deletePage/listTemplates/templateMap. All mutations run with overrideAccess: true; updatePage/restoreRevision throw "This page is locked and cannot be edited." when lockdown is set; deletePage cascades the page's revisions; slug collisions get a -2, -3, ... suffix; revision numbers are max + 1. Does NOT emit events (the actions layer does).
  • Server actions: src/app/(frontend)/wiki/actions.ts. createWikiPage, updateWikiPage, restoreWikiRevision (moderator), setPageLockdown (moderator), deleteWikiPage (moderator). Canonical repo pattern (duplicated authenticate(), ActionResult<T>, emitGameEvent after mutation). Moderator = hasIntelligenceQualification(payload, user) (intel division members + admin/developer pass). Emits wiki:page-create / wiki:page-edit / wiki:page-restore / wiki:page-lock / wiki:page-unlock / wiki:page-delete.
  • Markdown rendering: react-markdown@10.1.0 + remark-gfm@4.0.1 (GFM provides footnotes natively: [^1] marker + [^1]: definition) + remark-directive@4.0.0 (layout/callout directives). The shared pipeline lives in src/lib/wiki/markdown.tsx (client-safe, no Payload imports): exports wikiRemarkPlugins (= [remarkGfm, remarkDirective]), wikiRemarkRehypeOptions (handlers mapping containerDirective/leafDirective → div and textDirective → span), wikiMarkdownComponents, and re-exports ReactMarkdown. Wired into both the server component WikiContent and the editor's live preview. No raw HTML (no rehype-raw). Images are URL-only <img> (no upload support yet). Captioned images ![alt text](url "Caption on hover") render the caption as a hover title only, no figure/figcaption (deliberate: a figure inside a <p> is invalid HTML and would cause a hydration mismatch). Two/three-column layout via ::::columns / :::column / :::: fences, the closing fence must use the same or more colons. :::note and :::warning callouts render as styled divs. Wikilinks [[Page]]/[[Page|label]] and {{Template}} expansion still run through prepareMarkdown before rendering.
  • Editor: WikiEditor (src/components/frontend/wiki/WikiEditor.tsx): title/category/tags/edit-summary fields plus a markdown body with live preview (prepareMarkdown → react-markdown via the shared pipeline) and an "Insert wikilink" dialog (WikiLinkDialog, picks from existing pages at the cursor). The body (WikiEditorBody.tsx) sits under a formatting toolbar (WikiEditorToolbar.tsx): bold / italic / strikethrough / heading / inline code / code block / quote / bulleted list / numbered list / table, plus insert actions for wikilink, footnote, captioned image, 2-column, 3-column, and citation. Selection helpers live in src/lib/wiki/editorFormatting.ts. Keyboard shortcuts: Ctrl/Cmd+B bold, Ctrl/Cmd+I italic, Ctrl/Cmd+Shift+X strikethrough, Ctrl/Cmd+Shift+H heading, Ctrl/Cmd+K insert wikilink, Ctrl/Cmd+E inline code. An in-editor Markdown reference guide (MarkdownGuide.tsx) sits next to the live preview.
  • UI: src/app/(frontend)/wiki/: /wiki index (WikiIndex: search, tag filter, category tabs, "New page"), /wiki/new, /wiki/[slug] (detail: WikiContent render + WikiToolbar with Edit/History/Lock/Delete; LockdownBanner when locked; missing pages show a "Create this page" CTA that prefills ?title=), /wiki/[slug]/edit (redirects to /wiki/new?title= for missing pages, shows LockdownBanner when locked), /wiki/[slug]/history (RevisionList with type badges and moderator-only Restore). Tests: tests/int/wiki.int.spec.ts.
  • Event targets: wiki-pages, wiki-revisions, wiki-templates added to GameEventLogs TARGET_COLLECTIONS and emit.ts targetCollection union. Wiki event types in eventTypes.ts.
  • Database: migration batch 28 src/migrations/20260904_230838_add_wiki_collections.ts: tables wiki_pages (+ _texts), wiki_revisions, wiki_templates, plus the three wiki values added to the game_event_logs target_collection enum. Dev uses bun run payload migrate for schema changes (push: false).
  • Gotchas: image uploads go through the uploadWikiImage server action in src/app/(frontend)/wiki/actions.ts (any logged-in user, image MIME only, 8 MB cap, stored in media with read: () => true so images are public; the editor toolbar's "Upload image" button inserts ![alt](/api/media/file/<filename>) — requires experimental.serverActions.bodySizeLimit (10mb) in next.config.mjs); lockdown blocks edit/restore server-side (actions throw "This page is locked and cannot be edited."); restore/delete/lock are intelligence-qualification gated; WikiRevisions denies create/update/delete outright, so all revision writes go through the service layer with overrideAccess.

Training Minigames

Five XP-earning minigames under one parent: routes live at /minigames/* (moved from /qualification, /radio, /flappy, /eod, /opord; next.config.mjs redirects keep the old URLs working), components under src/components/frontend/minigames/<game>/ (shared bits like UnlockNotice.tsx at the root), game-rule libs under src/lib/minigames/ (custom-content libs in src/lib/minigames/custom/), stats under Profiles progression.minigames.*. The realtime presence channel strings ("eod", "radio", ...) are unchanged:

  • Qualification Course (aim trainer): /minigames/qualification (src/app/(frontend)/minigames/qualification/), game rules in src/lib/minigames/aim-trainer.ts (recruit/veteran/elite difficulties, civilian targets, scoring/accuracy/XP, injected rng). Components: src/components/frontend/minigames/qualification/ (AimTrainerArena, AimTrainerGame, AimTrainerCanvas, AimTrainerLeaderboard). Action submitAimTrainerResult validates result bounds, updates profile stats, awards XP, emits minigame:aim-trainer.
  • Radio Traffic: /minigames/radio, sprint + watch modes, procedural message generation (src/components/frontend/minigames/radio/messageGenerator.ts). Action submitRadioResult emits minigame:radio-traffic; stats under progression.minigames.radioTraffic (incl. bestSprintTimeMs).
  • Flight Simulator: /minigames/flappy (canvas game + leaderboard). Minefield Clearance (EOD): /minigames/eod. OPORD Recall: /minigames/opord (src/lib/minigames/opord-recall.ts).
  • Leaderboards match active players by user id (not display name) — see commit 72715c4 if leaderboard queries look wrong.

Discord Bot

A Discord bot living in src/bot/, run as a standalone long-running process via bun run bot — it imports @payload-config directly (same pattern as src/scripts/ and src/tools/seed/) and shares the Postgres DB with the web app. Under active development and testing. Full product requirements live in docs/bot/context.md; the approved implementation design is in docs/bot/design.md — read both before touching bot code.

  • Env / run: requires DISCORD_TOKEN and DISCORD_GUILD_ID (fail-fast on missing required vars in src/bot/config.ts); optional DISCORD_OPS_CHANNEL_ID, DISCORD_ANNOUNCE_CHANNEL_ID, DISCORD_STAFF_ROLE_IDS, DISCORD_ATTENDANCE_POLL_MS (default 60s), DISCORD_NOTIFICATION_POLL_MS (default 20s), DISCORD_EVALUATION_POLL_MS (default 60s), DISCORD_IMMINENT_POLL_MS (default 60s), DISCORD_SIDE_OPS_CHANNEL_ID (Friday open-slot notice channel), DISCORD_COMMUNITY_ZEUS_ROLE_ID (role ping for that notice), DISCORD_SIDE_OPS_POLL_MS (default 30 min), plus existing APP_URL. Logs through payload.logger.
  • Structure: commands/ — ping, signup, link, announce, remindEvaluations; events/interactionCreate.ts — routes ptf-att: RSVP buttons; services/ — missionEmbeds (attendance embed lifecycle + reconcile loop), notificationBridge (poll → Discord DMs), evaluationReminders (post-mission evaluation reminder DMs), imminentReminders (pre-op thread + attendee pings), and fridayOpsNotice (weekly #side-ops ping when the upcoming Friday has no op, edited to credit the community claimer); lib/ — roles.ts (isStaff), resolve.ts (discordId ↔ Payload user lookups). Command registration scope: signup/link/ping are global (DM-usable — guild-scoped commands never appear in DMs), announce/remind-evaluations are guild-only.
  • Evaluation reminders: once a mission is evaluable (Completed, or Scheduled/Active past its start — same rule as isMissionEvaluable), the bot sends a one-shot DM (per user with a linked discordId) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: computeEvaluationReminderPlan in src/lib/evaluations/reminders.ts; state marker: evaluationRemindersSentAt on Missions (one-shot, same pattern as discordAttendanceSentAt; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore preferences.discord.enabled (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via /remind-evaluations (optional mission option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports {processed, sent}).
  • Sign-up / linking (feature 1): /signup is DM-only (the temp password flows through the DM). Creates the Payload user with username = discordUsername = the caller's Discord username, plus discordId, displayName, steamId, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; interaction.user.send() is a best-effort persistent copy, so a blocked DM never orphans the account. /link works in servers and DMs (credential-free, ephemeral reply only) — matches discordUsername → sets discordId.
  • DM gotcha: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The /signup rejection message explains how to enable it.
  • Attendance (feature 2): mission-attendances collection + src/lib/attendance/ (single write path, emits mission:attendance-change). The bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility: "unit" missions into the ops channel; stores discordMessageId + discordAttendanceHash on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: MissionAttendance component on the mission detail page. bun run payload generate-mission clones the next weekly main mission.
  • Notifications / announcements (feature 3): notificationBridge polls user-notifications (cursor = last seen id; cap 5 DMs/tick) and DMs opted-in users (preferences.discord.enabled, not in mutedTypes). /announce (staff only) posts an announcement embed. New notify sites partially done: banking emits finance:deposit; shipments has no notify site yet.
  • Remaining polish: the web preferences UI does not yet expose the preferences.discord toggles — they exist on the Users collection and are read by the bridge, but are currently only settable in the Payload admin panel.

Auth

Username-based login (no email login). Users log in via Payload admin with username only.

  • src/app/(frontend)/layout.tsx is the gate: guests render LandingPage (no app shell); authed users get sidebar + GameTickRealtime. Because the gate replaces {children} for guests, page-level if (!user) redirect(...) blocks under (frontend) are unreachable dead code for guests — they only serve as type-guards for the authed render path.
  • /login (src/app/login/page.tsx + src/components/frontend/auth/LoginForm.tsx) POSTs { username, password } to /api/users/login, then router.push(returnTo ?? "/") + router.refresh().
  • 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.

Database

PostgreSQL via @payloadcms/db-postgres. Schema defined in src/payload-generated-schema.ts. Migrations in src/migrations/ (timestamp-named .ts + .json pairs, registered in migrations/index.ts). Drizzle config reads DATABASE_URI from env.

In development, postgresAdapter uses push: false — run bun run payload migrate after schema changes to apply them locally. The repo also ships a wrapper, bun run migrate (src/scripts/run-migrations.ts), which adds env selection (dev/stg/prd), --status, and a dev-only --fresh.

Always name your migrations: create them with bun run payload migrate:create <name> (snake_case, e.g. migrate:create add_evaluation_reminders_field), never bare migrate:create. Unnamed migrations get timestamp-only filenames (20260905_213824.ts) that say nothing about what they do — several older migrations suffer from this and it makes the migration history and rollback auditing (data-loss review of DROP COLUMN/DROP TABLE steps) much harder than it needs to be. Named example: 20260906_050900_add_evaluation_reminders_field.ts.

Code style

  • Prettier: double quotes, trailing commas (all), 100 char print width, semicolons.
  • ESLint: next/core-web-vitals + next/typescript. @typescript-eslint/no-unused-vars warns (prefix unused with _).
  • Tailwind v4: no tailwind.config — configured via @tailwindcss/postcss in postcss.config.mjs and CSS imports. Use cn() from @/lib/utils for class merging.
  • No em-dashes in final outputs: never emit em-dashes (—, –) in anything you produce as a final deliverable: chat replies, commit messages, PR descriptions, docs, comments, UI copy. Replace them with a comma, semicolon, colon, or parenthetical, or split into two sentences. (Earlier bullets in this section predate the rule; new writing must comply.)

Conventional Commit prefixes

  • docs is for documentation files only: changes confined to .md files (AGENTS.md, README.md, DEPLOYMENT.md, docs/). Never use docs for edits to source files (.ts/.tsx), even when the edit is copy-only.
  • User-facing string/copy changes inside source files (UI text, labels, hints) use chore(<scope>) when they change no behavior. If the edit adds or alters behavior (new UI, changed flow, changed logic), use feat or fix as usual.
  • Examples: docs: restrict release version bumps and tags to main deploys (changed AGENTS.md/README.md/DEPLOYMENT.md only) is a correct docs commit; chore(opord): rename veteran tier detail to extended order is the right form for a UI-string tweak in OpordArena.tsx (that commit shipped as docs(opord) before this rule existed; do not rewrite the pushed history).
  • Trivial non-functional source edits that are not user-facing copy (formatting, comment fixes, config touches) also use chore (or style for pure formatting).

UI components

shadcn/ui components live in src/components/ui/. Use bunx shadcn@latest add <component> to add new ones. Frontend components in src/components/frontend/. Payload admin custom components referenced in payload.config.ts under admin.components.

Rule: always prefer shadcn/ui components (Dialog, Button, Input, Textarea, Select, etc.) over raw HTML elements or custom-built alternatives. Only build new components when no existing shadcn component fits the need. Do not override shadcn's default dark theme classes with custom bg-* border-* overrides — the admin panel's theme handles styling.

Tactical "dossier" style (Battlefield-style UI)

An approved militaristic/intel-interface style for feature surfaces. The user (Jason) likes it a lot. Reference implementation: the official campaign cards (v0.2.27):

  • Component: src/components/frontend/intelligence/CampaignCard.tsx (official variant, size="lg"; community cards keep the conventional style for contrast)
  • Effects CSS: src/app/(frontend)/styles.css (.glitch-hover, .glitch-ghost, .glitch-shard-a/b/c, .glitch-soft, .glitch-soft-late, .glitch-ghost-img-a/b + their keyframes; trust the file for current values, the list here describes intent)

The recipe (each piece is separable; use subsets, do not feel obliged to use all):

  1. Classification strip: full-width top bar (bg-black/50 backdrop-blur-sm border-b border-white/15), 9px font-mono uppercase tracking-widest text-white/60 labels at both ends (e.g. OFFICIAL // UNIT COMMAND left, a deterministic REF ### derived from the record id right).
  2. Corner brackets: four 1px white/25 L-marks inset from the edges (inset-2, size-3, per-corner border sides), brightening to white/60 on hover. pointer-events-none + aria-hidden.
  3. Squared corners: rounded-none on the tactical surface only; sibling/secondary surfaces keep rounded-lg. The contrast is deliberate.
  4. Military status language: squared pill (rounded-sm) or plain colored text, uppercase relabels (e.g. Concept → Planning, In Progress → Active Deployment, Completed → Concluded). The status color renders as text (text-blue-400 / text-emerald-400 / text-muted-foreground); do not duplicate status in two places on one card.
  5. Readout footer: a permanent (not hover-only) single-line bar (border-t border-white/15 bg-black/60 py-1.5 backdrop-blur-sm), cells flex-1 separated by hairline border-r border-white/15, each cell a font-mono text-xs tabular-nums text-white/80 value plus a 9px uppercase text-white/50 label (e.g. 3 MSNS, 4h 0m DURATION). No owner/DIR cell unless the user wants it.
  6. Darkened cover imagery: image at opacity-15 (hover 25) under a heavy gradient (from-black/85 via-black/60 to-black/25) so text stays readable.
  7. Faint grid texture: 1px white grid lines, 28px cells, opacity-[0.04] (hover 0.07), motion-reduce honored.
  8. One-shot glitch hover (only with explicit user opt-in; it is extra motion): every layer fires instantly at 0% then decays; steps-based chaos only in the first ~15-20% of the animation; title/soft text restore to readable within ~25-35%; chromatic aberration via paired cyan/rose text-shadow; title shards are duplicated absolutely-positioned aria-hidden spans clipped by slanted clip-path polygons (cyan #22d3ee, rose #f43f5e, white); image tears use two ghost <img> copies clipped to horizontal bands so pieces shift but nothing ever disappears (never animate opacity: inherit on an image, it flashes to full brightness). Image band decay may run longer (2s) than text (1s). Everything is one-shot (no infinite) and fully disabled under prefers-reduced-motion.

Where it fits: large feature/hero cards, section headers, showcase surfaces. Where it does not: dense data UI, tables, forms, dialogs, buttons, or anything shadcn-styled (the shadcn rule above still governs those). Consistency rule: within one page, secondary surfaces stay conventional so the tactical surface stands out.

Protocol: offer, demo, or default (MANDATORY)

Whenever creating a new component or refactoring an existing component's styling, ask the user whether they want to try applying the tactical dossier style to it. Concretely:

  • Yes: demo similar styling on that component first (same approach as the campaign cards: build it, let the user eyeball it, iterate on tuning).
  • No, or no answer: stick with the existing styling conventions (shadcn + the site's dark theme conventions). Do not apply tactical styling unasked, and do not nag: ask once, then proceed either way.

Environment

  • .env — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
  • .env.example — template. DATABASE_URI line still shows MongoDB (outdated — trust .env.stg for the real Postgres format), but APP_URL + GAME_TICK_NOTIFY_SECRET are current and required by the game tick.
  • .env.stg — test/staging database
  • test.env — NODE_OPTIONS for playwright (loaded by playwright config)

Deploy

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.

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.

Gotchas

  • Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): searchParams/params are Promises and must be awaited; cookies().set() throws outside Server Actions/Route Handlers; x-invoke-path does not carry the real pathname in layouts.
  • .env.example shows MongoDB URI but the app uses PostgreSQL — trust DATABASE_URI format in .env.stg as the real reference.
  • bun run build passes --max-old-space-size=8000 — the build is memory-intensive.
  • The devturbo script uses Turbopack; dev and devsafe use webpack. These are different bundlers with different behavior.
  • Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.
  • 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).

Server Actions convention

Every actions.ts file follows the same pattern (10+ files use it):

  1. "use server" directive at top
  2. import config from "@payload-config" + const payload = await getPayload({ config })
  3. Local authenticate() helper — dynamic import of next/headers, calls payload.auth() and headers()
  4. Return type ActionResult<T>: { success: boolean; error?: string; data?: T }
  5. Permission check: const { user } = await authenticate() then await hasPermission(payload, user, "domain:action")
  6. Mutation via payload.create / payload.update / payload.delete
  7. Event emission: await emitGameEvent(payload, { type: EventTypes.xxx, message, ... })
  8. Error handling: catch (e) { return { success: false, error: e instanceof Error ? e.message : "Unknown error" } }

The authenticate() function is duplicated in every file — not extracted to a shared helper. If you add a new server action, copy the pattern from an existing one (e.g., src/app/(frontend)/logistics/banking/actions.ts). Do NOT attempt to extract it to a shared helper unless the entire codebase migrates at once.