1
0
Fork 0
polaris-task-force/AGENTS.md

388 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```bash
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:
```bash
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)`.
- **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`.
- **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]`).
- **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`.
## 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 `Error`s.
- `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
Four XP-earning minigames, each with a page + server actions + a pure client-safe game-rules lib, recording stats under Profiles `progression.minigames.*`:
- **Qualification Course (aim trainer)** — `/qualification` (`src/app/(frontend)/qualification/`), game rules in `src/lib/aim-trainer.ts` (recruit/veteran/elite difficulties, civilian targets, scoring/accuracy/XP, injected rng). Components: `src/components/frontend/qualification/` (`AimTrainerArena`, `AimTrainerGame`, `AimTrainerCanvas`, `AimTrainerLeaderboard`). Action `submitAimTrainerResult` validates result bounds, updates profile stats, awards XP, emits `minigame:aim-trainer`.
- **Radio Traffic** — `/radio`, sprint + watch modes, procedural message generation (`src/components/frontend/radio/messageGenerator.ts`). Action `submitRadioResult` emits `minigame:radio-traffic`; stats under `progression.minigames.radioTraffic` (incl. `bestSprintTimeMs`).
- **Flight Simulator** — `/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)** — `/eod`.
- 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), 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), and `evaluationReminders` (post-mission evaluation reminder DMs); `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.
## 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.
## 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.
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch 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. 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 — or pushing the 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.