diff --git a/AGENTS.md b/AGENTS.md index 13227a4..0bbe95f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # 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 @@ -125,6 +126,7 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R ### 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. @@ -134,12 +136,14 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R - 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 @@ -204,11 +208,11 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade - `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. + - **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 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. + - **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. @@ -244,6 +248,28 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade - **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons). - To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`. +## Wiki + +`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**. + +- **WikiPages**: `title` (text, required), `slug` (text, required, unique + indexed, generated by the service layer from the title), `category` (select, required: Campaign/World/Lore/Characters/Plot/Media/Guides/Meta), `tags` (text hasMany), `body` (textarea, required, markdown source), `lockdown` (checkbox, default false), `lockedBy`/`lockedAt` (→ users / date), `lastEditor` (→ users). Access: read/create/update any logged-in user; delete requires intelligence qualification (`hasIntelligenceQualification`). +- **WikiRevisions**: `page` (→ wiki-pages), `revisionNumber` (number, min 1), `titleSnapshot`/`contentSnapshot`, `editor` (→ users), `summary`, `type` (`create`/`edit`/`restore`), `restoredFromRevision`. Access: read logged-in; create/update/delete `false` (written internally via `overrideAccess`). +- **WikiTemplates**: `name` (unique), `body` (textarea, snippet with `{{param}}` placeholders), `description`. Read logged-in; write admin/developer. +- **Service lib**: `src/lib/wiki/`. + - `slugify.ts`: `slugify(title)` lowercases, collapses non-alphanumerics to hyphens, falls back to `"page"`. + - `templates.ts`: `expandTemplates(source, map)` expands `{{Name}}` at render time; nested templates expand up to 2 levels deep and a repeated name in the expansion chain is left literal (cycle guard). Pure module (client-safe). + - `wikilinks.ts`: `preprocessWikilinks` turns `[[Page]]` into `[Page](/wiki/)` and `[[Page|label]]` into `[label](/wiki/)`; `extractWikilinks` returns the raw target titles. Pure module. + - `prepare.ts`: `prepareMarkdown(source, templates)` = template expansion first, then wikilink preprocessing. Single entry point the frontend uses before rendering. + - `categories.ts`: `WIKI_CATEGORIES` (the 8 category values). + - `service.ts`: `createPage`/`updatePage`/`restoreRevision`/`setPageLockdown`/`deletePage`/`listTemplates`/`templateMap`. All mutations run with `overrideAccess: true`; `updatePage`/`restoreRevision` throw `"This page is locked and cannot be edited."` when `lockdown` is set; `deletePage` cascades the page's revisions; slug collisions get a `-2`, `-3`, ... suffix; revision numbers are `max + 1`. Does NOT emit events (the actions layer does). +- **Server actions**: `src/app/(frontend)/wiki/actions.ts`. `createWikiPage`, `updateWikiPage`, `restoreWikiRevision` (moderator), `setPageLockdown` (moderator), `deleteWikiPage` (moderator). Canonical repo pattern (duplicated `authenticate()`, `ActionResult`, `emitGameEvent` after mutation). Moderator = `hasIntelligenceQualification(payload, user)` (intel division members + admin/developer pass). Emits `wiki:page-create` / `wiki:page-edit` / `wiki:page-restore` / `wiki:page-lock` / `wiki:page-unlock` / `wiki:page-delete`. +- **Markdown rendering**: `react-markdown@10.1.0` + `remark-gfm@4.0.1` (GFM provides footnotes natively: `[^1]` marker + `[^1]:` definition) + `remark-directive@4.0.0` (layout/callout directives). The shared pipeline lives in `src/lib/wiki/markdown.tsx` (client-safe, no Payload imports): exports `wikiRemarkPlugins` (= `[remarkGfm, remarkDirective]`), `wikiRemarkRehypeOptions` (handlers mapping containerDirective/leafDirective → div and textDirective → span), `wikiMarkdownComponents`, and re-exports `ReactMarkdown`. Wired into both the server component `WikiContent` and the editor's live preview. No raw HTML (no rehype-raw). Images are URL-only `` (no upload support yet). Captioned images `![alt text](url "Caption on hover")` render the caption as a hover `title` only, no figure/figcaption (deliberate: a figure inside a `

` is invalid HTML and would cause a hydration mismatch). Two/three-column layout via `::::columns` / `:::column` / `::::` fences, the closing fence must use the same or more colons. `:::note` and `:::warning` callouts render as styled divs. Wikilinks `[[Page]]`/`[[Page|label]]` and `{{Template}}` expansion still run through `prepareMarkdown` before rendering. +- **Editor**: `WikiEditor` (`src/components/frontend/wiki/WikiEditor.tsx`): title/category/tags/edit-summary fields plus a markdown body with live preview (`prepareMarkdown` → react-markdown via the shared pipeline) and an "Insert wikilink" dialog (`WikiLinkDialog`, picks from existing pages at the cursor). The body (`WikiEditorBody.tsx`) sits under a formatting toolbar (`WikiEditorToolbar.tsx`): bold / italic / strikethrough / heading / inline code / code block / quote / bulleted list / numbered list / table, plus insert actions for wikilink, footnote, captioned image, 2-column, 3-column, and citation. Selection helpers live in `src/lib/wiki/editorFormatting.ts`. Keyboard shortcuts: Ctrl/Cmd+B bold, Ctrl/Cmd+I italic, Ctrl/Cmd+Shift+X strikethrough, Ctrl/Cmd+Shift+H heading, Ctrl/Cmd+K insert wikilink, Ctrl/Cmd+E inline code. An in-editor Markdown reference guide (`MarkdownGuide.tsx`) sits next to the live preview. +- **UI**: `src/app/(frontend)/wiki/`: `/wiki` index (`WikiIndex`: search, tag filter, category tabs, "New page"), `/wiki/new`, `/wiki/[slug]` (detail: `WikiContent` render + `WikiToolbar` with Edit/History/Lock/Delete; `LockdownBanner` when locked; missing pages show a "Create this page" CTA that prefills `?title=`), `/wiki/[slug]/edit` (redirects to `/wiki/new?title=` for missing pages, shows `LockdownBanner` when locked), `/wiki/[slug]/history` (`RevisionList` with type badges and moderator-only Restore). Tests: `tests/int/wiki.int.spec.ts`. +- **Event targets**: `wiki-pages`, `wiki-revisions`, `wiki-templates` added to `GameEventLogs` `TARGET_COLLECTIONS` and `emit.ts` `targetCollection` union. Wiki event types in `eventTypes.ts`. +- **Database**: migration batch 28 `src/migrations/20260904_230838_add_wiki_collections.ts`: tables `wiki_pages` (+ `_texts`), `wiki_revisions`, `wiki_templates`, plus the three wiki values added to the `game_event_logs` `target_collection` enum. Dev uses `bun run payload migrate` for schema changes (`push: false`). +- **Gotchas**: image uploads go through the `uploadWikiImage` server action in `src/app/(frontend)/wiki/actions.ts` (any logged-in user, image MIME only, 8 MB cap, stored in `media` with `read: () => true` so images are public; the editor toolbar's "Upload image" button inserts `![alt](/api/media/file/)` — requires `serverActions.bodySizeLimit` (10mb) in `next.config.mjs`); lockdown blocks edit/restore server-side (actions throw `"This page is locked and cannot be edited."`); restore/delete/lock are intelligence-qualification gated; `WikiRevisions` denies create/update/delete outright, so all revision writes go through the service layer with `overrideAccess`. + ## 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.** @@ -309,6 +335,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add < ## 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()`