18 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
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. 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.
- 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.