Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
388 lines
61 KiB
Markdown
388 lines
61 KiB
Markdown
# 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 `` 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 `` — 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` and push it together with the work. 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.
|