19 KiB
AGENTS.md — Polaris Task Force
What this is
Next.js 16 + Payload CMS 3.79.1 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
bun install # install deps
bun run dev # dev server (webpack, localhost:3000)
bun run devsafe # clears .next cache then dev
bun run build # production build (--max-old-space-size=8000, webpack)
bun run lint # BROKEN under Next 16 — see typecheck below
bun run test # runs test:int then test:e2e sequentially
bun run test:int # vitest integration tests only
bun run test:e2e # playwright e2e tests only
bun run db # drizzle-kit wrapper (e.g. bun run db migrate)
bun run generate:types # regenerates payload-types.ts
bun run generate:importmap # regenerates payload admin importMap
Type checking / linting
bun run lint is broken under Next 16 — next lint was removed and errors out with Invalid project directory provided, no such directory: .../lint. Don't rely on it. The reliable typecheck is:
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
Known pre-existing errors, don't chase them: src/utils/access-control/hasLogisticsQualification.ts (lines 12, 19 — 'user' is possibly 'null') and src/payload-generated-schema.ts (line 1883, generated file — moves when the schema is regenerated). Anything else is yours.
Test details
- Integration tests:
tests/int/**/*.int.spec.ts— Vitest with jsdom. Requires a live PostgreSQL database (connection from.env). Usesdotenv/configviavitest.setup.ts. - E2E tests:
tests/e2e/*.e2e.spec.ts— Playwright (Chromium only). Auto-startsbun run devviawebServerconfig. 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.lockcan 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.lockbefore restarting.
Generated files — never edit manually
src/payload-types.ts— regenerated bybun run generate:typessrc/payload-generated-schema.ts— regenerated by Payload db-schema generationsrc/app/(payload)/admin/importMap.js— regenerated bybun run generate:importmapsrc/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 getLandingPage, 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, 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
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, defaulttrue— 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? })insrc/utils/event-log/emit.ts. Silently catches errors (fire-and-forget). Always setssystem: true. - Types:
EventTypesconstants insrc/utils/event-log/eventTypes.ts— add new event categories there without touching the collection. Using these constants gives TypeScript narrowing on thetypefield. - Wiring: Server actions in
actions.tsemit events after successful mutations.GameStructuresafterChange hook catches admin-panel storage edits.StructuresbeforeChange hook emitsstructure:resize. - targetCollection options:
game-structures,structures,resources,assets,vehicles,game-vehicles,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: falsefor 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 givenstructureIdand renders a scrollable feed. - Accepts optional
refreshKeyprop — 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
- Add event type constant to
src/utils/event-log/eventTypes.tsif it doesn't exist yet. - If the target collection slug isn't in the
TARGET_COLLECTIONSarray inGameEventLogs.ts, add it. - Call
emitGameEvent(payload, { type: EventTypes.xxx, message: "...", ... })after the successful mutation in the server action. EventLedgerwill pick it up automatically if it's scoped to the samestructureId.
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),autoReturncheckbox,failureReason. - Game tick:
bun run payload game-tick— abinregistered onpayload.config.ts, NOT an npm script. It processes active shipments + fuel consumption, thenprocess.exit(0). - Arrival handling (
src/scripts/processShipmentTick.ts): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goesfailed,ShipmentFailevent logged. - GameRules tuning:
proximityThresholdandgameTickIntervalMinuteslive on the globalgame-rulesdoc. - UI:
src/app/(frontend)/logistics/shipments/(list +[id]detail withShipmentActionscontrols),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.
GameTickRealtimemounts only for logged-in users;ShipmentToastsonly for logistics-qualified users.hasLogisticsQualification(payload, user)(src/utils/access-control/hasLogisticsQualification.ts) queries Profilesprogression.qualificationsfor "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(createShipmentdestination check), andprocessShipmentTick.tson arrival. - UI:
ManageStorageDialogcaps deposits tomin(mass, allowance)and filters non-whitelisted items; structure page shows an amber "whitelist mode" banner whenrestrictToAllowedis 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.transactionNumberis 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, signedamount(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 thebank-transactionsdoc, appends ledger entries (signed), updates both balances, returns the transaction. Throws descriptiveErrors.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 RulesmainCurrency→ resource.src/lib/banking/format.ts—formatAmount,currencyLabel,formatDate. Currency labels prefer the resource'sname(e.g. "Gold") overcodeName(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). Emitsfinance:deposit/finance:withdraw/finance:transfer/bank:account-createevents. - UI:
src/app/(frontend)/logistics/banking/(overview +[id]detail), components insrc/components/frontend/banking/(BankingOverview,AccountCard,AccountDetail,CreateAccountDialog,BankTransactionDialog,LedgerTable,MyWalletCard). Sidebar entry "Banking" under Logistics. - Event targets:
bank-accounts,bank-transactions,ledger-entriesadded toGameEventLogsTARGET_COLLECTIONSandemit.tstargetCollectionunion. Finance event types ineventTypes.ts. - Gotchas: Payload's create TS overloads reject
undefinedon relationship/required fields — passnullfor empty relationships and a concrete value (""fortransactionNumber) or TS falls through to the draft-variant and errorsProperty 'draft' is missing. No migration has been added for these collections yet (project relies on devpush: true).
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 withid,label). - Outcome (
OutcomeNode.tsx) — emerald, terminal result. Target handle only. Fields:title,text,effects[](each withtype,value).
- NarrativeBeat (
- 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 usesJSON.stringifydiffing 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
nodeTypesobject inNarrativeFlowEditor.tsx.
Auth
Username-based login (no email login). Users log in via Payload admin with username only.
src/app/(frontend)/layout.tsxis the gate: guests renderLandingPage(no app shell); authed users get sidebar +GameTickRealtime./login(src/app/login/page.tsx+src/components/frontend/auth/LoginForm.tsx) POSTs{ username, password }to/api/users/login, thenrouter.push("/")+router.refresh(). The page redirects already-authed users to/.- Logout lives in
NavUser(sidebar) →/api/users/logout. - Tip: a corrupted
payload-tokencookie causes an infinite login loop (Unexpected end of JSON inputon/api/users/me) — clear cookies/use incognito. Dev userdev/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-varswarns (prefix unused with_). - Tailwind v4: no
tailwind.config— configured via@tailwindcss/postcssinpostcss.config.mjsand CSS imports. Usecn()from@/lib/utilsfor 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_URIline still shows MongoDB (outdated — trust.env.testfor the real Postgres format), butAPP_URL+GAME_TICK_NOTIFY_SECRETare current and required by the game tick..env.test— test/staging databasetest.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
.env.exampleshows MongoDB URI but the app uses PostgreSQL — trustDATABASE_URIformat in.env.testas the real reference.bun run buildpasses--max-old-space-size=8000— the build is memory-intensive.- The
devturboscript uses Turbopack;devanddevsafeuse 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.
.npmrcsetslegacy-peer-deps=truefor dependency resolution compatibility.