diff --git a/AGENTS.md b/AGENTS.md index 587b2b1..2eb1404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Organized by domain under `src/collections/`: - **banking/** — BankAccounts, BankTransactions, LedgerEntries - **world/** — Maps, NarrativeEvents - **server/** — MissionFiles, ModLists -- **game/** — GameRules (global), GameStructures, GameHardResources, GameEventLogs, GameVehicles +- **game/** — GameRules (global), GameStructures, GameHardResources, GameEventLogs, GameVehicles, GameNpcs Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). Roles: `guest`, `user`, `admin`, `developer`. @@ -90,7 +90,7 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R - **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`, `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. +- **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 @@ -157,6 +157,44 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade - **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. @@ -220,3 +258,4 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add < - 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. diff --git a/TODO.md b/TODO.md index 625cd47..a7acf46 100644 --- a/TODO.md +++ b/TODO.md @@ -6,4 +6,34 @@ - [ ] Inventory management system - A comprehensive system that allows users to view, organize, and manage their items, including sorting, filtering, and categorizing options. - [ ] Item crafting and customization - A feature that enables users to craft new items or customize existing ones, allowing for unique combinations and personalization. - [ ] Trading and marketplace - A platform where users can trade items with other players or buy/sell items in a virtual marketplace, including features like bidding, auctions, and secure transactions. It should be reminiscent of the flea market in Escape from Tarkov. Automatically generated market entries should be available for items that are not listed by users, allowing for a dynamic and evolving marketplace. -- [ ] Implement command palette - A feature that provides users with a list of available commands and allows them to execute them with a single keystroke or gesture. The command palette should be accessible from a designated keybind or gesture and should display a list of relevant commands based on the user's current context. \ No newline at end of file + - [x] Core buy/sell marketplace - Users list items from their locker at a fixed price; buyers pay via the banking system and items transfer directly into the buyer's locker. Runs `bun run payload market-tick` to expire listings and top up auto-generated vendor entries. + - [ ] Bidding and auctions - Allow sellers to list items as auctions with timed bidding instead of fixed-price buyouts. + - [ ] Marketplace fees and market price drift - Percentage listing/sale fees and price fluctuations that tend toward each asset's resting `baseBuyPrice`/`baseSellPrice` over time. +- [ ] Implement command palette - A feature that provides users with a list of available commands and allows them to execute them with a single keystroke or gesture. The command palette should be accessible from a designated keybind or gesture and should display a list of relevant commands based on the user's current context. + +## MVP - Priority Deadline by 8/15 + +This application must be available to my users by 8/15. The minimum viable product is defined as: + +- [ ] Working authentication (login, logout, forgot password without using email) +- [ ] Automatically generated profile with customization options for user on front-end +- [ ] Full permissions and ACL system for all components + - [ ] ALL ADMIN UI access should be blocked behind a "trusted" role. + - [ ] Admin UI access to the Intelligence section should be blocked behind an "intelligence" qualification. + - [ ] Admin UI access to the Logistics section should be blocked behind a "logistics" qualification. + - [ ] Admin UI access to anything else should be blocked for the "admin" and "developer" roles until an explicit change by the developer at a later date. +- [ ] Proper front-end + - [ ] Working dashboard, customizable + - [ ] Working profile page + - [ ] Working roster page + - [ ] Working intelligence pages + +### Logistics (core of the application) + +- [ ] Logistics front-end + - [ ] Working shipments screen + - [ ] Working structures screen + - [ ] Working assets screen + - [ ] Working vehicles screen + - [ ] Banking system + - [ ] Working market screen