1
0
Fork 0
polaris-task-force/AGENTS.md
Z8MB1E b0fc432e2a docs: document marketplace, notifications, NPCs, and MVP roadmap
- AGENTS.md: Marketplace and Notifications sections, game-npcs and
  market target collections, market + npc event types, and the
  drizzle push / PostGIS ownership gotcha
- TODO.md: mark the core buy/sell marketplace done and add the MVP
  8/15 roadmap (auth, ACL, frontend, logistics)
2026-08-10 16:06:05 -04:00

32 KiB
Raw Blame History

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

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, 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-<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 Errors.
    • 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) — <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 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.

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.
  • /login (src/app/login/page.tsx + src/components/frontend/auth/LoginForm.tsx) POSTs { username, password } to /api/users/login, then router.push("/") + router.refresh(). The page redirects already-authed users to /.
  • 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 <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.test for the real Postgres format), but APP_URL + GAME_TICK_NOTIFY_SECRET are current and required by the game tick.
  • .env.test — 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

  • .env.example shows MongoDB URI but the app uses PostgreSQL — trust DATABASE_URI format in .env.test 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.