# 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`). `pnpm-lock.yaml` also exists; use Bun for installs and running scripts.
## 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 (Chromium only). Auto-starts `bun run dev` via `webServer` config. 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.
## 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 `` 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 `` 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, Awards, Qualifications, Assignments, Experience
- **intelligence/** — Missions, Campaigns, Factions, Technologies
- **logistics/** — Assets, Resources, Vehicles, Structures, Shipments
- **banking/** — BankAccounts, BankTransactions, LedgerEntries
- **world/** — Maps, NarrativeEvents
- **server/** — MissionFiles, ModLists
- **game/** — GameRules (global), GameStructures, GameHardResources, GameEventLogs, GameVehicles, GameNpcs
Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). 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`, `factions`, `missions`, `campaigns`, `users`, `technologies`, `maps`, `shipments`, `bank-accounts`, `bank-transactions`, `ledger-entries`. 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)`.
- **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.
## 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--`) 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`. Currency labels prefer the resource's `name` (e.g. "Gold") over `codeName` (e.g. "res_gold").
- **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 (project relies on dev `push: true`).
## 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) — `` 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 relies on `push: true`; the `quantity` column on `market-negotiations` was added directly to the dev DB via `ALTER TABLE` because `bun run db push` fails with `must be owner of table spatial_ref_sys`).
## 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, mark-read on click, "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`.
## 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), plus existing `APP_URL`. Logs through `payload.logger`.
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop) and `notificationBridge` (poll → Discord 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` is guild-only.
- **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=`. 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: true` to auto-sync schema.
## 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 ` 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 patch version (via `bun pm version patch`), builds, then runs `build/deploy.sh`. The `build/` directory is gitignored so the deploy script is not in the repo.
## 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 start the dev server, whose Payload `push: true` path may skip the offending table.
## 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`: `{ 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.