Compare commits
14 commits
56b8b9d320
...
9f35dc7ddd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f35dc7ddd | |||
| 4524f969a6 | |||
| dbe8a210fb | |||
| b9a619807d | |||
| db5745d458 | |||
| 3bc689d572 | |||
| b558569b8e | |||
| 28250550ab | |||
| 44b78ba7d9 | |||
| f83301a923 | |||
| 13f2132e25 | |||
| d4c8d81e2f | |||
| 447df220f5 | |||
| ded5d4b341 |
61 changed files with 73743 additions and 132 deletions
|
|
@ -105,7 +105,7 @@ Organized by domain under `src/collections/`:
|
|||
- **intelligence/** — Missions, MissionAttendances, Campaigns, Factions, Technologies
|
||||
- **logistics/** — Assets, Resources, Vehicles, Structures (with staffing defaults), Shipments
|
||||
- **banking/** — BankAccounts, BankTransactions, LedgerEntries
|
||||
- **market/** — MarketListings, MarketNegotiations
|
||||
- **market/** — MarketListings, MarketNegotiations, MarketState (supply/demand ledger, one doc per tradeable resource/asset)
|
||||
- **locker/** — LockerStorages, Loadouts
|
||||
- **world/** — Maps, NarrativeEvents
|
||||
- **server/** — MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
|
||||
|
|
@ -158,8 +158,10 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
|
|||
|
||||
- **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)`.
|
||||
- **Bin file logging**: all six bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `base-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
||||
- **Bin file logging**: all seven bins (`game-tick`, `market-tick`, `mission-tick`, `server-tick`, `base-tick`, `economy-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
||||
- **Base tick**: `bun run payload base-tick` — bin registered like the others (needs external cron). Runs `processBaseTick` (`src/lib/base/tick.ts`): pays staff salaries from the structure treasury (banking `applyTransaction` type `salary`), charges structure maintenance, flags upkeep shortages (see Base Management below). Emits `staff:salary-paid`/`staff:salary-unpaid`/`structure:maintenance-paid`/`structure:maintenance-unpaid` events. Notifies clients via the same SSE path as the other ticks (`source: "base-tick"`).
|
||||
- **Economy tick**: `bun run payload economy-tick` — bin registered like the others (needs external cron; cadence hint is GameRules `economy.tickIntervalHintMinutes`). Runs `processEconomyTick` (`src/lib/economy/tick.ts`): ensures a `market-state` ledger doc exists per tradeable resource/asset (copied from GM baselines), decays `realDemand`, drifts `artificialDemand` (deterministic per period via `periodSeed` → mulberry32 rng — a cron retry within the same period recomputes the same values), recomputes `priceModifier` from the demand/supply ratio clamped to the item's band. Master gate: GameRules `economy.enabled` (default **off** — no-op when disabled). Cold-start gate: items with fewer than `coldStartObservations` completed purchases keep `priceModifier = 1`. Period idempotency: docs with `lastComputedAt >= periodStart` are skipped. Emits a batch `economy:tick` event + one `economy:price-change` event per item whose modifier moved ≥ `PRICE_CHANGE_THRESHOLD` (0.05) — target `market-state` (logic: `src/lib/economy/` incl. `events.ts`, tests: `tests/int/economy.int.spec.ts` + `tests/int/economy-events.int.spec.ts`). Nothing consumes the modifier yet (P1d: market-tick new-listing pricing). Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
||||
- **Economy consumption (P1d)**: market-tick prices new NPC listings from the ledger — `price = max(1, round(baseBuyPrice × priceModifier))` (neutral 1 when no market-state doc) then `applyNpcPriceModifier` (vendor's own modifier); the old `autoPrice` ±15% randomness is **removed**. Restock quantity scales down under shortage via `scaleQuantityForShortage` (bounded to a 0.25 factor floor, never below 1). `buyListing` ingests real demand: purchases of NPC/vendor stock (`seller == null`) call `recordPurchase` (fire-and-forget, `src/lib/economy/consumption.ts` — quantity-weighted `realDemand` increment). GM tools: `resetEconomyState` server action (permission `market-state:update`, rebuilds the doc from baselines, emits `economy:reset`; per-listing "Reset economy" button gated by `canResetEconomy` on the market page) and `getReferencePrices` (modifier-applied reference price shown in `CreateListingDialog`'s tip when it differs from the resting valuation). Tests: `tests/int/economy-consumption.int.spec.ts`. Notifies clients via the same SSE path as the other ticks (`source: "economy-tick"`).
|
||||
- **Mission auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Sweeps missions whose status is `Scheduled`/`Active` and whose scheduled date (`classification.startDateTime`) has fully passed — from the start of the following **server-local** day — and sets them to `Completed`, emitting a `mission:auto-complete` event per mission (logic: `src/lib/intelligence/missionLifecycle.ts`, test: `tests/int/mission-lifecycle.int.spec.ts`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **Server presence**: `bun run payload server-tick` — bin registered like the others (needs external cron, every minute). Flips `game-servers` docs that claim `status: "online"` but whose last heartbeat (`lastSeenAt`) is older than 180s to `offline`, including online docs with no `lastSeenAt` at all; emits a `server:offline` event per flipped server (logic: `src/lib/arma-bridge/presence.ts`, test: `tests/int/server-presence.int.spec.ts`). Idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **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.
|
||||
|
|
@ -357,6 +359,8 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
|||
|
||||
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
||||
|
||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch` and push it together with the work. The command itself creates the `vX.Y.Z` version commit **and** a local git tag, but plain `git push` does **not** push tags (`push.followTags` is not set in this repo) — push the tag explicitly with `git push && git push --tags` (or `git push --follow-tags`). The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
|
||||
|
|
|
|||
98
docs/economy/supply-demand-design.md
Normal file
98
docs/economy/supply-demand-design.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Supply & Demand Economy — Phase 1 Design
|
||||
|
||||
Status: **Approved direction** (decisions confirmed with product owner, 2026-09-06). Not yet implemented.
|
||||
|
||||
## Confirmed decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Supply model | **Abstract per-item ledger** (`market-state` collection), GM baseline + transaction-driven adjustments. Never derived by scanning physical stockpiles. |
|
||||
| Item scope | **Resources + tradeable assets only.** Vehicles deferred to a later phase. |
|
||||
| Pricing reach | **New NPC listings only** + displayed reference price. Existing listings, player listings, and open negotiations are never repriced. |
|
||||
| Simulation cadence | **New `economy-tick` bin** (external cron, idempotent, SSE notify). `market-tick` consumes the resulting state. |
|
||||
| GM surface | **Minimal per-item** (baseline supply, baseline demand, price band, enable toggle) on Resources/Assets; global knobs on GameRules. |
|
||||
|
||||
## Current-state facts this builds on
|
||||
|
||||
- Price anchors: `Assets.marketTrading.perceivedValue.baseBuyPrice/baseSellPrice` (`src/lib/market/index.ts` — `getBaseBuyPrice`/`getBaseSellPrice`), `Resources.baseValue`, `Structures.sellPrice`.
|
||||
- NPC listing generation: `src/scripts/marketTick.ts` — `autoPrice` (±15%) × `vendor.priceModifier`, `minPrice = price × 0.6`.
|
||||
- Real-demand signal sources that already exist: `bank-transactions` (type `payment`, memo carries asset + qty), `market-listings` (sold/buyer/soldAt), `market:sale` event logs with structured data.
|
||||
- Unwired production engine: `Structures.autoGeneratedResources[]` (min/max amount, frequency, produce chance) — schema exists, no tick consumes it.
|
||||
- Tick bin pattern: `bin[]` registration in `src/payload.config.ts`, `src/scripts/<key>.ts` exporting `script`, `createBinLogger` (`src/scripts/lib/binFileLogger.ts`), SSE notify via `/api/game-tick/notify` with `x-game-tick-secret`.
|
||||
- Negotiation system invariants to protect: NPC stance anchored to asking price, `minPrice` floor, patience meter, strictly-increasing buyer offers (`src/lib/market/negotiations.ts`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. `market-state` collection (new) — the supply/demand ledger
|
||||
|
||||
One doc per item (`itemType: "resource" | "asset"`, `itemId`). Fields:
|
||||
|
||||
- `gmBaselineSupply`, `gmBaselineDemand` — numbers, GM-set (copied from per-item fields on first creation).
|
||||
- `currentSupply` — adjusted by: unit purchases from NPC listings (decrement), unit sales to NPC buyers (increment, later phase), production events (increment).
|
||||
- `artificialDemand` — slow-drifting NPC pressure, updated per tick (random walk bounded around `gmBaselineDemand`).
|
||||
- `realDemand` — decaying rolling aggregate of completed unit purchases (quantity-weighted), decayed each tick by a GameRules decay factor.
|
||||
- `priceModifier` — computed, bounded: derived from demand-vs-supply ratio, clamped to the item's band (`±maxModifier`).
|
||||
- `lastComputedAt`.
|
||||
|
||||
### 2. Per-item GM fields (Resources + Assets)
|
||||
|
||||
- `economy.enabled` (default true for tradeable items)
|
||||
- `economy.baselineSupply`, `economy.baselineDemand` (numbers; defaults derived from rarity)
|
||||
- `economy.maxModifier` (price band, e.g. 0.5 → price can move ±50% of base)
|
||||
|
||||
### 3. GameRules global knobs (new group `economy`)
|
||||
|
||||
- `economyTickIntervalHint` (docs for cron), `realDemandWindowTicks`, `realDemandDecay`, `artificialDemandDrift`, `coldStartBehavior` (use baseline until N observations), global price band default, `economyEnabled` master gate (pattern: `staffHiringEnabled`).
|
||||
|
||||
### 4. `economy-tick` bin (new)
|
||||
|
||||
`src/scripts/economyTick.ts` + logic in `src/lib/economy/`:
|
||||
|
||||
1. For each enabled market-state doc (creating missing ones from GM baselines): decay `realDemand`, drift `artificialDemand`, recompute `priceModifier` from `demand / supply` ratio, clamp to band.
|
||||
2. Cold start: items with no purchase observations keep `priceModifier = 1` (neutral).
|
||||
3. Emits a batch summary `economy:tick` event + per-item `economy:price-change` events only when the modifier crosses a visible threshold (avoid event spam).
|
||||
4. SSE notify (same path as other bins). Idempotent: a re-run recomputes the same values from stored state; drift steps are seeded deterministically per period.
|
||||
|
||||
### 5. Consumption by market-tick
|
||||
|
||||
When generating a new NPC auto listing, price becomes:
|
||||
|
||||
```
|
||||
price = round(baseBuyPrice × priceModifier × vendor.priceModifier)
|
||||
```
|
||||
|
||||
`autoPrice` ±15% randomness is dropped (the modifier replaces it) — `minPrice = price × 0.6`, `desiredPrice = price` unchanged, so negotiation invariants are untouched. A "reference price" (modifier-applied base) is displayed on item views for player pricing guidance.
|
||||
|
||||
Shortage behavior (phase 1): modifier raises prices and NPC restock quantity scales down when `supply < demand` (bounded, never zero without a GM flag).
|
||||
|
||||
### 6. Real-demand ingestion
|
||||
|
||||
Hook points (no new collection needed in phase 1 — read from existing records):
|
||||
|
||||
- `buyListing` (`src/lib/market/index.ts`) — on successful purchase of an NPC/vendor listing, increment `realDemand` on that item's market-state (quantity-weighted).
|
||||
- Player-to-player sales do **not** move prices in phase 1 (no double counting).
|
||||
|
||||
## Out of scope (explicit deferrals)
|
||||
|
||||
NPC faction economies, autonomous NPC buyers with wallets, production/crafting chains, vehicle market, physical-stock reconciliation, multi-currency, repricing existing listings/negotiations, real-time price streaming (refresh-on-tick only — SSE bus is single-instance).
|
||||
|
||||
## Migration & rollout
|
||||
|
||||
- New collection + fields = **named migration** (`bun run payload migrate:create add_market_economy_state`), registered in `migrations/index.ts`; dev applies via `bun run payload migrate` (`push: false`). Never `db push`.
|
||||
- Existing listings stay at their prices; modifier applies only to newly generated listings. `economyEnabled` master flag allows instant disable.
|
||||
- Regenerate `src/payload-types.ts` after schema changes.
|
||||
|
||||
## Top risks (from design review)
|
||||
|
||||
1. **Non-atomic mutations** — `buyListing` already credits locker before clearing payment; new flows must not compound this. Define failure/retry behavior before adding ledger writes.
|
||||
2. **Negotiation breakage** — never reprice under open negotiations (phase 1 avoids entirely by only touching new listings).
|
||||
3. **Ledger/physical divergence** — abstract supply will not track destroyed/admin-edited stock; GM can reset per-item state from the admin panel (rebuild from baselines).
|
||||
4. **Tick duplication** — external cron may retry; idempotency + deterministic period boundaries required.
|
||||
5. **Scope explosion** — every extension (factions, vehicles, production) is a separate approved phase.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. **P1a — schema**: `market-state` collection, per-item economy fields, GameRules knobs, named migration, generated types.
|
||||
2. **P1b — engine**: `src/lib/economy/` (modifier math, decay, drift, cold start) + unit-testable pure functions.
|
||||
3. **P1c — tick**: `economy-tick` bin + event types + SSE wiring.
|
||||
4. **P1d — consumption**: market-tick uses modifier for new NPC listings; restock scaling; reference price display; `buyListing` real-demand ingestion; GM reset action.
|
||||
|
|
@ -7,7 +7,7 @@ when writing new entries.
|
|||
## Inline Formatting
|
||||
|
||||
Body text supports **bold**, *italic*, ***bold italic***, ~~strikethrough~~,
|
||||
and `inline code`. You can also link out — internally with wikilinks like
|
||||
and `inline code`. You can link internally with wikilinks like
|
||||
[[Campaigns]], [[Missions|mission index]], and [[Factions|faction dossiers]],
|
||||
or externally to the [Arma 3 website](https://arma3.com).
|
||||
|
||||
|
|
@ -46,11 +46,11 @@ cover most editorial needs.
|
|||
|
||||
> "We don't fight for the flag on the map. We fight so the person to your
|
||||
> left goes home."
|
||||
> — Colonel Vasquez, briefing before Ashfall
|
||||
> Colonel Vasquez, briefing before Ashfall
|
||||
|
||||
## Code
|
||||
|
||||
Inline commands like `hint "Hello world";` sit comfortably in prose. Fenced
|
||||
Inline commands like `hint "Hello world";` can appear in body text. Fenced
|
||||
blocks get their own container:
|
||||
|
||||
```sqf
|
||||
|
|
@ -80,14 +80,14 @@ briefs.
|
|||
|
||||
#### This is an H4
|
||||
|
||||
H4 is the deepest level — use it for granular notes like per-team frequency
|
||||
H4 is the deepest level. Use it for granular notes like per-team frequency
|
||||
assignments.
|
||||
|
||||
## Callouts
|
||||
|
||||
:::note
|
||||
Night operations require NVGs distributed at gear check. Ask logistics the
|
||||
evening before — supply will not break out NVGs after 2100.
|
||||
evening before; supply will not break out NVGs after 2100.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
|
@ -97,7 +97,7 @@ escort. Last sweep found three APERS bouncing Betties at the tree line.
|
|||
|
||||
## Infobox
|
||||
|
||||
The infobox is a template — insert `{{Infobox}}` near the top of an article
|
||||
The infobox is a template. Insert `{{Infobox}}` near the top of an article
|
||||
body (after creating the template from `docs/wiki-infobox-template.md`) and
|
||||
it renders as a floated right-hand info card:
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ it renders as a floated right-hand info card:
|
|||
| Conflict | Southern Campaign |
|
||||
| Theater | Altis, northern coast |
|
||||
| Dates | 12–19 Mar 2035 |
|
||||
| Outcome | Decisive — airfield secured |
|
||||
| Outcome | Decisive, airfield secured |
|
||||
| Strength | 2x fireteams, 2x rotary |
|
||||
|
||||
A week-long clearing operation to seize the northern airfield and deny
|
||||
|
|
@ -125,13 +125,13 @@ lead section. On narrow screens the card drops to full width.
|
|||
|
||||
::::columns
|
||||
:::column
|
||||
**Left column — Ground plan.** Alpha and Bravo advance along the western
|
||||
**Left column: Ground plan.** Alpha and Bravo advance along the western
|
||||
tree line, using the low rock walls as cover. Expect contact at the first
|
||||
stone bridge.
|
||||
:::
|
||||
|
||||
:::column
|
||||
**Right column — Air plan.** Reaper 6 orbits south at 800 m for overwatch.
|
||||
**Right column: Air plan.** Reaper 6 orbits south at 800 m for overwatch.
|
||||
Dagger 1 holds at Landing Zone Hawk until the ground element calls the
|
||||
push.
|
||||
:::
|
||||
|
|
@ -159,7 +159,7 @@ green panel.
|
|||
|
||||
Captioned images render the caption as a hover title:
|
||||
|
||||

|
||||

|
||||
|
||||
## Footnotes
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ Use a rule to separate major sections of long articles.
|
|||
- Image uploads happen through the editor toolbar (this demo links an
|
||||
existing file).
|
||||
- `{{Template}}` placeholders expand at render time, but only if a template
|
||||
with a matching name exists in the wiki template list — this article
|
||||
with a matching name exists in the wiki template list. This article
|
||||
intentionally skips one so you can see the fallback behavior with
|
||||
`{{NonexistentTemplate}}`.
|
||||
- HTML is not rendered; raw tags appear as literal text.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import {
|
|||
} from "@/lib/market";
|
||||
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
|
||||
import { getMainCurrencyId } from "@/lib/banking";
|
||||
import { getBaseBuyPrice } from "@/lib/market";
|
||||
import { resetMarketStateDocs } from "@/lib/economy/consumption";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import {
|
||||
expireNegotiationsForListing,
|
||||
|
|
@ -999,3 +1001,74 @@ export async function cancelMarketOffer(negotiationId: number): Promise<ActionRe
|
|||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReferencePrices(
|
||||
assetIds: number[],
|
||||
): Promise<ActionResult<Record<number, number | null>>> {
|
||||
try {
|
||||
const { payload } = await authenticate();
|
||||
const ids = [...new Set(assetIds.filter((id) => Number.isInteger(id) && id > 0))];
|
||||
if (ids.length === 0) return { success: true, data: {} };
|
||||
|
||||
const assets = await payload.find({
|
||||
collection: "assets",
|
||||
where: { id: { in: ids } },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const states = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { in: ids } }] },
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const modifierByItemId = new Map(
|
||||
states.docs.map((doc) => [doc.itemId as number, doc.priceModifier as number]),
|
||||
);
|
||||
|
||||
const references: Record<number, number | null> = {};
|
||||
for (const asset of assets.docs) {
|
||||
const base = getBaseBuyPrice(asset);
|
||||
const modifier = modifierByItemId.get(asset.id);
|
||||
references[asset.id] =
|
||||
base == null ? null : Math.max(1, Math.round(base * (modifier ?? 1)));
|
||||
}
|
||||
return { success: true, data: references };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetEconomyState(
|
||||
itemType: "resource" | "asset",
|
||||
itemId: number,
|
||||
): Promise<ActionResult<{ reset: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const allowed = await hasPermission(payload, user, "market-state:update");
|
||||
if (!allowed) {
|
||||
return { success: false, error: "You do not have permission to reset market state." };
|
||||
}
|
||||
|
||||
const result = await resetMarketStateDocs(payload, { items: [{ itemType, itemId }] });
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.EconomyReset,
|
||||
message: `Market state reset for ${itemType} #${itemId} (${result.reset} doc(s) rebuilt from baselines).`,
|
||||
targetCollection: "market-state",
|
||||
targetId: itemId,
|
||||
data: { itemType, itemId, reset: result.reset },
|
||||
});
|
||||
return { success: true, data: { reset: result.reset } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export default async function MarketPage() {
|
|||
});
|
||||
|
||||
const isManager = user ? await hasPermission(payload, user, "logistics:manage") : false;
|
||||
const canResetEconomy = user ? await hasPermission(payload, user, "market-state:update") : false;
|
||||
const currencyConfig = await getCurrencyConfig(payload);
|
||||
|
||||
return (
|
||||
|
|
@ -99,6 +100,7 @@ export default async function MarketPage() {
|
|||
currentUserId={userId}
|
||||
currencyConfig={currencyConfig}
|
||||
isManager={isManager}
|
||||
canResetEconomy={canResetEconomy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
62
src/app/(frontend)/voiceover/[id]/page.tsx
Normal file
62
src/app/(frontend)/voiceover/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ArrowLeftIcon, MicIcon } from "lucide-react";
|
||||
|
||||
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
|
||||
import { getScriptForUser } from "@/lib/voiceover";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Record voiceover — Polaris Task Force",
|
||||
description: "Read a Polaris Task Force voiceover script and submit a recording.",
|
||||
};
|
||||
|
||||
export default async function VoiceoverScriptPage({
|
||||
params,
|
||||
}: {
|
||||
readonly params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const scriptId = Number(id);
|
||||
if (!Number.isInteger(scriptId) || scriptId < 1) notFound();
|
||||
|
||||
const headers = await nextHeaders();
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
redirect(`/login?returnTo=${encodeURIComponent(`/voiceover/${id}`)}`);
|
||||
}
|
||||
|
||||
const script = await getScriptForUser(payload, user, scriptId);
|
||||
if (!script) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||
<Link
|
||||
href="/voiceover"
|
||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3" />
|
||||
Voiceover scripts
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{script.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Read the script at your pace, then submit one clear take for review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VoiceoverRecorder script={script} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
src/app/(frontend)/voiceover/actions.ts
Normal file
303
src/app/(frontend)/voiceover/actions.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import type { VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */
|
||||
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({
|
||||
headers: hdrs,
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
async function fetchScript(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
id: number,
|
||||
): Promise<VoiceScript | null> {
|
||||
try {
|
||||
return await payload.findByID({
|
||||
collection: "voice-scripts",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubmission(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
id: number,
|
||||
): Promise<VoiceSubmission | null> {
|
||||
try {
|
||||
return await payload.findByID({
|
||||
collection: "voice-submissions",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitVoiceRecording(
|
||||
scriptId: number,
|
||||
formData: FormData,
|
||||
): Promise<ActionResult<{ id: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const script = await fetchScript(payload, scriptId);
|
||||
if (!script) {
|
||||
return { success: false, error: "Voiceover script not found." };
|
||||
}
|
||||
if (script.status !== "open") {
|
||||
return { success: false, error: "This script is closed to new submissions." };
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return { success: false, error: "No audio file provided." };
|
||||
}
|
||||
if (!file.type.startsWith("audio/")) {
|
||||
return { success: false, error: "Only audio files can be submitted." };
|
||||
}
|
||||
if (file.size > MAX_AUDIO_BYTES) {
|
||||
return { success: false, error: "Audio recordings must be 9 MB or smaller." };
|
||||
}
|
||||
|
||||
const submission = await payload.create({
|
||||
collection: "voice-submissions",
|
||||
data: {
|
||||
script: scriptId,
|
||||
user: user.id,
|
||||
status: "pending",
|
||||
},
|
||||
file: {
|
||||
data: Buffer.from(await file.arrayBuffer()),
|
||||
mimetype: file.type,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.VoiceoverSubmit,
|
||||
message: `${user.displayName ?? user.username} submitted a recording for "${script.title}"`,
|
||||
actor: user.id,
|
||||
targetCollection: "voice-submissions",
|
||||
targetId: submission.id,
|
||||
data: { scriptId, scriptTitle: script.title },
|
||||
});
|
||||
|
||||
return { success: true, data: { id: submission.id } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function withdrawSubmission(
|
||||
submissionId: number,
|
||||
): Promise<ActionResult<{ ok: true }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const submission = await fetchSubmission(payload, submissionId);
|
||||
if (!submission) {
|
||||
return { success: false, error: "Submission not found." };
|
||||
}
|
||||
if (submission.user !== user.id) {
|
||||
return { success: false, error: "You can only withdraw your own submissions." };
|
||||
}
|
||||
if (submission.status !== "pending") {
|
||||
return { success: false, error: "Only pending submissions can be withdrawn." };
|
||||
}
|
||||
|
||||
await payload.delete({
|
||||
collection: "voice-submissions",
|
||||
id: submissionId,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return { success: true, data: { ok: true } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewVoiceSubmission(
|
||||
submissionId: number,
|
||||
decision: "accepted" | "rejected",
|
||||
reviewNote?: string,
|
||||
): Promise<ActionResult<{ id: number; status: string }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
if (!hasRoles(["admin", "developer"], user)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only admins can review voiceover submissions.",
|
||||
};
|
||||
}
|
||||
|
||||
const submission = await fetchSubmission(payload, submissionId);
|
||||
if (!submission) {
|
||||
return { success: false, error: "Submission not found." };
|
||||
}
|
||||
if (submission.status !== "pending") {
|
||||
return { success: false, error: "This submission has already been reviewed." };
|
||||
}
|
||||
|
||||
const script = await fetchScript(
|
||||
payload,
|
||||
typeof submission.script === "number" ? submission.script : submission.script.id,
|
||||
);
|
||||
if (!script) {
|
||||
return { success: false, error: "The script for this submission no longer exists." };
|
||||
}
|
||||
|
||||
const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id;
|
||||
|
||||
// Resolve the profile before mutating anything so a missing profile fails
|
||||
// the review cleanly instead of leaving the submission accepted without XP.
|
||||
let profileId: number | undefined;
|
||||
let progression: {
|
||||
xp?: unknown;
|
||||
voiceover?: { xpEarned?: number };
|
||||
} = {};
|
||||
if (decision === "accepted") {
|
||||
const profileRes = await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: submitterId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const profile = profileRes.docs[0];
|
||||
if (!profile) {
|
||||
return { success: false, error: "No profile found for the submitter." };
|
||||
}
|
||||
profileId = profile.id;
|
||||
progression = (profile.progression ?? {}) as {
|
||||
xp?: unknown;
|
||||
voiceover?: { xpEarned?: number };
|
||||
};
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "voice-submissions",
|
||||
id: submissionId,
|
||||
data: {
|
||||
status: decision,
|
||||
reviewNote: reviewNote ?? null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
if (decision === "accepted" && profileId !== undefined) {
|
||||
const voiceover = progression.voiceover ?? {};
|
||||
const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount;
|
||||
// Strip the virtual `xp` field before writing (computed, not stored).
|
||||
const { xp: _xp, ...progressionRest } = progression;
|
||||
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profileId,
|
||||
data: {
|
||||
progression: {
|
||||
...progressionRest,
|
||||
voiceover: {
|
||||
...voiceover,
|
||||
xpEarned,
|
||||
},
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
broadcastToUser("profile-update", submitterId, {
|
||||
reason: "xp",
|
||||
xpGained: script.xpAmount,
|
||||
profileId,
|
||||
});
|
||||
await notifyUser(payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:accepted",
|
||||
title: "Voiceover accepted",
|
||||
message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
} else {
|
||||
await notifyUser(payload, {
|
||||
userId: submitterId,
|
||||
type: "voiceover:rejected",
|
||||
title: "Voiceover rejected",
|
||||
message: `Your recording for "${script.title}" was not accepted this time.`,
|
||||
link: "/voiceover",
|
||||
});
|
||||
}
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected,
|
||||
message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`,
|
||||
actor: user.id,
|
||||
targetCollection: "voice-submissions",
|
||||
targetId: submissionId,
|
||||
data: {
|
||||
scriptId: script.id,
|
||||
scriptTitle: script.title,
|
||||
submitterId,
|
||||
decision,
|
||||
reviewNote: reviewNote ?? null,
|
||||
xpAwarded: decision === "accepted" ? script.xpAmount : 0,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: { id: submissionId, status: decision } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
174
src/app/(frontend)/voiceover/page.tsx
Normal file
174
src/app/(frontend)/voiceover/page.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { ArrowRightIcon, CheckCircle2Icon, Clock3Icon, MicIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Voiceover — Polaris Task Force",
|
||||
description: "Record voiceover takes for active Polaris Task Force scripts.",
|
||||
};
|
||||
|
||||
const submissionStatusClasses: Record<string, string> = {
|
||||
pending: "border-amber-500/40 text-amber-400",
|
||||
accepted: "border-emerald-500/40 text-emerald-400",
|
||||
rejected: "border-red-500/40 text-red-400",
|
||||
};
|
||||
|
||||
function ScriptAvailability({ status }: { readonly status: string }) {
|
||||
const isOpen = status === "open";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 gap-1",
|
||||
isOpen ? "border-emerald-500/40 text-emerald-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isOpen ? <CheckCircle2Icon className="size-3" /> : <Clock3Icon className="size-3" />}
|
||||
{isOpen ? "Open" : "Closed"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionStatuses({ script }: { readonly script: VoiceoverScriptView }) {
|
||||
if (script.mySubmissions.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No takes submitted yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" aria-label="Your take statuses">
|
||||
{script.mySubmissions.map((submission, index) => (
|
||||
<Badge
|
||||
key={submission.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"font-normal capitalize",
|
||||
submissionStatusClasses[submission.status] ?? "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Take {index + 1}: {submission.status}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
|
||||
const isOpen = script.status === "open";
|
||||
|
||||
return (
|
||||
<Card className={cn("flex h-full flex-col", !isOpen && "opacity-75")}>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<CardTitle className="min-w-0">
|
||||
{isOpen ? (
|
||||
<Link
|
||||
href={`/voiceover/${script.id}`}
|
||||
className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{script.title}
|
||||
</Link>
|
||||
) : (
|
||||
script.title
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>Voiceover script #{script.id}</CardDescription>
|
||||
</div>
|
||||
<ScriptAvailability status={script.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
||||
<SparklesIcon className="size-3" />+{script.xpAmount} XP
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{script.otherSubmissionCount} other submission
|
||||
{script.otherSubmissionCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Your submissions
|
||||
</span>
|
||||
<SubmissionStatuses script={script} />
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<Button asChild className="mt-auto w-full sm:w-fit">
|
||||
<Link href={`/voiceover/${script.id}`}>
|
||||
<MicIcon data-icon="inline-start" />
|
||||
Record a take
|
||||
<ArrowRightIcon data-icon="inline-end" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-auto text-sm text-muted-foreground">
|
||||
This script is closed to new takes.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function VoiceoverPage() {
|
||||
const headers = await nextHeaders();
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!isPayloadUser(user)) {
|
||||
redirect(`/login?returnTo=${encodeURIComponent("/voiceover")}`);
|
||||
}
|
||||
|
||||
const scripts = await listScriptsForUser(payload, user);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MicIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Voiceover submissions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose an active script, read it in the teleprompter, and submit your best take.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scripts.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
|
||||
{scripts.map((script) => (
|
||||
<ScriptCard key={script.id} script={script} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 p-8 text-center">
|
||||
<MicIcon className="size-8 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold">No scripts available</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New voiceover opportunities will appear here when they are published.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Loadouts", value: "loadouts" },
|
||||
{ label: "Market Listings", value: "market-listings" },
|
||||
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||
{ label: "Market State", value: "market-state" },
|
||||
{ label: "Evaluations", value: "evaluations" },
|
||||
{ label: "Tickets", value: "tickets" },
|
||||
{ label: "Wiki Pages", value: "wiki-pages" },
|
||||
|
|
@ -31,6 +32,8 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Wiki Templates", value: "wiki-templates" },
|
||||
{ label: "Game Servers", value: "game-servers" },
|
||||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||
{ label: "Voice Scripts", value: "voice-scripts" },
|
||||
{ label: "Voice Submissions", value: "voice-submissions" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
|
|
@ -141,5 +141,88 @@ export const GameRules: GlobalConfig = {
|
|||
"Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "economy",
|
||||
type: "group",
|
||||
label: "Market Economy",
|
||||
admin: {
|
||||
description:
|
||||
"Global knobs for the supply-and-demand market simulation run by the economy tick (docs/economy/supply-demand-design.md).",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
"Master gate for the market economy simulation. When off, the economy tick leaves all prices at their resting values.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaultPriceBand",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.5,
|
||||
admin: {
|
||||
description:
|
||||
"Default price band as a fraction of base price (e.g. 0.5 = price can move ±50%). Used when an item does not set its own band.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "realDemandWindowTicks",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 24,
|
||||
admin: {
|
||||
description:
|
||||
"How many economy ticks of completed-purchase history feed the real-demand signal.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "realDemandDecay",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.5,
|
||||
admin: {
|
||||
description:
|
||||
"Fraction of accumulated real demand that survives each tick (0.5 halves it every tick).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "artificialDemandDrift",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.05,
|
||||
admin: {
|
||||
description:
|
||||
"How far the NPC (artificial) demand random walk may move per tick, as a fraction of the GM baseline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "coldStartObservations",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 5,
|
||||
admin: {
|
||||
description:
|
||||
"Completed purchases needed before an item's price starts moving. Until then items stay at their resting price (modifier 1).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tickIntervalHintMinutes",
|
||||
type: "number",
|
||||
min: 1,
|
||||
defaultValue: 60,
|
||||
admin: {
|
||||
description:
|
||||
"Suggested economy tick cadence in minutes, for cron documentation only — the tick itself is triggered externally.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -398,9 +398,59 @@ export const Assets: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "crafting",
|
||||
type: "group",
|
||||
{
|
||||
name: "economy",
|
||||
type: "group",
|
||||
label: "Supply & Demand",
|
||||
admin: {
|
||||
description:
|
||||
"Baseline world supply and demand used by the market economy simulation (economy tick).",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description:
|
||||
"Whether the economy simulation tracks this asset. Disabled items keep their resting price.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "baselineSupply",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting world supply. Used as the starting point of this asset's market-state ledger.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "baselineDemand",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 50,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting demand (NPC faction pressure). Used as the starting point of this asset's market-state ledger.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "maxModifier",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Price band, as a fraction of the base price (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "crafting",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "craftable",
|
||||
|
|
|
|||
|
|
@ -254,6 +254,58 @@ export const Resources: CollectionConfig = {
|
|||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "economy",
|
||||
type: "group",
|
||||
label: "Supply & Demand",
|
||||
admin: {
|
||||
description:
|
||||
"Baseline world supply and demand used by the market economy simulation (economy tick).",
|
||||
condition: (_, siblingData) => siblingData?.tradeable === true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description:
|
||||
"Whether the economy simulation tracks this resource. Disabled items keep their resting price.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "baselineSupply",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting world supply. Used as the starting point of this resource's market-state ledger.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "baselineDemand",
|
||||
type: "number",
|
||||
min: 0,
|
||||
defaultValue: 50,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting demand (NPC faction pressure). Used as the starting point of this resource's market-state ledger.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "maxModifier",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Price band, as a fraction of the base value (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: "Measurement & Storage",
|
||||
type: "group",
|
||||
|
|
|
|||
121
src/collections/market/MarketState.ts
Normal file
121
src/collections/market/MarketState.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
/**
|
||||
* Abstract per-item supply/demand ledger, one doc per market-simulated item
|
||||
* (resource or tradeable asset). Maintained by the economy tick (overrideAccess)
|
||||
* and resettable by GMs from the admin panel. Never derived from physical
|
||||
* stockpiles — see docs/economy/supply-demand-design.md.
|
||||
*/
|
||||
export const MarketState: CollectionConfig = {
|
||||
slug: "market-state",
|
||||
admin: {
|
||||
group: "Market",
|
||||
description:
|
||||
"Supply/demand ledger per item, maintained by the economy tick. Baselines are copied from the item's Supply & Demand fields on first creation.",
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: requirePermission("market-state:create"),
|
||||
update: requirePermission("market-state:update"),
|
||||
delete: requirePermission("market-state:delete"),
|
||||
},
|
||||
indexes: [{ fields: ["itemType", "itemId"], unique: true }],
|
||||
fields: [
|
||||
{
|
||||
name: "itemType",
|
||||
type: "select",
|
||||
required: true,
|
||||
index: true,
|
||||
options: [
|
||||
{ label: "Resource", value: "resource" },
|
||||
{ label: "Asset", value: "asset" },
|
||||
],
|
||||
admin: {
|
||||
description: "Which collection this ledger entry tracks.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "itemId",
|
||||
type: "number",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "ID of the tracked resource or asset document.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gmBaselineSupply",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting supply. Copied from the item's Supply & Demand baseline when this entry is first created.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gmBaselineDemand",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 50,
|
||||
admin: {
|
||||
description:
|
||||
"GM-set resting demand. Copied from the item's Supply & Demand baseline when this entry is first created.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "currentSupply",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description:
|
||||
"Living abstract supply. Decremented by unit purchases from NPC listings, incremented by production/sales (later phases).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "artificialDemand",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 50,
|
||||
admin: {
|
||||
description:
|
||||
"NPC faction demand pressure. Slow-drifting random walk bounded around the GM baseline, updated per economy tick.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "realDemand",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
description:
|
||||
"Decaying rolling aggregate of completed unit purchases (quantity-weighted). Decayed each tick by the Game Rules decay factor.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "priceModifier",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
defaultValue: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Computed price multiplier applied to the item's base price when generating NEW NPC listings only (1 = resting price). Existing listings and open negotiations are never repriced.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lastComputedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
description: "When the economy tick last recomputed this entry.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -132,6 +132,7 @@ export const Profiles: CollectionConfig = {
|
|||
radioTraffic?: { xpEarned?: number | null };
|
||||
aimTrainer?: { xpEarned?: number | null };
|
||||
};
|
||||
voiceover?: { xpEarned?: number | null };
|
||||
};
|
||||
const awardsList = progressionData?.awards || [];
|
||||
const awardIds = awardsList.flatMap((entry) => {
|
||||
|
|
@ -145,6 +146,7 @@ export const Profiles: CollectionConfig = {
|
|||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||
|
||||
if (awardIds.length > 0) {
|
||||
await req.payload
|
||||
|
|
@ -186,6 +188,8 @@ export const Profiles: CollectionConfig = {
|
|||
xpAcc += eodXp;
|
||||
xpAcc += radioTrafficXp;
|
||||
xpAcc += aimTrainerXp;
|
||||
// Voiceover submissions: XP awarded per accepted submission
|
||||
xpAcc += voiceoverXp;
|
||||
|
||||
return xpAcc;
|
||||
},
|
||||
|
|
@ -563,6 +567,19 @@ export const Profiles: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "voiceover",
|
||||
label: "Voiceover",
|
||||
type: "group",
|
||||
fields: [
|
||||
{
|
||||
name: "xpEarned",
|
||||
label: "XP Earned",
|
||||
type: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
57
src/collections/voiceover/VoiceScripts.ts
Normal file
57
src/collections/voiceover/VoiceScripts.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
|
||||
export const VoiceScripts: CollectionConfig = {
|
||||
slug: "voice-scripts",
|
||||
admin: {
|
||||
group: "Voiceover",
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "xpAmount", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "script",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
"Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "xpAmount",
|
||||
label: "XP Amount",
|
||||
type: "number",
|
||||
required: true,
|
||||
defaultValue: 250,
|
||||
min: 0,
|
||||
admin: {
|
||||
description: "XP awarded per accepted submission of this script.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "open",
|
||||
options: [
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
],
|
||||
admin: {
|
||||
description: "Closed scripts reject new submissions",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
78
src/collections/voiceover/VoiceSubmissions.ts
Normal file
78
src/collections/voiceover/VoiceSubmissions.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import type { User } from "@/payload-types";
|
||||
import hasRoles from "@/utils/access-control/hasRoles";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const VoiceSubmissions: CollectionConfig = {
|
||||
slug: "voice-submissions",
|
||||
admin: {
|
||||
group: "Voiceover",
|
||||
useAsTitle: "id",
|
||||
defaultColumns: ["script", "user", "status", "createdAt"],
|
||||
},
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
// Elevation bypasses the owner scope so reviewers see every submission;
|
||||
// regular users only ever see their own.
|
||||
if (await isSuperuser(req.payload, user)) return true;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
return { user: { equals: user.id } };
|
||||
},
|
||||
create: ({ req, data }) => {
|
||||
const user = req.user as User | null;
|
||||
if (!user) return false;
|
||||
if (hasRoles(["admin", "developer"], user)) return true;
|
||||
// Submitters may only create their own submissions. Server actions that
|
||||
// write on behalf of users run with overrideAccess and set `user` themselves.
|
||||
const submittedUser =
|
||||
typeof data?.user === "object" && data?.user !== null ? data.user.id : data?.user;
|
||||
return submittedUser === user.id;
|
||||
},
|
||||
// Status changes are review actions — submitters must not edit their own docs.
|
||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
||||
delete: ({ req }) => hasRoles(["developer"], req.user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "script",
|
||||
type: "relationship",
|
||||
relationTo: "voice-scripts",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: "Submitter",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "pending",
|
||||
options: [
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Accepted", value: "accepted" },
|
||||
{ label: "Rejected", value: "rejected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reviewNote",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Optional admin review note.",
|
||||
},
|
||||
},
|
||||
],
|
||||
upload: {
|
||||
mimeTypes: ["audio/webm", "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a"],
|
||||
},
|
||||
timestamps: true,
|
||||
};
|
||||
|
|
@ -138,20 +138,10 @@ export function AppSidebar({
|
|||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto group-data-[collapsible=icon]:overflow-hidden">
|
||||
<NavCore label="Platform" items={data.navMain} />
|
||||
{hasIntelligence && (
|
||||
<NavCore
|
||||
label="Intelligence"
|
||||
items={data.navIntelligence.map((i) =>
|
||||
typeof i.items === "object" ? { ...i, items: [...i.items] } : { ...i },
|
||||
)}
|
||||
/>
|
||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||
)}
|
||||
{hasLogistics && (
|
||||
<NavCore
|
||||
label="Infrastructure"
|
||||
items={data.navLogistics.map((i) =>
|
||||
typeof i.items === "object" ? { ...i, items: [...i.items] } : { ...i },
|
||||
)}
|
||||
/>
|
||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||
)}
|
||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||
<NavCore label="Commerce" items={data.navCommerce} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FileTextIcon,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
Mic,
|
||||
Radio,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -170,6 +171,16 @@ export function NavUser({
|
|||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Contribute</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/voiceover">
|
||||
<Mic />
|
||||
Voiceover
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/account">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
import {
|
||||
Award,
|
||||
Backpack,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Building2,
|
||||
Car,
|
||||
Contact,
|
||||
Crosshair,
|
||||
Flag,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Package,
|
||||
Search,
|
||||
Send,
|
||||
ShoppingBag,
|
||||
Swords,
|
||||
TrendingUp,
|
||||
Truck,
|
||||
UserCog,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
export const sidebarData = {
|
||||
|
|
@ -33,56 +41,51 @@ export const sidebarData = {
|
|||
],
|
||||
navIntelligence: [
|
||||
{
|
||||
title: "Intelligence",
|
||||
url: "#",
|
||||
title: "Campaigns",
|
||||
url: "/intelligence/campaigns",
|
||||
icon: Swords,
|
||||
},
|
||||
{
|
||||
title: "Missions",
|
||||
url: "/intelligence/missions",
|
||||
icon: Crosshair,
|
||||
items: [
|
||||
{
|
||||
title: "Campaigns",
|
||||
url: "/intelligence/campaigns",
|
||||
},
|
||||
{
|
||||
title: "Missions",
|
||||
url: "/intelligence/missions",
|
||||
},
|
||||
{
|
||||
title: "Factions",
|
||||
url: "/intelligence/factions",
|
||||
},
|
||||
{
|
||||
title: "Wiki",
|
||||
url: "/wiki",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Factions",
|
||||
url: "/intelligence/factions",
|
||||
icon: Flag,
|
||||
},
|
||||
{
|
||||
title: "Wiki",
|
||||
url: "/wiki",
|
||||
icon: BookOpen,
|
||||
},
|
||||
],
|
||||
navLogistics: [
|
||||
{
|
||||
title: "Logistics",
|
||||
url: "#",
|
||||
icon: Warehouse,
|
||||
items: [
|
||||
{
|
||||
title: "Structures",
|
||||
url: "/logistics",
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
url: "/logistics/resources",
|
||||
},
|
||||
{
|
||||
title: "Vehicles",
|
||||
url: "/logistics/vehicles",
|
||||
},
|
||||
{
|
||||
title: "Shipments",
|
||||
url: "/logistics/shipments",
|
||||
},
|
||||
{
|
||||
title: "Deployed Vehicles",
|
||||
url: "/logistics/game-vehicles",
|
||||
},
|
||||
],
|
||||
title: "Structures",
|
||||
url: "/logistics",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
url: "/logistics/resources",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
title: "Vehicles",
|
||||
url: "/logistics/vehicles",
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
title: "Shipments",
|
||||
url: "/logistics/shipments",
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: "Deployed Vehicles",
|
||||
url: "/logistics/game-vehicles",
|
||||
icon: Car,
|
||||
},
|
||||
],
|
||||
navCommerce: [
|
||||
|
|
@ -99,19 +102,14 @@ export const sidebarData = {
|
|||
],
|
||||
navPersonnel: [
|
||||
{
|
||||
title: "Personnel",
|
||||
url: "#",
|
||||
title: "Contacts",
|
||||
url: "/personnel/contacts",
|
||||
icon: Contact,
|
||||
items: [
|
||||
{
|
||||
title: "Contacts",
|
||||
url: "/personnel/contacts",
|
||||
},
|
||||
{
|
||||
title: "Statistics",
|
||||
url: "/personnel/statistics",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Statistics",
|
||||
url: "/personnel/statistics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
navPersonal: [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Asset, LockerStorage } from "@/payload-types";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { resolveAssetId } from "@/lib/locker";
|
||||
import { getReferencePrices } from "@/app/(frontend)/logistics/market/actions";
|
||||
import { createMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
||||
|
||||
interface CreateListingDialogProps {
|
||||
|
|
@ -61,6 +62,7 @@ export function CreateListingDialog({
|
|||
const [minPrice, setMinPrice] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [referencePrices, setReferencePrices] = useState<Record<number, number | null>>({});
|
||||
|
||||
const sellable = useMemo<SellableAsset[]>(() => {
|
||||
if (!locker?.items) return [];
|
||||
|
|
@ -97,6 +99,19 @@ export function CreateListingDialog({
|
|||
const maxQuantity = selected?.quantity ?? 0;
|
||||
const hasLockerItems = !!locker?.items?.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || sellable.length === 0) return;
|
||||
const ids = sellable.filter((s) => s.tradeable && s.isLive).map((s) => s.assetId);
|
||||
if (ids.length === 0) return;
|
||||
let cancelled = false;
|
||||
getReferencePrices(ids).then((res) => {
|
||||
if (!cancelled && res.success && res.data) setReferencePrices(res.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, sellable]);
|
||||
|
||||
function reset() {
|
||||
setAssetId("");
|
||||
setQuantity("1");
|
||||
|
|
@ -277,6 +292,9 @@ export function CreateListingDialog({
|
|||
Tip
|
||||
</Badge>
|
||||
Resting valuation is {selected.basePrice.toLocaleString()} per unit.
|
||||
{referencePrices[selected.assetId] != null &&
|
||||
referencePrices[selected.assetId] !== selected.basePrice &&
|
||||
` NPC vendors are around ${referencePrices[selected.assetId]?.toLocaleString()} per unit.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { getAssetTypeLabel } from "@/lib/locker";
|
|||
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
||||
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
|
||||
import { buyMarketListing, cancelMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
||||
import { buyMarketListing, cancelMarketListing, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
|
||||
import { Countdown } from "@/components/frontend/market/Countdown";
|
||||
import { MakeOfferDialog } from "@/components/frontend/market/MakeOfferDialog";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
|
@ -30,6 +30,7 @@ interface ListingCardProps {
|
|||
currentUserId: number;
|
||||
currencyConfig: CurrencyConfig;
|
||||
canManage: boolean;
|
||||
canResetEconomy?: boolean;
|
||||
}
|
||||
|
||||
function npcLabel(listing: MarketListing): string {
|
||||
|
|
@ -56,9 +57,10 @@ export function ListingCard({
|
|||
currentUserId,
|
||||
currencyConfig,
|
||||
canManage,
|
||||
canResetEconomy = false,
|
||||
}: ListingCardProps) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<"buy" | "cancel" | null>(null);
|
||||
const [busy, setBusy] = useState<"buy" | "cancel" | "reset" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [qty, setQty] = useState(1);
|
||||
|
||||
|
|
@ -107,6 +109,19 @@ export function ListingCard({
|
|||
}
|
||||
}
|
||||
|
||||
async function handleResetEconomy() {
|
||||
setBusy("reset");
|
||||
setError(null);
|
||||
const assetId = typeof asset === "object" && asset ? asset.id : (listing.asset as number);
|
||||
const res = await resetEconomyState("asset", assetId);
|
||||
if (res.success) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error ?? "Failed to reset market state.");
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnail =
|
||||
typeof asset === "object" && asset && asset["Asset Metadata"]?.thumbnail
|
||||
? (asset["Asset Metadata"].thumbnail as { url?: string; width?: number; height?: number })
|
||||
|
|
@ -192,12 +207,25 @@ export function ListingCard({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{canCancel && (
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
|
||||
<XIcon className="size-4" />
|
||||
{busy === "cancel" ? "..." : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{canResetEconomy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleResetEconomy}
|
||||
disabled={busy !== null}
|
||||
title="Rebuild this item's supply/demand ledger from its GM baselines"
|
||||
>
|
||||
{busy === "reset" ? "..." : "Reset economy"}
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
|
||||
<XIcon className="size-4" />
|
||||
{busy === "cancel" ? "..." : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ interface MarketViewProps {
|
|||
currentUserId: number;
|
||||
currencyConfig: CurrencyConfig;
|
||||
isManager: boolean;
|
||||
canResetEconomy: boolean;
|
||||
}
|
||||
|
||||
export function MarketView({
|
||||
|
|
@ -74,6 +75,7 @@ export function MarketView({
|
|||
currentUserId,
|
||||
currencyConfig,
|
||||
isManager,
|
||||
canResetEconomy,
|
||||
}: MarketViewProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [rarity, setRarity] = useState("all");
|
||||
|
|
@ -183,6 +185,7 @@ export function MarketView({
|
|||
currentUserId={currentUserId}
|
||||
currencyConfig={currencyConfig}
|
||||
canManage={isManager}
|
||||
canResetEconomy={canResetEconomy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -211,6 +214,7 @@ export function MarketView({
|
|||
currentUserId={currentUserId}
|
||||
currencyConfig={currencyConfig}
|
||||
canManage={isManager}
|
||||
canResetEconomy={canResetEconomy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
373
src/components/frontend/voiceover/TeleprompterReader.tsx
Normal file
373
src/components/frontend/voiceover/TeleprompterReader.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AudioLinesIcon,
|
||||
GaugeIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
RotateCcwIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseEmotionTags } from "@/lib/voiceover/emotionTags";
|
||||
import {
|
||||
DEFAULT_WORDS_PER_MINUTE,
|
||||
MAX_WORDS_PER_MINUTE,
|
||||
MIN_WORDS_PER_MINUTE,
|
||||
buildPacingTimeline,
|
||||
} from "@/lib/voiceover/pacing";
|
||||
|
||||
const MIN_SCROLL_SPEED = 5;
|
||||
const MAX_SCROLL_SPEED = 100;
|
||||
const DEFAULT_SCROLL_SPEED = 20;
|
||||
|
||||
const TAG_COLORS = [
|
||||
{ text: "text-sky-200/75", underline: "decoration-sky-300/50", label: "text-sky-300/80" },
|
||||
{ text: "text-amber-200/75", underline: "decoration-amber-300/50", label: "text-amber-300/80" },
|
||||
{
|
||||
text: "text-emerald-200/75",
|
||||
underline: "decoration-emerald-300/50",
|
||||
label: "text-emerald-300/80",
|
||||
},
|
||||
{ text: "text-rose-200/75", underline: "decoration-rose-300/50", label: "text-rose-300/80" },
|
||||
{
|
||||
text: "text-violet-200/75",
|
||||
underline: "decoration-violet-300/50",
|
||||
label: "text-violet-300/80",
|
||||
},
|
||||
{ text: "text-teal-200/75", underline: "decoration-teal-300/50", label: "text-teal-300/80" },
|
||||
{
|
||||
text: "text-fuchsia-200/75",
|
||||
underline: "decoration-fuchsia-300/50",
|
||||
label: "text-fuchsia-300/80",
|
||||
},
|
||||
{
|
||||
text: "text-orange-200/75",
|
||||
underline: "decoration-orange-300/50",
|
||||
label: "text-orange-300/80",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function tagColor(tags: readonly string[]) {
|
||||
const key = tags.join(", ");
|
||||
let hash = 0;
|
||||
for (let index = 0; index < key.length; index += 1) {
|
||||
hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return TAG_COLORS[hash % TAG_COLORS.length];
|
||||
}
|
||||
|
||||
export function TeleprompterReader({ script }: { readonly script: string }) {
|
||||
const readerRef = useRef<HTMLDivElement | null>(null);
|
||||
const elapsedRef = useRef(0);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [scrollSpeed, setScrollSpeed] = useState(DEFAULT_SCROLL_SPEED);
|
||||
const [pacingEnabled, setPacingEnabled] = useState(false);
|
||||
const [wordsPerMinute, setWordsPerMinute] = useState(DEFAULT_WORDS_PER_MINUTE);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const segments = useMemo(() => parseEmotionTags(script), [script]);
|
||||
const timeline = useMemo(
|
||||
() => buildPacingTimeline(segments, wordsPerMinute),
|
||||
[segments, wordsPerMinute],
|
||||
);
|
||||
|
||||
const toggleScrolling = useCallback(() => {
|
||||
setIsScrolling((scrolling) => !scrolling);
|
||||
}, []);
|
||||
|
||||
const scrollMultiplier = useCallback(
|
||||
(elapsed: number) => {
|
||||
let multiplier = 1;
|
||||
for (const directive of timeline.scrollDirectives) {
|
||||
if (elapsed < directive.atMs) break;
|
||||
multiplier = directive.multiplier;
|
||||
}
|
||||
return multiplier;
|
||||
},
|
||||
[timeline.scrollDirectives],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScrolling) return;
|
||||
|
||||
let animationFrame = 0;
|
||||
let previousTime = performance.now();
|
||||
|
||||
const scroll = (now: number) => {
|
||||
const reader = readerRef.current;
|
||||
if (!reader) {
|
||||
setIsScrolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = now - previousTime;
|
||||
previousTime = now;
|
||||
|
||||
if (pacingEnabled) {
|
||||
elapsedRef.current += delta;
|
||||
setElapsedMs(elapsedRef.current);
|
||||
}
|
||||
|
||||
const effectiveSpeed =
|
||||
scrollSpeed * (pacingEnabled ? scrollMultiplier(elapsedRef.current) : 1);
|
||||
|
||||
const maxScrollTop = reader.scrollHeight - reader.clientHeight;
|
||||
const scrollFinished = maxScrollTop <= 0 || reader.scrollTop >= maxScrollTop;
|
||||
const pacingFinished = !pacingEnabled || elapsedRef.current >= timeline.totalMs;
|
||||
|
||||
if (scrollFinished && pacingFinished) {
|
||||
setIsScrolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scrollFinished) {
|
||||
reader.scrollTop = Math.min(
|
||||
maxScrollTop,
|
||||
reader.scrollTop + (effectiveSpeed * delta) / 1000,
|
||||
);
|
||||
}
|
||||
animationFrame = requestAnimationFrame(scroll);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(scroll);
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [isScrolling, scrollSpeed, pacingEnabled, timeline.totalMs, scrollMultiplier]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space" || event.repeat) return;
|
||||
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.isContentEditable ||
|
||||
["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(target.tagName))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
toggleScrolling();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleScrolling]);
|
||||
|
||||
function resetReader() {
|
||||
const reader = readerRef.current;
|
||||
if (reader) reader.scrollTop = 0;
|
||||
elapsedRef.current = 0;
|
||||
setElapsedMs(0);
|
||||
setIsScrolling(false);
|
||||
}
|
||||
|
||||
function handlePacingToggle(enabled: boolean) {
|
||||
elapsedRef.current = 0;
|
||||
setElapsedMs(0);
|
||||
setPacingEnabled(enabled);
|
||||
}
|
||||
|
||||
function handleWordsPerMinuteChange(value: string) {
|
||||
const next = Number(value);
|
||||
if (!Number.isFinite(next)) return;
|
||||
setWordsPerMinute(Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, next)));
|
||||
}
|
||||
|
||||
function renderSpokenText(
|
||||
segment: (typeof segments)[number],
|
||||
pacing: (typeof timeline.segmentPacing)[number],
|
||||
wordClass?: string,
|
||||
) {
|
||||
if (!pacingEnabled || !pacing || pacing.words.length === 0) {
|
||||
return segment.text;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pacing.leadingWhitespace}
|
||||
{pacing.words.map((word, wordIndex) => (
|
||||
<span
|
||||
key={`word-${wordIndex}`}
|
||||
className={cn(
|
||||
"transition-opacity duration-500",
|
||||
wordClass,
|
||||
elapsedMs >= word.endMs && "opacity-35",
|
||||
)}
|
||||
>
|
||||
{word.text}
|
||||
{word.whitespaceAfter}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSpeedChange(value: string) {
|
||||
const nextSpeed = Number(value);
|
||||
if (!Number.isFinite(nextSpeed)) return;
|
||||
setScrollSpeed(Math.min(MAX_SCROLL_SPEED, Math.max(MIN_SCROLL_SPEED, nextSpeed)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 border-b border-border">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>Teleprompter</CardTitle>
|
||||
<CardDescription>Press spacebar to start or pause the reader.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="voiceover-pacing"
|
||||
checked={pacingEnabled}
|
||||
onCheckedChange={handlePacingToggle}
|
||||
aria-label="Toggle pacing highlighter"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="voiceover-pacing"
|
||||
className="cursor-pointer text-xs text-muted-foreground"
|
||||
>
|
||||
Follow along
|
||||
</Label>
|
||||
</div>
|
||||
{pacingEnabled && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
id="voiceover-wpm"
|
||||
type="number"
|
||||
min={MIN_WORDS_PER_MINUTE}
|
||||
max={MAX_WORDS_PER_MINUTE}
|
||||
step={10}
|
||||
value={wordsPerMinute}
|
||||
onChange={(event) => handleWordsPerMinuteChange(event.target.value)}
|
||||
className="h-7 w-20 text-xs"
|
||||
aria-label="Speaking pace in words per minute"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">wpm</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="voiceover-scroll-speed" className="text-xs text-muted-foreground">
|
||||
Speed (px/s)
|
||||
</Label>
|
||||
<Input
|
||||
id="voiceover-scroll-speed"
|
||||
type="number"
|
||||
min={MIN_SCROLL_SPEED}
|
||||
max={MAX_SCROLL_SPEED}
|
||||
step={5}
|
||||
value={scrollSpeed}
|
||||
onChange={(event) => handleSpeedChange(event.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={toggleScrolling} aria-pressed={isScrolling}>
|
||||
{isScrolling ? (
|
||||
<PauseIcon data-icon="inline-start" />
|
||||
) : (
|
||||
<PlayIcon data-icon="inline-start" />
|
||||
)}
|
||||
{isScrolling ? "Pause" : "Start"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="icon" onClick={resetReader}>
|
||||
<RotateCcwIcon />
|
||||
<span className="sr-only">Reset teleprompter</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:p-6">
|
||||
<div
|
||||
ref={readerRef}
|
||||
tabIndex={0}
|
||||
aria-label="Voiceover script reader"
|
||||
className="max-h-[34rem] min-h-[24rem] overflow-y-auto scroll-auto rounded-md border border-border bg-background/70 px-4 py-8 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-8 sm:py-12"
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-xl leading-relaxed tracking-wide text-foreground sm:text-2xl">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.directive) {
|
||||
return (
|
||||
<span
|
||||
key={`directive-${index}`}
|
||||
className="my-3 flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-muted-foreground/70"
|
||||
>
|
||||
<SlidersHorizontalIcon className="size-3 shrink-0" aria-hidden />
|
||||
{segment.directive}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (segment.sfx) {
|
||||
return (
|
||||
<span
|
||||
key={`sfx-${index}`}
|
||||
className="my-6 flex items-center gap-1.5 text-xs italic text-muted-foreground"
|
||||
>
|
||||
<AudioLinesIcon className="size-3.5 shrink-0" aria-hidden />
|
||||
{segment.sfx}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (segment.tags.length === 0) {
|
||||
return (
|
||||
<span key={`text-${index}`}>
|
||||
{renderSpokenText(segment, timeline.segmentPacing[index])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const color = tagColor(segment.tags);
|
||||
const segmentPacing = timeline.segmentPacing[index];
|
||||
const pacingActive = pacingEnabled && segmentPacing !== undefined;
|
||||
|
||||
return (
|
||||
<span key={`tagged-${index}`} className="relative inline">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-3.5 left-0 whitespace-nowrap text-[10px] italic leading-none",
|
||||
color.label,
|
||||
)}
|
||||
aria-label={`Stage direction: ${segment.tags.join(", ")}`}
|
||||
>
|
||||
{segment.tags.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"underline-offset-4",
|
||||
color.text,
|
||||
!pacingActive && "underline",
|
||||
color.underline,
|
||||
)}
|
||||
>
|
||||
{renderSpokenText(
|
||||
segment,
|
||||
segmentPacing,
|
||||
pacingActive ? cn("underline", color.underline) : undefined,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<GaugeIcon className="size-3.5" />
|
||||
<span>Constant scroll speed · {scrollSpeed} px/s</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
425
src/components/frontend/voiceover/VoiceCapture.tsx
Normal file
425
src/components/frontend/voiceover/VoiceCapture.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { MicIcon, SquareIcon, UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
|
||||
const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
|
||||
|
||||
export type VoiceCaptureSource = Blob | File;
|
||||
|
||||
interface VoiceCaptureProps {
|
||||
readonly onSubmitTake: (source: VoiceCaptureSource) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, "0")}:${remainder.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function recordingErrorMessage(error: unknown): string {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
["NotAllowedError", "PermissionDeniedError", "SecurityError"].includes(error.name)
|
||||
) {
|
||||
return "Microphone access was denied. Allow microphone access in your browser settings, then try again.";
|
||||
}
|
||||
|
||||
return "The microphone could not be opened. Check that it is connected and available to this browser.";
|
||||
}
|
||||
|
||||
export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const meterAnimationRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [support, setSupport] = useState<"checking" | "supported" | "unsupported">("checking");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||
const [level, setLevel] = useState(0);
|
||||
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
|
||||
const [reviewUrl, setReviewUrl] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const stopLevelMeter = useCallback(() => {
|
||||
if (meterAnimationRef.current !== null) {
|
||||
cancelAnimationFrame(meterAnimationRef.current);
|
||||
meterAnimationRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
const context = audioContextRef.current;
|
||||
audioContextRef.current = null;
|
||||
if (context) void context.close();
|
||||
setLevel(0);
|
||||
}, []);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearReview = useCallback(() => {
|
||||
setReviewBlob(null);
|
||||
setReviewUrl(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSupport(
|
||||
typeof MediaRecorder !== "undefined" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices?.getUserMedia
|
||||
? "supported"
|
||||
: "unsupported",
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (mediaRecorderRef.current?.state === "recording") mediaRecorderRef.current.stop();
|
||||
stopLevelMeter();
|
||||
stopStream();
|
||||
};
|
||||
}, [stopLevelMeter, stopStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reviewUrl) return;
|
||||
return () => URL.revokeObjectURL(reviewUrl);
|
||||
}, [reviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const startedAt = recordingStartedAtRef.current;
|
||||
if (startedAt !== null) setRecordingSeconds(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 250);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isRecording]);
|
||||
|
||||
function startLevelMeter(stream: MediaStream) {
|
||||
if (typeof AudioContext === "undefined") return;
|
||||
|
||||
try {
|
||||
const context = new AudioContext();
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
context.createMediaStreamSource(stream).connect(analyser);
|
||||
const data = new Uint8Array(analyser.fftSize);
|
||||
audioContextRef.current = context;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const update = () => {
|
||||
if (!mountedRef.current || !analyserRef.current) return;
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (const value of data) {
|
||||
const normalized = (value - 128) / 128;
|
||||
sum += normalized * normalized;
|
||||
}
|
||||
setLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
|
||||
meterAnimationRef.current = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
void context.resume().catch(() => undefined);
|
||||
meterAnimationRef.current = requestAnimationFrame(update);
|
||||
} catch {
|
||||
setLevel(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (support !== "supported" || isRecording || isSubmitting) return;
|
||||
|
||||
setRecordingError(null);
|
||||
clearReview();
|
||||
setSelectedFile(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (!mountedRef.current) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported(PREFERRED_MIME_TYPE)
|
||||
? PREFERRED_MIME_TYPE
|
||||
: "";
|
||||
const recorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
streamRef.current = stream;
|
||||
mediaRecorderRef.current = recorder;
|
||||
recordingStartedAtRef.current = Date.now();
|
||||
setRecordingSeconds(0);
|
||||
setIsStopping(false);
|
||||
|
||||
recorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
});
|
||||
recorder.addEventListener("error", () => {
|
||||
if (mountedRef.current)
|
||||
setRecordingError("The recording could not be completed. Try again.");
|
||||
});
|
||||
recorder.addEventListener("stop", () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || mimeType || "audio/webm",
|
||||
});
|
||||
stopLevelMeter();
|
||||
stopStream();
|
||||
mediaRecorderRef.current = null;
|
||||
recordingStartedAtRef.current = null;
|
||||
if (!mountedRef.current) return;
|
||||
setReviewBlob(blob);
|
||||
setReviewUrl(URL.createObjectURL(blob));
|
||||
setIsRecording(false);
|
||||
setIsStopping(false);
|
||||
setLevel(0);
|
||||
});
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
startLevelMeter(stream);
|
||||
} catch (error) {
|
||||
setIsRecording(false);
|
||||
setIsStopping(false);
|
||||
setRecordingError(recordingErrorMessage(error));
|
||||
stopLevelMeter();
|
||||
stopStream();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
if (mediaRecorderRef.current?.state !== "recording") return;
|
||||
setIsStopping(true);
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
async function submitSource(source: VoiceCaptureSource) {
|
||||
if (source.size > MAX_AUDIO_BYTES) {
|
||||
toast.error("Audio files must be 9 MB or smaller.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const submitted = await onSubmitTake(source);
|
||||
setIsSubmitting(false);
|
||||
if (!submitted) return;
|
||||
|
||||
clearReview();
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (!file) {
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_AUDIO_BYTES) {
|
||||
toast.error("Audio files must be 9 MB or smaller.");
|
||||
event.target.value = "";
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
if (file.type && !file.type.startsWith("audio/")) {
|
||||
toast.error("Choose an audio file to submit.");
|
||||
event.target.value = "";
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
setRecordingError(null);
|
||||
setSelectedFile(file);
|
||||
}
|
||||
|
||||
async function handleReviewSubmit() {
|
||||
if (reviewBlob) await submitSource(reviewBlob);
|
||||
}
|
||||
|
||||
async function handleFileSubmit() {
|
||||
if (selectedFile) await submitSource(selectedFile);
|
||||
}
|
||||
|
||||
function handleRerecord() {
|
||||
clearReview();
|
||||
setRecordingError(null);
|
||||
}
|
||||
|
||||
const meterValue = Math.max(0.08, level);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle>Record your take</CardTitle>
|
||||
<CardDescription>
|
||||
Use your microphone or upload an existing audio file. Audio submissions are limited to 9
|
||||
MB.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{support === "unsupported" && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm"
|
||||
>
|
||||
This browser does not support microphone recording. Upload an audio file instead.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{support === "checking" && (
|
||||
<p className="text-sm text-muted-foreground">Checking microphone support…</p>
|
||||
)}
|
||||
|
||||
{support === "supported" && !reviewBlob && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full bg-muted-foreground",
|
||||
isRecording &&
|
||||
"bg-destructive motion-safe:animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{isRecording ? "Recording" : "Microphone ready"}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="font-mono text-sm tabular-nums text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{formatDuration(recordingSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label="Microphone level"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={1}
|
||||
aria-valuenow={isRecording ? level : 0}
|
||||
>
|
||||
<div
|
||||
className="h-full origin-left rounded-full bg-primary transition-transform motion-reduce:transition-none"
|
||||
style={{ transform: `scaleX(${isRecording ? meterValue : 0})` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isRecording ? (
|
||||
<Button type="button" onClick={() => void handleStart()} disabled={isSubmitting}>
|
||||
<MicIcon data-icon="inline-start" />
|
||||
Start recording
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<SquareIcon data-icon="inline-start" />
|
||||
{isStopping ? "Finishing…" : "Stop recording"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordingError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{recordingError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reviewBlob && reviewUrl && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Review your take</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Listen back before sending it for review.
|
||||
</p>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={reviewUrl}
|
||||
className="w-full"
|
||||
aria-label="Recorded take"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleReviewSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<UploadIcon data-icon="inline-start" />
|
||||
{isSubmitting ? "Submitting…" : "Submit take"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRerecord}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Re-record
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="voiceover-audio-file">Upload a file instead</Label>
|
||||
<p className="text-xs text-muted-foreground">Audio files only, up to 9 MB.</p>
|
||||
</div>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="voiceover-audio-file"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={isSubmitting || isRecording}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleFileSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Submitting…" : "Submit file"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
112
src/components/frontend/voiceover/VoiceoverRecorder.tsx
Normal file
112
src/components/frontend/voiceover/VoiceoverRecorder.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { VoiceoverScriptView } from "@/lib/voiceover";
|
||||
import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader";
|
||||
import {
|
||||
VoiceCapture,
|
||||
type VoiceCaptureSource,
|
||||
} from "@/components/frontend/voiceover/VoiceCapture";
|
||||
import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes";
|
||||
|
||||
export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
|
||||
const router = useRouter();
|
||||
const isOpen = script.status === "open";
|
||||
|
||||
async function handleSubmitTake(source: VoiceCaptureSource): Promise<boolean> {
|
||||
const formData = new FormData();
|
||||
if (source instanceof File) {
|
||||
formData.append("file", source, source.name);
|
||||
} else {
|
||||
formData.append("file", source, "take.webm");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitVoiceRecording(script.id, formData);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not submit your take.");
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Take submitted", {
|
||||
description: "Your recording is now pending review.",
|
||||
});
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Could not submit your take.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWithdraw(submissionId: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await withdrawSubmission(submissionId);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not withdraw this take.");
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Take withdrawn");
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Could not withdraw this take.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
|
||||
<SparklesIcon className="size-3" />+{script.xpAmount} XP
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
isOpen ? "gap-1 border-emerald-500/40 text-emerald-400" : "gap-1 text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{isOpen ? (
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
) : (
|
||||
<LockKeyholeIcon className="size-3" />
|
||||
)}
|
||||
{isOpen ? "Open for submissions" : "Closed to submissions"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{script.otherSubmissionCount} other submission
|
||||
{script.otherSubmissionCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<TeleprompterReader script={script.script} />
|
||||
|
||||
<VoiceoverTakes submissions={script.mySubmissions} onWithdraw={handleWithdraw} />
|
||||
</div>
|
||||
|
||||
<div className="lg:sticky lg:top-24">
|
||||
{isOpen ? (
|
||||
<VoiceCapture onSubmitTake={handleSubmitTake} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4 text-sm text-muted-foreground">
|
||||
<MicIcon className="size-4 shrink-0" />
|
||||
This script is closed. Existing takes remain available below for playback.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/frontend/voiceover/VoiceoverTakes.tsx
Normal file
116
src/components/frontend/voiceover/VoiceoverTakes.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, Clock3Icon, Trash2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { VoiceoverSubmissionSummary } from "@/lib/voiceover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VoiceoverTakesProps {
|
||||
readonly submissions: readonly VoiceoverSubmissionSummary[];
|
||||
readonly onWithdraw: (submissionId: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const statusMeta: Record<string, { readonly className: string; readonly icon: typeof Clock3Icon }> =
|
||||
{
|
||||
pending: {
|
||||
className: "border-amber-500/40 text-amber-400",
|
||||
icon: Clock3Icon,
|
||||
},
|
||||
accepted: {
|
||||
className: "border-emerald-500/40 text-emerald-400",
|
||||
icon: CheckIcon,
|
||||
},
|
||||
rejected: { className: "border-red-500/40 text-red-400", icon: XIcon },
|
||||
};
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps) {
|
||||
const [withdrawingId, setWithdrawingId] = useState<number | null>(null);
|
||||
|
||||
async function handleWithdraw(id: number) {
|
||||
setWithdrawingId(id);
|
||||
await onWithdraw(id);
|
||||
setWithdrawingId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle>My takes</CardTitle>
|
||||
<CardDescription>Your submitted recordings and their review status.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{submissions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not submitted a take for this script.
|
||||
</p>
|
||||
) : (
|
||||
submissions.map((submission, index) => {
|
||||
const meta = statusMeta[submission.status] ?? {
|
||||
className: "text-muted-foreground",
|
||||
icon: Clock3Icon,
|
||||
};
|
||||
const StatusIcon = meta.icon;
|
||||
const canWithdraw = submission.status === "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={submission.id}
|
||||
className="flex flex-col gap-3 rounded-md border border-border p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Take {index + 1}</span>
|
||||
<Badge variant="outline" className={cn("gap-1 capitalize", meta.className)}>
|
||||
<StatusIcon className="size-3" />
|
||||
{submission.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<time dateTime={submission.createdAt} className="text-xs text-muted-foreground">
|
||||
{formatDate(submission.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{submission.audioUrl ? (
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
src={submission.audioUrl}
|
||||
className="w-full"
|
||||
aria-label={`Playback for take ${index + 1}`}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Audio playback is unavailable.</p>
|
||||
)}
|
||||
|
||||
{canWithdraw && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void handleWithdraw(submission.id)}
|
||||
disabled={withdrawingId !== null}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
{withdrawingId === submission.id ? "Withdrawing…" : "Withdraw"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -215,6 +215,8 @@ export function WikiEditor({
|
|||
onLinkDialogChange={setLinkDialogOpen}
|
||||
pending={pending}
|
||||
templates={templates}
|
||||
templateCssMap={templateCssMap}
|
||||
templateHtmlMap={templateHtmlMap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ interface WikiEditorBodyProps {
|
|||
readonly onLinkDialogChange: (open: boolean) => void;
|
||||
readonly pending: boolean;
|
||||
readonly templates: TemplateMap;
|
||||
readonly templateCssMap?: Record<string, string>;
|
||||
readonly templateHtmlMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function WikiEditorBody({
|
||||
|
|
@ -74,6 +76,8 @@ export function WikiEditorBody({
|
|||
onLinkDialogChange,
|
||||
pending,
|
||||
templates,
|
||||
templateCssMap,
|
||||
templateHtmlMap,
|
||||
}: WikiEditorBodyProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -429,6 +433,8 @@ export function WikiEditorBody({
|
|||
onOpenChange={setTemplateDialogOpen}
|
||||
disabled={pending}
|
||||
onInsert={insertTemplateBody}
|
||||
templateCssMap={templateCssMap}
|
||||
templateHtmlMap={templateHtmlMap}
|
||||
/>
|
||||
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
|
||||
<div className="relative min-h-[23.5rem] w-full rounded-md border border-input bg-transparent shadow-sm transition-[box-shadow] focus-within:border-ring focus-within:ring-1 focus-within:ring-ring lg:h-[calc(100vh-10rem)]">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractPlaceholders, type TemplateMap } from "@/lib/wiki/templates";
|
||||
import { prepareMarkdown } from "@/lib/wiki/prepare";
|
||||
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
|
||||
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
|
||||
|
||||
interface WikiTemplateDialogProps {
|
||||
readonly templates: TemplateMap;
|
||||
|
|
@ -21,6 +24,8 @@ interface WikiTemplateDialogProps {
|
|||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly disabled?: boolean;
|
||||
readonly onInsert: (body: string) => void;
|
||||
readonly templateCssMap?: Record<string, string>;
|
||||
readonly templateHtmlMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
|
|
@ -48,9 +53,14 @@ export function WikiTemplateDialog({
|
|||
onOpenChange,
|
||||
disabled,
|
||||
onInsert,
|
||||
templateCssMap,
|
||||
templateHtmlMap,
|
||||
}: WikiTemplateDialogProps) {
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [previewMode, setPreviewMode] = useState<"rendered" | "source">(
|
||||
"rendered",
|
||||
);
|
||||
|
||||
const names = useMemo(() => Object.keys(templates).sort(), [templates]);
|
||||
const selectedBody = selectedName !== null ? (templates[selectedName] ?? "") : "";
|
||||
|
|
@ -62,11 +72,35 @@ export function WikiTemplateDialog({
|
|||
() => fillTemplate(selectedBody, values),
|
||||
[selectedBody, values],
|
||||
);
|
||||
const preparedPreview = useMemo(
|
||||
() =>
|
||||
selectedName !== null
|
||||
? prepareMarkdown(filled, templates, templateHtmlMap ?? {})
|
||||
: "",
|
||||
[selectedName, filled, templates, templateHtmlMap],
|
||||
);
|
||||
const { css: previewCss, scopeClasses: previewScopeClasses } = useMemo(
|
||||
() =>
|
||||
buildTemplateStyles(
|
||||
filled,
|
||||
selectedName !== null
|
||||
? [
|
||||
{
|
||||
name: selectedName,
|
||||
body: templates[selectedName] ?? "",
|
||||
css: templateCssMap?.[selectedName] ?? null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
[filled, selectedName, templates, templateCssMap],
|
||||
);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setSelectedName(null);
|
||||
setValues({});
|
||||
setPreviewMode("rendered");
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
|
@ -143,12 +177,48 @@ export function WikiTemplateDialog({
|
|||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Preview
|
||||
</span>
|
||||
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{filled}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Preview
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={previewMode === "rendered" ? "secondary" : "ghost"}
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setPreviewMode("rendered")}
|
||||
>
|
||||
Rendered
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={previewMode === "source" ? "secondary" : "ghost"}
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setPreviewMode("source")}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{previewMode === "rendered" ? (
|
||||
<div className="max-h-72 min-h-24 overflow-auto rounded-md border bg-muted/20 p-3">
|
||||
{previewCss ? (
|
||||
<style dangerouslySetInnerHTML={{ __html: previewCss }} />
|
||||
) : null}
|
||||
<div className={cn("wiki-markdown", previewScopeClasses)}>
|
||||
<WikiPreparedBody
|
||||
prepared={preparedPreview}
|
||||
htmlTemplates={templateHtmlMap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{filled}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex justify-end gap-2")}>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ const EMPTY: VersionInfo = {
|
|||
};
|
||||
|
||||
function resolveVersionLabel(info: VersionInfo): string {
|
||||
return info.gitDescribe ?? info.tag ?? info.version ?? "unknown version";
|
||||
// Coolify strips .git from the build context, so only commitHash (via the
|
||||
// SOURCE_COMMIT build arg) resolves there — compose it into the label so
|
||||
// every deploy shows a distinct version without a manual semver bump.
|
||||
if (info.gitDescribe) return info.gitDescribe;
|
||||
if (info.tag) return info.tag;
|
||||
if (info.version && info.commitHash) return `${info.version}+${info.commitHash}`;
|
||||
return info.version ?? "unknown version";
|
||||
}
|
||||
|
||||
function formatCommitDate(iso: string | null): string | null {
|
||||
|
|
|
|||
115
src/lib/economy/config.ts
Normal file
115
src/lib/economy/config.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Supply & demand economy — configuration accessors.
|
||||
*
|
||||
* Resolves global knobs from the GameRules `economy` group and per-item
|
||||
* configuration from the two item shapes (Resources use a top-level
|
||||
* `economy` group, Assets nest theirs under `marketTrading`).
|
||||
*/
|
||||
|
||||
import type { Payload } from "payload";
|
||||
|
||||
import type { EconomyKnobs, EconomyItemType, ItemEconomyConfig } from "./types";
|
||||
import { ECONOMY_DEFAULTS, ITEM_ECONOMY_DEFAULTS } from "./types";
|
||||
|
||||
type GameRulesDoc = {
|
||||
economy?: {
|
||||
enabled?: boolean | null;
|
||||
defaultPriceBand?: number | null;
|
||||
realDemandWindowTicks?: number | null;
|
||||
realDemandDecay?: number | null;
|
||||
artificialDemandDrift?: number | null;
|
||||
coldStartObservations?: number | null;
|
||||
tickIntervalHintMinutes?: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toNumber(value: number | null | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Read the global economy knobs from GameRules, falling back to defaults. */
|
||||
export function knobsFromGameRules(rules: GameRulesDoc | null | undefined): EconomyKnobs {
|
||||
const economy = rules?.economy;
|
||||
return {
|
||||
defaultPriceBand: clamp(
|
||||
toNumber(economy?.defaultPriceBand, ECONOMY_DEFAULTS.defaultPriceBand),
|
||||
0,
|
||||
1,
|
||||
),
|
||||
realDemandWindowTicks: Math.max(
|
||||
1,
|
||||
toNumber(economy?.realDemandWindowTicks, ECONOMY_DEFAULTS.realDemandWindowTicks),
|
||||
),
|
||||
realDemandDecay: clamp(
|
||||
toNumber(economy?.realDemandDecay, ECONOMY_DEFAULTS.realDemandDecay),
|
||||
0,
|
||||
1,
|
||||
),
|
||||
artificialDemandDrift: clamp(
|
||||
toNumber(economy?.artificialDemandDrift, ECONOMY_DEFAULTS.artificialDemandDrift),
|
||||
0,
|
||||
1,
|
||||
),
|
||||
coldStartObservations: Math.max(
|
||||
0,
|
||||
toNumber(economy?.coldStartObservations, ECONOMY_DEFAULTS.coldStartObservations),
|
||||
),
|
||||
tickIntervalHintMinutes: Math.max(
|
||||
1,
|
||||
toNumber(economy?.tickIntervalHintMinutes, ECONOMY_DEFAULTS.tickIntervalHintMinutes),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEconomySettings(
|
||||
payload: Payload,
|
||||
): Promise<{ enabled: boolean; knobs: EconomyKnobs }> {
|
||||
const rules = (await payload.findGlobal({ slug: "game-rules", depth: 0 })) as GameRulesDoc;
|
||||
return { enabled: rules?.economy?.enabled === true, knobs: knobsFromGameRules(rules) };
|
||||
}
|
||||
|
||||
type ResourceLike = {
|
||||
economy?: {
|
||||
enabled?: boolean | null;
|
||||
baselineSupply?: number | null;
|
||||
baselineDemand?: number | null;
|
||||
maxModifier?: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve per-item economy configuration. Both item shapes store the group
|
||||
* at the top level (`economy.*`); returns null when the item's economy is
|
||||
* disabled (no ledger, no pricing).
|
||||
*/
|
||||
export function resolveEconomyConfig(
|
||||
_itemType: EconomyItemType,
|
||||
doc: ResourceLike | null | undefined,
|
||||
knobs: EconomyKnobs,
|
||||
): ItemEconomyConfig | null {
|
||||
const economy = doc?.economy;
|
||||
if (!economy || economy.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const priceBand = clamp(
|
||||
toNumber(economy.maxModifier, knobs.defaultPriceBand),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
return {
|
||||
enabled: true,
|
||||
baselineSupply: Math.max(
|
||||
0,
|
||||
toNumber(economy.baselineSupply, ITEM_ECONOMY_DEFAULTS.baselineSupply),
|
||||
),
|
||||
baselineDemand: Math.max(
|
||||
0,
|
||||
toNumber(economy.baselineDemand, ITEM_ECONOMY_DEFAULTS.baselineDemand),
|
||||
),
|
||||
priceBand,
|
||||
};
|
||||
}
|
||||
120
src/lib/economy/consumption.ts
Normal file
120
src/lib/economy/consumption.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Payload, Where } from "payload";
|
||||
|
||||
import type { EconomyItemType } from "./types";
|
||||
import type { ItemRef } from "./persistence";
|
||||
|
||||
/**
|
||||
* Consumption-side helpers: the market system reads ledger state to price new
|
||||
* NPC listings, ingests real demand from completed purchases, and GMs can
|
||||
* rebuild a doc from its baselines when the ledger drifts from reality.
|
||||
*/
|
||||
|
||||
export interface MarketPriceContext {
|
||||
priceModifier: number;
|
||||
currentSupply: number;
|
||||
artificialDemand: number;
|
||||
realDemand: number;
|
||||
}
|
||||
|
||||
type PayloadLike = Payload;
|
||||
|
||||
export async function getMarketPriceContext(
|
||||
payload: PayloadLike,
|
||||
itemType: EconomyItemType,
|
||||
itemId: number,
|
||||
): Promise<MarketPriceContext | null> {
|
||||
const docs = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = docs.docs[0];
|
||||
if (!doc) return null;
|
||||
return {
|
||||
priceModifier: doc.priceModifier,
|
||||
currentSupply: doc.currentSupply,
|
||||
artificialDemand: doc.artificialDemand,
|
||||
realDemand: doc.realDemand,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget real-demand ingestion for completed purchases of NPC stock.
|
||||
* The purchase has already succeeded, so failures are logged, never thrown.
|
||||
*/
|
||||
export async function recordPurchase(
|
||||
payload: PayloadLike,
|
||||
{ itemId, quantity }: { itemId: number; quantity: number },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docs = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: itemId } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = docs.docs[0];
|
||||
if (!doc) return;
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: doc.id,
|
||||
data: { realDemand: doc.realDemand + quantity },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
payload.logger.error({ err: error }, "economy purchase ingestion failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds market-state docs from their GM baselines (admin repair action). */
|
||||
export async function resetMarketStateDocs(
|
||||
payload: PayloadLike,
|
||||
{ items }: { items?: ItemRef[] },
|
||||
): Promise<{ reset: number; scanned: number }> {
|
||||
const where: Where | undefined = items
|
||||
? {
|
||||
or: items.map((item): Where => ({
|
||||
and: [
|
||||
{ itemType: { equals: item.itemType } },
|
||||
{ itemId: { equals: item.itemId } },
|
||||
],
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const found = await payload.find({
|
||||
collection: "market-state",
|
||||
...(where ? { where } : {}),
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
let reset = 0;
|
||||
for (const doc of found.docs) {
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: doc.id,
|
||||
data: {
|
||||
currentSupply: doc.gmBaselineSupply,
|
||||
artificialDemand: doc.gmBaselineDemand,
|
||||
realDemand: 0,
|
||||
priceModifier: 1,
|
||||
lastComputedAt: null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
reset += 1;
|
||||
} catch (error: unknown) {
|
||||
payload.logger.error({ err: error }, `failed to reset market-state doc ${doc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { reset, scanned: found.docs.length };
|
||||
}
|
||||
43
src/lib/economy/events.ts
Normal file
43
src/lib/economy/events.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Supply & demand economy — event emission.
|
||||
*
|
||||
* Payload-dependent; called by the `economy-tick` bin after a successful
|
||||
* tick. Emits one batch summary plus one event per recorded price change.
|
||||
*/
|
||||
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
|
||||
import type { TickSummary } from "./tick";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export async function emitEconomyEvents(
|
||||
payload: PayloadType,
|
||||
result: TickSummary,
|
||||
): Promise<void> {
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.EconomyTick,
|
||||
message: `Economy tick processed ${result.processed} item(s) with ${result.priceChanges.length} price change(s)${result.errors > 0 ? ` and ${result.errors} error(s)` : ""}.`,
|
||||
data: {
|
||||
processed: result.processed,
|
||||
priceChangeCount: result.priceChanges.length,
|
||||
errors: result.errors,
|
||||
},
|
||||
});
|
||||
|
||||
for (const change of result.priceChanges) {
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.EconomyPriceChange,
|
||||
message: `Price modifier for ${change.itemType} #${change.itemId} moved from ${change.from} to ${change.to}.`,
|
||||
targetCollection: "market-state",
|
||||
targetId: change.stateId,
|
||||
data: {
|
||||
itemType: change.itemType,
|
||||
itemId: change.itemId,
|
||||
from: change.from,
|
||||
to: change.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
164
src/lib/economy/math.ts
Normal file
164
src/lib/economy/math.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Supply & demand economy — pure modifier math.
|
||||
*
|
||||
* No Payload imports; every function is deterministic except where an `rng`
|
||||
* is injected (default `Math.random`). See docs/economy/supply-demand-design.md.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EconomyKnobs,
|
||||
EconomyTickResult,
|
||||
MarketStateSnapshot,
|
||||
} from "./types";
|
||||
|
||||
/** Round to 4 decimal places to keep stored floats stable across ticks. */
|
||||
export function round4(value: number): number {
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Decay real demand by a factor in [0, 1]: 0 = no decay, 1 = wipe. */
|
||||
export function decayRealDemand(realDemand: number, decay: number): number {
|
||||
return round4(realDemand * (1 - clamp(decay, 0, 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Random-walk the artificial NPC demand around its baseline, bounded to
|
||||
* [0, 2 × baseline] so demand never goes negative or runs away.
|
||||
* A full step is ±drift × baseline.
|
||||
*/
|
||||
export function driftArtificialDemand(
|
||||
current: number,
|
||||
baseline: number,
|
||||
drift: number,
|
||||
rng: () => number = Math.random,
|
||||
): number {
|
||||
const step = (rng() * 2 - 1) * drift * baseline;
|
||||
return clamp(round4(current + step), 0, baseline * 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Price modifier from the demand/supply ratio, clamped to ±band around 1.
|
||||
* Zero supply is treated as maximum scarcity (1 + band). The band itself is
|
||||
* clamped to [0, 1].
|
||||
*/
|
||||
export function priceModifierFromRatio(
|
||||
supply: number,
|
||||
demand: number,
|
||||
band: number,
|
||||
): number {
|
||||
const safeBand = clamp(band, 0, 1);
|
||||
if (supply <= 0) {
|
||||
return round4(1 + safeBand);
|
||||
}
|
||||
const ratio = demand / supply;
|
||||
return round4(clamp(ratio, 1 - safeBand, 1 + safeBand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-start gate: an item needs at least `knobs.coldStartObservations`
|
||||
* completed purchases (realDemand) before its price starts moving; until
|
||||
* then the modifier stays neutral at 1.
|
||||
*/
|
||||
export function computePriceModifier(
|
||||
supply: number,
|
||||
demand: number,
|
||||
realDemand: number,
|
||||
band: number,
|
||||
knobs: Pick<EconomyKnobs, "coldStartObservations">,
|
||||
): number {
|
||||
if (realDemand < knobs.coldStartObservations) {
|
||||
return 1;
|
||||
}
|
||||
return priceModifierFromRatio(supply, demand, band);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one tick of economy math to a snapshot.
|
||||
*
|
||||
* - realDemand decays by the knob factor
|
||||
* - artificialDemand random-walks around its baseline
|
||||
* - priceModifier is recomputed from the demand/supply ratio (cold-start gated)
|
||||
* - currentSupply is unchanged in phase 1 (production arrives in a later phase)
|
||||
*/
|
||||
export function applyEconomyTick(
|
||||
state: MarketStateSnapshot,
|
||||
config: { baselineDemand: number; priceBand: number },
|
||||
knobs: EconomyKnobs,
|
||||
rng: () => number = Math.random,
|
||||
): EconomyTickResult {
|
||||
const realDemand = decayRealDemand(state.realDemand, knobs.realDemandDecay);
|
||||
const artificialDemand = driftArtificialDemand(
|
||||
state.artificialDemand,
|
||||
config.baselineDemand,
|
||||
knobs.artificialDemandDrift,
|
||||
rng,
|
||||
);
|
||||
const totalDemand = artificialDemand + realDemand;
|
||||
const priceModifier = computePriceModifier(
|
||||
state.currentSupply,
|
||||
totalDemand,
|
||||
realDemand,
|
||||
config.priceBand,
|
||||
knobs,
|
||||
);
|
||||
return {
|
||||
currentSupply: state.currentSupply,
|
||||
artificialDemand,
|
||||
realDemand,
|
||||
priceModifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mulberry32 — small deterministic PRNG. Same seed always yields the same
|
||||
* sequence, so a tick re-run within the same period reproduces its drift.
|
||||
*/
|
||||
export function createSeededRng(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic seed for a cron period: stable for every run within the
|
||||
* same interval window, different across windows (idempotent re-runs).
|
||||
*/
|
||||
export function periodSeed(now: Date, intervalMinutes: number): number {
|
||||
const periodMs = Math.max(1, intervalMinutes) * 60_000;
|
||||
return Math.floor(now.getTime() / periodMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum modifier delta for a change to be recorded (and later emitted as
|
||||
* an economy:price-change event) — keeps event spam down for micro-moves.
|
||||
*/
|
||||
export const PRICE_CHANGE_THRESHOLD = 0.05;
|
||||
|
||||
export function shouldEmitPriceChange(from: number, to: number): boolean {
|
||||
return Math.abs(to - from) >= PRICE_CHANGE_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales an NPC restock quantity down when supply runs short of demand.
|
||||
* Bounded: the scale factor is clamped to [0.25, 1], so vendors still stock
|
||||
* something under extreme shortage (never zero without a GM flag) and the
|
||||
* quantity is never reduced below 1.
|
||||
*/
|
||||
export function scaleQuantityForShortage(
|
||||
quantity: number,
|
||||
supply: number,
|
||||
demand: number,
|
||||
): number {
|
||||
if (demand <= 0 || supply >= demand) return quantity;
|
||||
const factor = clamp(supply / demand, 0.25, 1);
|
||||
return Math.max(1, Math.floor(quantity * factor));
|
||||
}
|
||||
62
src/lib/economy/persistence.ts
Normal file
62
src/lib/economy/persistence.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Supply & demand economy — find/load helpers shared by the tick
|
||||
* orchestrator. Kept out of `tick.ts` to respect the 250-line ceiling.
|
||||
*/
|
||||
|
||||
import type { Payload } from "payload";
|
||||
import type { Where } from "payload";
|
||||
|
||||
import type { EconomyItemType } from "./types";
|
||||
|
||||
export type ItemRef = { itemType: EconomyItemType; itemId: number };
|
||||
|
||||
export const FIND_LIMIT = 500;
|
||||
|
||||
export async function findItemIds(
|
||||
payload: Payload,
|
||||
itemType: EconomyItemType,
|
||||
items: ItemRef[] | undefined,
|
||||
): Promise<number[]> {
|
||||
if (items) {
|
||||
return items.filter((i) => i.itemType === itemType).map((i) => i.itemId);
|
||||
}
|
||||
const { docs } = await payload.find({
|
||||
collection: itemType === "resource" ? "resources" : "assets",
|
||||
where: { tradeable: { equals: true } },
|
||||
limit: FIND_LIMIT,
|
||||
depth: 0,
|
||||
});
|
||||
return docs.map((d) => d.id);
|
||||
}
|
||||
|
||||
export async function loadItemDocs(
|
||||
payload: Payload,
|
||||
itemType: EconomyItemType,
|
||||
itemIds: number[],
|
||||
): Promise<Map<number, Record<string, unknown>>> {
|
||||
const byId = new Map<number, Record<string, unknown>>();
|
||||
const collection = itemType === "resource" ? "resources" : "assets";
|
||||
for (let i = 0; i < itemIds.length; i += FIND_LIMIT) {
|
||||
const chunk = itemIds.slice(i, i + FIND_LIMIT);
|
||||
const { docs } = await payload.find({
|
||||
collection,
|
||||
where: { id: { in: chunk } },
|
||||
limit: FIND_LIMIT,
|
||||
depth: 0,
|
||||
});
|
||||
for (const doc of docs) {
|
||||
byId.set(doc.id, doc as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
export function marketStateWhereFor(items: ItemRef[]): Where {
|
||||
return {
|
||||
or: items.map(
|
||||
(i): Where => ({
|
||||
and: [{ itemType: { equals: i.itemType } }, { itemId: { equals: i.itemId } }],
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
226
src/lib/economy/tick.ts
Normal file
226
src/lib/economy/tick.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* Supply & demand economy — tick orchestrator.
|
||||
*
|
||||
* Persists the ledger: ensures one market-state doc per enabled tradeable
|
||||
* item (copied from GM baselines), then advances every doc one tick via the
|
||||
* pure math in `math.ts`. Called by the `economy-tick` bin (P1c).
|
||||
*/
|
||||
|
||||
import type { Where } from "payload";
|
||||
|
||||
import { getEconomySettings, resolveEconomyConfig } from "./config";
|
||||
import { applyEconomyTick, shouldEmitPriceChange } from "./math";
|
||||
import {
|
||||
FIND_LIMIT,
|
||||
findItemIds,
|
||||
loadItemDocs,
|
||||
marketStateWhereFor,
|
||||
} from "./persistence";
|
||||
import type { ItemRef } from "./persistence";
|
||||
import type { EconomyKnobs } from "./types";
|
||||
import { ECONOMY_DEFAULTS } from "./types";
|
||||
|
||||
export type { ItemRef };
|
||||
|
||||
export type EnsureSummary = {
|
||||
created: number;
|
||||
scanned: number;
|
||||
};
|
||||
|
||||
export type PriceChange = {
|
||||
itemType: ItemRef["itemType"];
|
||||
itemId: number;
|
||||
/** market-state doc id — used as the event-log targetId. */
|
||||
stateId: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type TickSummary = {
|
||||
processed: number;
|
||||
/** Docs skipped because they were already computed in this period. */
|
||||
skipped: number;
|
||||
priceChanges: PriceChange[];
|
||||
errors: number;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type ProcessOptions = {
|
||||
now?: Date;
|
||||
rng?: () => number;
|
||||
items?: ItemRef[];
|
||||
/**
|
||||
* Only docs whose lastComputedAt is before this boundary are processed —
|
||||
* makes cron re-runs within one period idempotent no-ops.
|
||||
*/
|
||||
periodStart?: Date;
|
||||
/** Override the GameRules master gate (hermetic tests / manual runs). */
|
||||
masterGate?: boolean;
|
||||
/** Override the GameRules knobs (hermetic tests / manual runs). */
|
||||
knobs?: EconomyKnobs;
|
||||
};
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
/**
|
||||
* Create missing market-state docs for enabled tradeable items, copying the
|
||||
* GM baselines from the item doc. Idempotent: existing docs are left alone.
|
||||
*/
|
||||
export async function ensureMarketStateDocs(
|
||||
payload: PayloadType,
|
||||
options: { items?: ItemRef[]; knobs?: EconomyKnobs } = {},
|
||||
): Promise<EnsureSummary> {
|
||||
const { knobs = ECONOMY_DEFAULTS } = options;
|
||||
let created = 0;
|
||||
let scanned = 0;
|
||||
|
||||
for (const itemType of ["resource", "asset"] as const) {
|
||||
const itemIds = await findItemIds(payload, itemType, options.items);
|
||||
scanned += itemIds.length;
|
||||
if (itemIds.length === 0) continue;
|
||||
|
||||
const { docs: existing } = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: itemType } }, { itemId: { in: itemIds } }] },
|
||||
limit: FIND_LIMIT,
|
||||
depth: 0,
|
||||
select: { itemId: true },
|
||||
});
|
||||
const have = new Set(existing.map((d) => d.itemId));
|
||||
const missing = itemIds.filter((id) => !have.has(id));
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
const itemDocs = await loadItemDocs(payload, itemType, missing);
|
||||
for (const itemId of missing) {
|
||||
try {
|
||||
const config = resolveEconomyConfig(itemType, itemDocs.get(itemId), knobs);
|
||||
if (!config) continue;
|
||||
await payload.create({
|
||||
collection: "market-state",
|
||||
data: {
|
||||
itemType,
|
||||
itemId,
|
||||
gmBaselineSupply: config.baselineSupply,
|
||||
gmBaselineDemand: config.baselineDemand,
|
||||
currentSupply: config.baselineSupply,
|
||||
artificialDemand: config.baselineDemand,
|
||||
realDemand: 0,
|
||||
priceModifier: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
created += 1;
|
||||
} catch (error) {
|
||||
payload.logger.error({
|
||||
msg: "economy: failed to ensure market-state doc",
|
||||
itemType,
|
||||
itemId,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { created, scanned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance every market-state doc one tick. Disabled economy (master gate)
|
||||
* is a no-op. Per-item failures are logged and skipped.
|
||||
*/
|
||||
export async function processEconomyTick(
|
||||
payload: PayloadType,
|
||||
options: ProcessOptions = {},
|
||||
): Promise<TickSummary> {
|
||||
const { now = new Date(), rng = Math.random, items, periodStart } = options;
|
||||
const settings = await getEconomySettings(payload);
|
||||
const enabled = options.masterGate ?? settings.enabled;
|
||||
const knobs = options.knobs ?? settings.knobs;
|
||||
if (!enabled) {
|
||||
return { processed: 0, skipped: 0, priceChanges: [], errors: 0, disabled: true };
|
||||
}
|
||||
await ensureMarketStateDocs(payload, { items, knobs });
|
||||
|
||||
const marketStateWhere: Where | undefined = items ? marketStateWhereFor(items) : undefined;
|
||||
const { docs } = await payload.find({
|
||||
collection: "market-state",
|
||||
...(marketStateWhere ? { where: marketStateWhere } : {}),
|
||||
limit: FIND_LIMIT,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const resourceDocs = await loadItemDocs(
|
||||
payload,
|
||||
"resource",
|
||||
docs.filter((d) => d.itemType === "resource").map((d) => d.itemId),
|
||||
);
|
||||
const assetDocs = await loadItemDocs(
|
||||
payload,
|
||||
"asset",
|
||||
docs.filter((d) => d.itemType === "asset").map((d) => d.itemId),
|
||||
);
|
||||
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
const priceChanges: PriceChange[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
if (
|
||||
periodStart &&
|
||||
doc.lastComputedAt &&
|
||||
new Date(doc.lastComputedAt) >= periodStart
|
||||
) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const itemDocs = doc.itemType === "resource" ? resourceDocs : assetDocs;
|
||||
const config = resolveEconomyConfig(doc.itemType, itemDocs.get(doc.itemId), knobs);
|
||||
if (!config) continue;
|
||||
const next = applyEconomyTick(
|
||||
{
|
||||
currentSupply: doc.currentSupply,
|
||||
artificialDemand: doc.artificialDemand,
|
||||
realDemand: doc.realDemand,
|
||||
priceModifier: doc.priceModifier,
|
||||
},
|
||||
{ baselineDemand: config.baselineDemand, priceBand: config.priceBand },
|
||||
knobs,
|
||||
rng,
|
||||
);
|
||||
if (shouldEmitPriceChange(doc.priceModifier, next.priceModifier)) {
|
||||
priceChanges.push({
|
||||
itemType: doc.itemType,
|
||||
itemId: doc.itemId,
|
||||
stateId: doc.id,
|
||||
from: doc.priceModifier,
|
||||
to: next.priceModifier,
|
||||
});
|
||||
}
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: doc.id,
|
||||
data: {
|
||||
currentSupply: next.currentSupply,
|
||||
artificialDemand: next.artificialDemand,
|
||||
realDemand: next.realDemand,
|
||||
priceModifier: next.priceModifier,
|
||||
lastComputedAt: now.toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
processed += 1;
|
||||
} catch (error) {
|
||||
errors += 1;
|
||||
payload.logger.error({
|
||||
msg: "economy: failed to process market-state doc",
|
||||
itemType: doc.itemType,
|
||||
itemId: doc.itemId,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, skipped, priceChanges, errors, disabled: false };
|
||||
}
|
||||
53
src/lib/economy/types.ts
Normal file
53
src/lib/economy/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Supply & demand economy — shared types and defaults.
|
||||
*
|
||||
* Pure module (no Payload imports) — safe to import client-side.
|
||||
* See docs/economy/supply-demand-design.md for the approved design.
|
||||
*/
|
||||
|
||||
export type EconomyItemType = "resource" | "asset";
|
||||
|
||||
/** Per-item economy configuration resolved from Resources `economy.*` or Assets `marketTrading.economy.*`. */
|
||||
export type ItemEconomyConfig = {
|
||||
enabled: boolean;
|
||||
baselineSupply: number;
|
||||
baselineDemand: number;
|
||||
/** Price band: the modifier may move up to ±band from 1. Clamped to [0, 1]. */
|
||||
priceBand: number;
|
||||
};
|
||||
|
||||
/** Global economy knobs (from the GameRules `economy` group, with defaults). */
|
||||
export type EconomyKnobs = {
|
||||
defaultPriceBand: number;
|
||||
realDemandWindowTicks: number;
|
||||
realDemandDecay: number;
|
||||
artificialDemandDrift: number;
|
||||
coldStartObservations: number;
|
||||
tickIntervalHintMinutes: number;
|
||||
};
|
||||
|
||||
/** The mutable numeric state of a market-state doc, as consumed by the tick math. */
|
||||
export type MarketStateSnapshot = {
|
||||
currentSupply: number;
|
||||
artificialDemand: number;
|
||||
realDemand: number;
|
||||
priceModifier: number;
|
||||
};
|
||||
|
||||
/** Result of applying one tick of economy math to a snapshot. */
|
||||
export type EconomyTickResult = MarketStateSnapshot;
|
||||
|
||||
export const ECONOMY_DEFAULTS: EconomyKnobs = {
|
||||
defaultPriceBand: 0.5,
|
||||
realDemandWindowTicks: 24,
|
||||
realDemandDecay: 0.5,
|
||||
artificialDemandDrift: 0.05,
|
||||
coldStartObservations: 5,
|
||||
tickIntervalHintMinutes: 60,
|
||||
};
|
||||
|
||||
/** Baseline defaults used when a GM leaves the per-item fields empty. */
|
||||
export const ITEM_ECONOMY_DEFAULTS = {
|
||||
baselineSupply: 100,
|
||||
baselineDemand: 50,
|
||||
} as const;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Asset, LockerStorage, MarketListing, User } from "@/payload-types";
|
||||
import { recordPurchase } from "@/lib/economy/consumption";
|
||||
import {
|
||||
ensureLockerStorage,
|
||||
findLockerEmptySpot,
|
||||
|
|
@ -321,6 +322,13 @@ export async function buyListing(
|
|||
depth: 1,
|
||||
})) as unknown as MarketListing;
|
||||
|
||||
// Real-demand ingestion: completed purchases of NPC/vendor stock feed the
|
||||
// economy ledger. The purchase already succeeded, so ingestion must not
|
||||
// throw — recordPurchase logs and swallows internally.
|
||||
if (sellerId == null) {
|
||||
await recordPurchase(payload, { itemId: asset.id, quantity });
|
||||
}
|
||||
|
||||
return {
|
||||
transactionId: transaction.id,
|
||||
total,
|
||||
|
|
@ -363,18 +371,6 @@ export async function expireListing(
|
|||
})) as unknown as MarketListing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price an auto-generated entry as a deviation around the asset's base buy
|
||||
* price. `kind` mirrors the resting valuation: `buy` prices NPC stock near
|
||||
* the base buy price, `sell` prices it near the base sell price.
|
||||
*/
|
||||
export function autoPrice(asset: Asset, kind: "buy" | "sell" = "buy"): number | null {
|
||||
const base = kind === "buy" ? getBaseBuyPrice(asset) : getBaseSellPrice(asset);
|
||||
if (base == null || base <= 0) return null;
|
||||
const deviation = (Math.random() * 2 - 1) * 0.15;
|
||||
return Math.max(1, Math.round(base * (1 + deviation)));
|
||||
}
|
||||
|
||||
/** Suggested auto-generated quantity: a few units for stackables, else 1. */
|
||||
export function autoQuantity(asset: Asset): number {
|
||||
if (lockerIsStackable(asset)) {
|
||||
|
|
|
|||
105
src/lib/voiceover/emotionTags.ts
Normal file
105
src/lib/voiceover/emotionTags.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
export interface VoiceoverSegment {
|
||||
readonly text: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly sfx?: string;
|
||||
readonly directive?: string;
|
||||
}
|
||||
|
||||
const TAG_PATTERN = /\[([a-z][a-z\s,-]*)\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi;
|
||||
|
||||
function normalizeScript(script: string): string {
|
||||
return script
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/(?<!\n)\n(?!\n)/g, " ")
|
||||
.replace(/[^\S\n]{2,}/g, " ");
|
||||
}
|
||||
|
||||
function normalizeTag(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/[\s,]+$/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function trimAroundSfx(segments: VoiceoverSegment[]): VoiceoverSegment[] {
|
||||
const isStandalone = (segment: VoiceoverSegment | undefined) =>
|
||||
segment !== undefined && (segment.sfx !== undefined || segment.directive !== undefined);
|
||||
return segments.map((segment, index) => {
|
||||
let text = segment.text;
|
||||
if (isStandalone(segments[index - 1])) text = text.replace(/^\s+/, "");
|
||||
if (isStandalone(segments[index + 1])) text = text.replace(/\s+$/, "");
|
||||
return text === segment.text ? segment : { ...segment, text };
|
||||
});
|
||||
}
|
||||
|
||||
export function parseEmotionTags(script: string): VoiceoverSegment[] {
|
||||
const segments: VoiceoverSegment[] = [];
|
||||
let cursor = 0;
|
||||
let pendingTags: string[] = [];
|
||||
let carriedWhitespace = "";
|
||||
|
||||
const source = normalizeScript(script);
|
||||
|
||||
const flush = (rawText: string, isFinal: boolean) => {
|
||||
let text = carriedWhitespace + rawText;
|
||||
carriedWhitespace = "";
|
||||
|
||||
// Whitespace-only runs between consecutive tags carry forward so a group
|
||||
// like "[calm] [formal]\nWE RECEIVED…" attaches every tag to the line.
|
||||
if (pendingTags.length > 0 && text.trim().length === 0 && !isFinal) {
|
||||
carriedWhitespace = text;
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length === 0) return;
|
||||
|
||||
if (pendingTags.length > 0 && text.trim().length > 0) {
|
||||
// A tag labels the text that follows it; strip the whitespace the source
|
||||
// used to separate the tag line from its paragraph — the label already
|
||||
// renders on its own line.
|
||||
segments.push({ text: text.replace(/^\s+/, ""), tags: pendingTags });
|
||||
pendingTags = [];
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push({ text, tags: [] });
|
||||
};
|
||||
|
||||
for (const match of source.matchAll(TAG_PATTERN)) {
|
||||
const matchIndex = match.index ?? cursor;
|
||||
if (matchIndex > cursor) {
|
||||
flush(source.slice(cursor, matchIndex), false);
|
||||
}
|
||||
|
||||
const emotive = normalizeTag(match[1] ?? "");
|
||||
if (emotive) {
|
||||
pendingTags.push(emotive);
|
||||
}
|
||||
|
||||
const sfx = normalizeTag(match[2] ?? "");
|
||||
if (sfx) {
|
||||
segments.push({ text: "", tags: [], sfx });
|
||||
}
|
||||
|
||||
const directive = normalizeTag(match[3] ?? "");
|
||||
if (directive) {
|
||||
segments.push({ text: "", tags: [], directive });
|
||||
}
|
||||
|
||||
cursor = matchIndex + match[0].length;
|
||||
}
|
||||
|
||||
flush(source.slice(cursor), true);
|
||||
|
||||
const hasSfx = segments.some((segment) => segment.sfx);
|
||||
const trimmed = hasSfx ? trimAroundSfx(segments) : segments;
|
||||
return trimmed.filter(
|
||||
(segment) =>
|
||||
segment.sfx !== undefined ||
|
||||
segment.directive !== undefined ||
|
||||
segment.tags.length > 0 ||
|
||||
segment.text.length > 0,
|
||||
);
|
||||
}
|
||||
128
src/lib/voiceover/index.ts
Normal file
128
src/lib/voiceover/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export interface VoiceoverSubmissionSummary {
|
||||
id: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
export interface VoiceoverScriptView {
|
||||
id: number;
|
||||
title: string;
|
||||
script: string;
|
||||
xpAmount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
/** This user's own submissions on the script, newest first. */
|
||||
mySubmissions: VoiceoverSubmissionSummary[];
|
||||
/**
|
||||
* How many submissions OTHER users have made on this script. A number only —
|
||||
* never identities (privacy requirement).
|
||||
*/
|
||||
otherSubmissionCount: number;
|
||||
}
|
||||
|
||||
/** Upload docs expose `url`; fall back to the media file route like wiki image uploads do. */
|
||||
function submissionAudioUrl(submission: VoiceSubmission): string {
|
||||
return submission.url ?? (submission.filename ? `/api/media/file/${submission.filename}` : "");
|
||||
}
|
||||
|
||||
function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSummary {
|
||||
return {
|
||||
id: submission.id,
|
||||
status: submission.status,
|
||||
createdAt: submission.createdAt,
|
||||
audioUrl: submissionAudioUrl(submission),
|
||||
};
|
||||
}
|
||||
|
||||
function buildScriptView(
|
||||
script: VoiceScript,
|
||||
submissions: VoiceSubmission[],
|
||||
userId: number,
|
||||
): VoiceoverScriptView {
|
||||
const mySubmissions: VoiceoverSubmissionSummary[] = [];
|
||||
let otherSubmissionCount = 0;
|
||||
|
||||
for (const submission of submissions) {
|
||||
if (submission.user === userId) {
|
||||
mySubmissions.push(toSubmissionSummary(submission));
|
||||
} else {
|
||||
// Other users' submissions are counted, never surfaced — identities stay server-side.
|
||||
otherSubmissionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: script.id,
|
||||
title: script.title,
|
||||
script: script.script,
|
||||
xpAmount: script.xpAmount,
|
||||
status: script.status,
|
||||
createdAt: script.createdAt,
|
||||
mySubmissions,
|
||||
otherSubmissionCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All voiceover scripts (newest first) annotated with the user's own submissions
|
||||
* and an anonymous count of other users' submissions.
|
||||
*
|
||||
* Round-trip pattern: one query for scripts + one for all submissions, grouped
|
||||
* in memory — avoids per-script count queries (N+1).
|
||||
*/
|
||||
export async function listScriptsForUser(
|
||||
payload: PayloadType,
|
||||
user: User,
|
||||
): Promise<VoiceoverScriptView[]> {
|
||||
const [scriptsRes, submissionsRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "voice-scripts",
|
||||
sort: "-createdAt",
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "voice-submissions",
|
||||
sort: "-createdAt",
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return scriptsRes.docs.map((script) => buildScriptView(script, submissionsRes.docs, user.id));
|
||||
}
|
||||
|
||||
/** A single voiceover script in the same shape as `listScriptsForUser`, or null. */
|
||||
export async function getScriptForUser(
|
||||
payload: PayloadType,
|
||||
user: User,
|
||||
scriptId: number,
|
||||
): Promise<VoiceoverScriptView | null> {
|
||||
const script = await payload
|
||||
.findByID({
|
||||
collection: "voice-scripts",
|
||||
id: scriptId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!script) return null;
|
||||
|
||||
const submissionsRes = await payload.find({
|
||||
collection: "voice-submissions",
|
||||
where: { script: { equals: scriptId } },
|
||||
sort: "-createdAt",
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return buildScriptView(script, submissionsRes.docs, user.id);
|
||||
}
|
||||
120
src/lib/voiceover/pacing.ts
Normal file
120
src/lib/voiceover/pacing.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { VoiceoverSegment } from "./emotionTags";
|
||||
|
||||
export const DEFAULT_WORDS_PER_MINUTE = 140;
|
||||
export const MIN_WORDS_PER_MINUTE = 60;
|
||||
export const MAX_WORDS_PER_MINUTE = 300;
|
||||
|
||||
export interface PacingWord {
|
||||
readonly text: string;
|
||||
readonly whitespaceAfter: string;
|
||||
readonly startMs: number;
|
||||
readonly endMs: number;
|
||||
}
|
||||
|
||||
export interface SegmentPacing {
|
||||
readonly leadingWhitespace: string;
|
||||
readonly words: ReadonlyArray<PacingWord>;
|
||||
}
|
||||
|
||||
export interface ScrollDirective {
|
||||
readonly atMs: number;
|
||||
readonly multiplier: number;
|
||||
}
|
||||
|
||||
export interface PacingTimeline {
|
||||
readonly segmentPacing: ReadonlyArray<SegmentPacing | undefined>;
|
||||
readonly scrollDirectives: ReadonlyArray<ScrollDirective>;
|
||||
readonly totalMs: number;
|
||||
}
|
||||
|
||||
const WORD_RATE_MULTIPLIERS: Record<string, number> = { slow: 1.5, fast: 0.7, normal: 1 };
|
||||
const SCROLL_RATE_MULTIPLIERS: Record<string, number> = { slow: 0.6, fast: 1.5, normal: 1 };
|
||||
const DIRECTIVE_PAUSE_MS = 1000;
|
||||
|
||||
const WORD_SPLIT_PATTERN = /(\s+)/;
|
||||
|
||||
const SENTENCE_PAUSE_PATTERN = /[.!?…]["')\]]?$/;
|
||||
const CLAUSE_PAUSE_PATTERN = /[,;:—–-]["')\]]?$/;
|
||||
|
||||
function wordCost(word: string, baseMs: number): number {
|
||||
const spokenLength = word.replace(/[^\p{L}\p{N}]/gu, "").length || 1;
|
||||
const lengthFactor =
|
||||
spokenLength <= 3 ? 0.7 : spokenLength <= 6 ? 1 : 1 + (spokenLength - 6) * 0.1;
|
||||
return baseMs * lengthFactor;
|
||||
}
|
||||
|
||||
function pauseAfter(word: string, whitespaceAfter: string, baseMs: number): number {
|
||||
if (SENTENCE_PAUSE_PATTERN.test(word)) return baseMs * 0.9;
|
||||
if (CLAUSE_PAUSE_PATTERN.test(word)) return baseMs * 0.4;
|
||||
if (whitespaceAfter.includes("\n\n")) return baseMs * 0.6;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildPacingTimeline(
|
||||
segments: ReadonlyArray<VoiceoverSegment>,
|
||||
wordsPerMinute: number,
|
||||
): PacingTimeline {
|
||||
const clampedWpm = Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, wordsPerMinute));
|
||||
const baseMs = 60000 / clampedWpm;
|
||||
const segmentPacing: Array<SegmentPacing | undefined> = [];
|
||||
const scrollDirectives: ScrollDirective[] = [];
|
||||
let cursorMs = 0;
|
||||
let wordRate = 1;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.directive) {
|
||||
if (segment.directive === "pause") {
|
||||
cursorMs += DIRECTIVE_PAUSE_MS;
|
||||
} else if (segment.directive in WORD_RATE_MULTIPLIERS) {
|
||||
wordRate = WORD_RATE_MULTIPLIERS[segment.directive] ?? 1;
|
||||
scrollDirectives.push({
|
||||
atMs: cursorMs,
|
||||
multiplier: SCROLL_RATE_MULTIPLIERS[segment.directive] ?? 1,
|
||||
});
|
||||
}
|
||||
segmentPacing.push(undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.sfx || segment.text.length === 0) {
|
||||
segmentPacing.push(undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pieces = segment.text.split(WORD_SPLIT_PATTERN);
|
||||
const words: Array<{
|
||||
text: string;
|
||||
whitespaceAfter: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}> = [];
|
||||
let leadingWhitespace = "";
|
||||
|
||||
for (const piece of pieces) {
|
||||
if (piece === undefined || piece.length === 0) continue;
|
||||
if (/^\s+$/.test(piece)) {
|
||||
const lastWord = words[words.length - 1];
|
||||
if (lastWord) {
|
||||
lastWord.whitespaceAfter += piece;
|
||||
} else {
|
||||
leadingWhitespace += piece;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
words.push({ text: piece, whitespaceAfter: "", startMs: 0, endMs: 0 });
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
word.startMs = cursorMs;
|
||||
const spokenMs =
|
||||
(wordCost(word.text, baseMs) + pauseAfter(word.text, word.whitespaceAfter, baseMs)) *
|
||||
wordRate;
|
||||
word.endMs = cursorMs + spokenMs;
|
||||
cursorMs = word.endMs;
|
||||
}
|
||||
|
||||
segmentPacing.push({ leadingWhitespace, words });
|
||||
}
|
||||
|
||||
return { segmentPacing, scrollDirectives, totalMs: cursorMs };
|
||||
}
|
||||
22757
src/migrations/20260906_100908_add_market_economy_state.json
Normal file
22757
src/migrations/20260906_100908_add_market_economy_state.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260906_100908_add_market_economy_state.ts
Normal file
77
src/migrations/20260906_100908_add_market_economy_state.ts
Normal file
File diff suppressed because one or more lines are too long
22788
src/migrations/20260906_165837_add_market_state_event_target.json
Normal file
22788
src/migrations/20260906_165837_add_market_state_event_target.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,18 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'market-state' BEFORE 'evaluations';
|
||||
CREATE INDEX "market_state_item_type_idx" ON "market_state" USING btree ("item_type");
|
||||
CREATE INDEX "market_state_item_id_idx" ON "market_state" USING btree ("item_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "market_state_item_type_idx";
|
||||
DROP INDEX "market_state_item_id_idx";`)
|
||||
}
|
||||
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
23189
src/migrations/20260906_202925_add_voice_submissions.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal file
77
src/migrations/20260906_202925_add_voice_submissions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_voice_scripts_status" AS ENUM('open', 'closed');
|
||||
CREATE TYPE "public"."enum_voice_submissions_status" AS ENUM('pending', 'accepted', 'rejected');
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-scripts';
|
||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'voice-submissions';
|
||||
CREATE TABLE "voice_scripts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"script" varchar NOT NULL,
|
||||
"xp_amount" numeric DEFAULT 250 NOT NULL,
|
||||
"status" "enum_voice_scripts_status" DEFAULT 'open' NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "voice_submissions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"script_id" integer NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"status" "enum_voice_submissions_status" DEFAULT 'pending' NOT NULL,
|
||||
"review_note" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric
|
||||
);
|
||||
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_voiceover_xp_earned" numeric DEFAULT 0;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_scripts_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "voice_submissions_id" integer;
|
||||
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_script_id_voice_scripts_id_fk" FOREIGN KEY ("script_id") REFERENCES "public"."voice_scripts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "voice_submissions" ADD CONSTRAINT "voice_submissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "voice_scripts_updated_at_idx" ON "voice_scripts" USING btree ("updated_at");
|
||||
CREATE INDEX "voice_scripts_created_at_idx" ON "voice_scripts" USING btree ("created_at");
|
||||
CREATE INDEX "voice_submissions_script_idx" ON "voice_submissions" USING btree ("script_id");
|
||||
CREATE INDEX "voice_submissions_user_idx" ON "voice_submissions" USING btree ("user_id");
|
||||
CREATE INDEX "voice_submissions_updated_at_idx" ON "voice_submissions" USING btree ("updated_at");
|
||||
CREATE INDEX "voice_submissions_created_at_idx" ON "voice_submissions" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "voice_submissions_filename_idx" ON "voice_submissions" USING btree ("filename");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk" FOREIGN KEY ("voice_scripts_id") REFERENCES "public"."voice_scripts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk" FOREIGN KEY ("voice_submissions_id") REFERENCES "public"."voice_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_voice_scripts_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_scripts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_voice_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("voice_submissions_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "voice_scripts" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "voice_submissions" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "voice_scripts" CASCADE;
|
||||
DROP TABLE "voice_submissions" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_scripts_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_voice_submissions_fk";
|
||||
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'market-state', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
|
||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
||||
DROP INDEX "payload_locked_documents_rels_voice_scripts_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_voice_submissions_id_idx";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_voiceover_xp_earned";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_scripts_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "voice_submissions_id";
|
||||
DROP TYPE "public"."enum_voice_scripts_status";
|
||||
DROP TYPE "public"."enum_voice_submissions_status";`)
|
||||
}
|
||||
|
|
@ -32,6 +32,9 @@ import * as migration_20260906_050900_add_evaluation_reminders_field from './202
|
|||
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
|
||||
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
|
||||
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
|
||||
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
||||
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
||||
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -189,19 +192,34 @@ export const migrations = [
|
|||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
||||
name: '20260906_050900_add_evaluation_reminders_field',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_061451_add_wiki_template_css.up,
|
||||
down: migration_20260906_061451_add_wiki_template_css.down,
|
||||
name: '20260906_061451_add_wiki_template_css',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_065405_add_wiki_template_html.up,
|
||||
down: migration_20260906_065405_add_wiki_template_html.down,
|
||||
name: '20260906_065405_add_wiki_template_html'
|
||||
name: '20260906_065405_add_wiki_template_html',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_100908_add_market_economy_state.up,
|
||||
down: migration_20260906_100908_add_market_economy_state.down,
|
||||
name: '20260906_100908_add_market_economy_state',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_165837_add_market_state_event_target.up,
|
||||
down: migration_20260906_165837_add_market_state_event_target.down,
|
||||
name: '20260906_165837_add_market_state_event_target',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_202925_add_voice_submissions.up,
|
||||
down: migration_20260906_202925_add_voice_submissions.down,
|
||||
name: '20260906_202925_add_voice_submissions'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ export interface Config {
|
|||
loadouts: Loadout;
|
||||
'market-listings': MarketListing;
|
||||
'market-negotiations': MarketNegotiation;
|
||||
'market-state': MarketState;
|
||||
'voice-scripts': VoiceScript;
|
||||
'voice-submissions': VoiceSubmission;
|
||||
tickets: Ticket;
|
||||
'ticket-votes': TicketVote;
|
||||
'wiki-pages': WikiPage;
|
||||
|
|
@ -172,6 +175,9 @@ export interface Config {
|
|||
loadouts: LoadoutsSelect<false> | LoadoutsSelect<true>;
|
||||
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
||||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||
'market-state': MarketStateSelect<false> | MarketStateSelect<true>;
|
||||
'voice-scripts': VoiceScriptsSelect<false> | VoiceScriptsSelect<true>;
|
||||
'voice-submissions': VoiceSubmissionsSelect<false> | VoiceSubmissionsSelect<true>;
|
||||
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
||||
'ticket-votes': TicketVotesSelect<false> | TicketVotesSelect<true>;
|
||||
'wiki-pages': WikiPagesSelect<false> | WikiPagesSelect<true>;
|
||||
|
|
@ -703,6 +709,27 @@ export interface Resource {
|
|||
*/
|
||||
tradeable?: boolean | null;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'exotic' | 'unique';
|
||||
/**
|
||||
* Baseline world supply and demand used by the market economy simulation (economy tick).
|
||||
*/
|
||||
economy?: {
|
||||
/**
|
||||
* Whether the economy simulation tracks this resource. Disabled items keep their resting price.
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
/**
|
||||
* GM-set resting world supply. Used as the starting point of this resource's market-state ledger.
|
||||
*/
|
||||
baselineSupply?: number | null;
|
||||
/**
|
||||
* GM-set resting demand (NPC faction pressure). Used as the starting point of this resource's market-state ledger.
|
||||
*/
|
||||
baselineDemand?: number | null;
|
||||
/**
|
||||
* Price band, as a fraction of the base value (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.
|
||||
*/
|
||||
maxModifier?: number | null;
|
||||
};
|
||||
/**
|
||||
* The unit of measure for this resource in singular form (no plurals, e.g., liter, kg, point, unit, megawatt, person, etc.)
|
||||
*/
|
||||
|
|
@ -910,6 +937,27 @@ export interface Asset {
|
|||
baseSellPrice?: number | null;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Baseline world supply and demand used by the market economy simulation (economy tick).
|
||||
*/
|
||||
economy?: {
|
||||
/**
|
||||
* Whether the economy simulation tracks this asset. Disabled items keep their resting price.
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
/**
|
||||
* GM-set resting world supply. Used as the starting point of this asset's market-state ledger.
|
||||
*/
|
||||
baselineSupply?: number | null;
|
||||
/**
|
||||
* GM-set resting demand (NPC faction pressure). Used as the starting point of this asset's market-state ledger.
|
||||
*/
|
||||
baselineDemand?: number | null;
|
||||
/**
|
||||
* Price band, as a fraction of the base price (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.
|
||||
*/
|
||||
maxModifier?: number | null;
|
||||
};
|
||||
crafting: {
|
||||
craftable?: boolean | null;
|
||||
/**
|
||||
|
|
@ -1528,6 +1576,11 @@ export interface Role {
|
|||
| 'market-negotiations:update'
|
||||
| 'market-negotiations:delete'
|
||||
| 'admin:market-negotiations:manage'
|
||||
| 'market-state:create'
|
||||
| 'market-state:read'
|
||||
| 'market-state:update'
|
||||
| 'market-state:delete'
|
||||
| 'admin:market-state:manage'
|
||||
| 'projects:create'
|
||||
| 'projects:read'
|
||||
| 'projects:update'
|
||||
|
|
@ -1819,6 +1872,7 @@ export interface GameEventLog {
|
|||
| 'loadouts'
|
||||
| 'market-listings'
|
||||
| 'market-negotiations'
|
||||
| 'market-state'
|
||||
| 'evaluations'
|
||||
| 'tickets'
|
||||
| 'wiki-pages'
|
||||
|
|
@ -1826,6 +1880,8 @@ export interface GameEventLog {
|
|||
| 'wiki-templates'
|
||||
| 'game-servers'
|
||||
| 'assignment-transfers'
|
||||
| 'voice-scripts'
|
||||
| 'voice-submissions'
|
||||
)
|
||||
| null;
|
||||
targetId?: number | null;
|
||||
|
|
@ -1976,6 +2032,9 @@ export interface Profile {
|
|||
xpEarned?: number | null;
|
||||
};
|
||||
};
|
||||
voiceover?: {
|
||||
xpEarned?: number | null;
|
||||
};
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -2975,6 +3034,103 @@ export interface MarketNegotiation {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Supply/demand ledger per item, maintained by the economy tick. Baselines are copied from the item's Supply & Demand fields on first creation.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "market-state".
|
||||
*/
|
||||
export interface MarketState {
|
||||
id: number;
|
||||
/**
|
||||
* Which collection this ledger entry tracks.
|
||||
*/
|
||||
itemType: 'resource' | 'asset';
|
||||
/**
|
||||
* ID of the tracked resource or asset document.
|
||||
*/
|
||||
itemId: number;
|
||||
/**
|
||||
* GM-set resting supply. Copied from the item's Supply & Demand baseline when this entry is first created.
|
||||
*/
|
||||
gmBaselineSupply: number;
|
||||
/**
|
||||
* GM-set resting demand. Copied from the item's Supply & Demand baseline when this entry is first created.
|
||||
*/
|
||||
gmBaselineDemand: number;
|
||||
/**
|
||||
* Living abstract supply. Decremented by unit purchases from NPC listings, incremented by production/sales (later phases).
|
||||
*/
|
||||
currentSupply: number;
|
||||
/**
|
||||
* NPC faction demand pressure. Slow-drifting random walk bounded around the GM baseline, updated per economy tick.
|
||||
*/
|
||||
artificialDemand: number;
|
||||
/**
|
||||
* Decaying rolling aggregate of completed unit purchases (quantity-weighted). Decayed each tick by the Game Rules decay factor.
|
||||
*/
|
||||
realDemand: number;
|
||||
/**
|
||||
* Computed price multiplier applied to the item's base price when generating NEW NPC listings only (1 = resting price). Existing listings and open negotiations are never repriced.
|
||||
*/
|
||||
priceModifier: number;
|
||||
/**
|
||||
* When the economy tick last recomputed this entry.
|
||||
*/
|
||||
lastComputedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "voice-scripts".
|
||||
*/
|
||||
export interface VoiceScript {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Voice talent reads this verbatim. Inline emotion tags like [calm] are stored as written.
|
||||
*/
|
||||
script: string;
|
||||
/**
|
||||
* XP awarded per accepted submission of this script.
|
||||
*/
|
||||
xpAmount: number;
|
||||
/**
|
||||
* Closed scripts reject new submissions
|
||||
*/
|
||||
status: 'open' | 'closed';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "voice-submissions".
|
||||
*/
|
||||
export interface VoiceSubmission {
|
||||
id: number;
|
||||
script: number | VoiceScript;
|
||||
/**
|
||||
* Submitter
|
||||
*/
|
||||
user: number | User;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
/**
|
||||
* Optional admin review note.
|
||||
*/
|
||||
reviewNote?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tickets".
|
||||
|
|
@ -3665,6 +3821,18 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'market-negotiations';
|
||||
value: number | MarketNegotiation;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'market-state';
|
||||
value: number | MarketState;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'voice-scripts';
|
||||
value: number | VoiceScript;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'voice-submissions';
|
||||
value: number | VoiceSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'tickets';
|
||||
value: number | Ticket;
|
||||
|
|
@ -4156,6 +4324,11 @@ export interface ProfilesSelect<T extends boolean = true> {
|
|||
xpEarned?: T;
|
||||
};
|
||||
};
|
||||
voiceover?:
|
||||
| T
|
||||
| {
|
||||
xpEarned?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
@ -4560,6 +4733,14 @@ export interface AssetsSelect<T extends boolean = true> {
|
|||
baseSellPrice?: T;
|
||||
};
|
||||
};
|
||||
economy?:
|
||||
| T
|
||||
| {
|
||||
enabled?: T;
|
||||
baselineSupply?: T;
|
||||
baselineDemand?: T;
|
||||
maxModifier?: T;
|
||||
};
|
||||
crafting?:
|
||||
| T
|
||||
| {
|
||||
|
|
@ -4621,6 +4802,14 @@ export interface ResourcesSelect<T extends boolean = true> {
|
|||
baseValue?: T;
|
||||
tradeable?: T;
|
||||
rarity?: T;
|
||||
economy?:
|
||||
| T
|
||||
| {
|
||||
enabled?: T;
|
||||
baselineSupply?: T;
|
||||
baselineDemand?: T;
|
||||
maxModifier?: T;
|
||||
};
|
||||
unitOfMeasure?: T;
|
||||
massPerUnit?: T;
|
||||
stackSize?: T;
|
||||
|
|
@ -4990,6 +5179,56 @@ export interface MarketNegotiationsSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "market-state_select".
|
||||
*/
|
||||
export interface MarketStateSelect<T extends boolean = true> {
|
||||
itemType?: T;
|
||||
itemId?: T;
|
||||
gmBaselineSupply?: T;
|
||||
gmBaselineDemand?: T;
|
||||
currentSupply?: T;
|
||||
artificialDemand?: T;
|
||||
realDemand?: T;
|
||||
priceModifier?: T;
|
||||
lastComputedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "voice-scripts_select".
|
||||
*/
|
||||
export interface VoiceScriptsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
script?: T;
|
||||
xpAmount?: T;
|
||||
status?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "voice-submissions_select".
|
||||
*/
|
||||
export interface VoiceSubmissionsSelect<T extends boolean = true> {
|
||||
script?: T;
|
||||
user?: T;
|
||||
status?: T;
|
||||
reviewNote?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tickets_select".
|
||||
|
|
@ -5396,6 +5635,39 @@ export interface GameRule {
|
|||
* Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.
|
||||
*/
|
||||
staffHiringDisabledMessage?: string | null;
|
||||
/**
|
||||
* Global knobs for the supply-and-demand market simulation run by the economy tick (docs/economy/supply-demand-design.md).
|
||||
*/
|
||||
economy?: {
|
||||
/**
|
||||
* Master gate for the market economy simulation. When off, the economy tick leaves all prices at their resting values.
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
/**
|
||||
* Default price band as a fraction of base price (e.g. 0.5 = price can move ±50%). Used when an item does not set its own band.
|
||||
*/
|
||||
defaultPriceBand?: number | null;
|
||||
/**
|
||||
* How many economy ticks of completed-purchase history feed the real-demand signal.
|
||||
*/
|
||||
realDemandWindowTicks?: number | null;
|
||||
/**
|
||||
* Fraction of accumulated real demand that survives each tick (0.5 halves it every tick).
|
||||
*/
|
||||
realDemandDecay?: number | null;
|
||||
/**
|
||||
* How far the NPC (artificial) demand random walk may move per tick, as a fraction of the GM baseline.
|
||||
*/
|
||||
artificialDemandDrift?: number | null;
|
||||
/**
|
||||
* Completed purchases needed before an item's price starts moving. Until then items stay at their resting price (modifier 1).
|
||||
*/
|
||||
coldStartObservations?: number | null;
|
||||
/**
|
||||
* Suggested economy tick cadence in minutes, for cron documentation only — the tick itself is triggered externally.
|
||||
*/
|
||||
tickIntervalHintMinutes?: number | null;
|
||||
};
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
|
@ -5477,6 +5749,17 @@ export interface GameRulesSelect<T extends boolean = true> {
|
|||
lockerGridHeight?: T;
|
||||
staffHiringEnabled?: T;
|
||||
staffHiringDisabledMessage?: T;
|
||||
economy?:
|
||||
| T
|
||||
| {
|
||||
enabled?: T;
|
||||
defaultPriceBand?: T;
|
||||
realDemandWindowTicks?: T;
|
||||
realDemandDecay?: T;
|
||||
artificialDemandDrift?: T;
|
||||
coldStartObservations?: T;
|
||||
tickIntervalHintMinutes?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ import { LockerStorages } from "@/collections/locker/LockerStorages";
|
|||
import { Loadouts } from "@/collections/locker/Loadouts";
|
||||
import { MarketListings } from "@/collections/market/MarketListings";
|
||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { MarketState } from "@/collections/market/MarketState";
|
||||
import { VoiceScripts } from "@/collections/voiceover/VoiceScripts";
|
||||
import { VoiceSubmissions } from "@/collections/voiceover/VoiceSubmissions";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { Evaluations } from "@/collections/users/Evaluations";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
|
|
@ -196,6 +199,11 @@ const collections = [
|
|||
// Market
|
||||
MarketListings,
|
||||
MarketNegotiations,
|
||||
MarketState,
|
||||
|
||||
// Voiceover
|
||||
VoiceScripts,
|
||||
VoiceSubmissions,
|
||||
|
||||
// Helpdesk
|
||||
Tickets,
|
||||
|
|
@ -322,6 +330,10 @@ export default buildConfig({
|
|||
scriptPath: path.resolve(dirname, "scripts/baseTick.ts"),
|
||||
key: "base-tick",
|
||||
},
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/economyTick.ts"),
|
||||
key: "economy-tick",
|
||||
},
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
|
||||
key: "generate-mission",
|
||||
|
|
|
|||
|
|
@ -347,6 +347,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
{ value: "admin:market-negotiations:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Market State",
|
||||
permissions: [
|
||||
{ value: "market-state:create", label: "Create" },
|
||||
{ value: "market-state:read", label: "Read" },
|
||||
{ value: "market-state:update", label: "Update" },
|
||||
{ value: "market-state:delete", label: "Delete" },
|
||||
{ value: "admin:market-state:manage", label: "Manage Admin Page" },
|
||||
],
|
||||
},
|
||||
|
||||
// ---- Helpdesk ------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -739,6 +749,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
|||
"market-negotiations:update",
|
||||
"market-negotiations:delete",
|
||||
"admin:market-negotiations:manage",
|
||||
"market-state:create",
|
||||
"market-state:read",
|
||||
"market-state:update",
|
||||
"market-state:delete",
|
||||
"admin:market-state:manage",
|
||||
"projects:create",
|
||||
"projects:read",
|
||||
"projects:update",
|
||||
|
|
|
|||
66
src/scripts/economyTick.ts
Normal file
66
src/scripts/economyTick.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Economy tick bin — recomputes the supply/demand ledger (market-state).
|
||||
*
|
||||
* Run via `bun run payload economy-tick` (external cron). Uses the GameRules
|
||||
* `tickIntervalHintMinutes` knob to seed a deterministic per-period RNG so a
|
||||
* retried run inside the same period produces identical drift steps.
|
||||
*/
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { createSeededRng, periodSeed } from "@/lib/economy/math";
|
||||
import { processEconomyTick } from "@/lib/economy/tick";
|
||||
import { emitEconomyEvents } from "@/lib/economy/events";
|
||||
import { getEconomySettings } from "@/lib/economy/config";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("economy-tick", payload.logger);
|
||||
|
||||
try {
|
||||
log.info("[Economy Tick] Starting economy tick...");
|
||||
|
||||
const now = new Date();
|
||||
const { enabled, knobs } = await getEconomySettings(payload);
|
||||
|
||||
const result = await processEconomyTick(payload, {
|
||||
now,
|
||||
rng: createSeededRng(periodSeed(now, knobs.tickIntervalHintMinutes)),
|
||||
masterGate: enabled,
|
||||
knobs,
|
||||
});
|
||||
|
||||
if (result.disabled) {
|
||||
log.info("[Economy Tick] Master gate is off — nothing processed.");
|
||||
} else {
|
||||
await emitEconomyEvents(payload, result);
|
||||
log.info(
|
||||
`[Economy Tick] Complete. Processed ${result.processed}, skipped ${result.skipped}, ` +
|
||||
`${result.priceChanges.length} price change(s), ${result.errors} error(s).`,
|
||||
);
|
||||
|
||||
// Notify connected clients so economy-driven views refresh.
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
},
|
||||
body: JSON.stringify({ processedAt: now.toISOString(), source: "economy-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
log.error(`[Economy Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Economy Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
|
@ -87,7 +87,7 @@ function main(): void {
|
|||
"utf8",
|
||||
);
|
||||
|
||||
const label = gitDescribe ?? tag ?? version ?? "unknown";
|
||||
const label = gitDescribe ?? tag ?? (version && commitHash ? `${version}+${commitHash}` : null) ?? version ?? "unknown";
|
||||
console.log(`[version-info] wrote src/generated/versionInfo.json (${label})`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { getPayload } from "payload";
|
|||
import type { Asset, MarketListing } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { AUTO_LISTING_DURATION_MS, autoPrice, autoQuantity, expireListing } from "@/lib/market";
|
||||
import { AUTO_LISTING_DURATION_MS, autoQuantity, expireListing, getBaseBuyPrice } from "@/lib/market";
|
||||
import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
|
||||
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
|
||||
import { getMarketPriceContext } from "@/lib/economy/consumption";
|
||||
import { scaleQuantityForShortage } from "@/lib/economy/math";
|
||||
import { resolveAssetId } from "@/lib/locker";
|
||||
import { getMainCurrencyId } from "@/lib/banking";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
|
@ -94,17 +96,26 @@ export const script = async (config: SanitizedConfig) => {
|
|||
let generatedNpcCount = 0;
|
||||
for (const asset of assets.docs as unknown as Asset[]) {
|
||||
if (activeAssetIds.has(asset.id)) continue;
|
||||
const basePrice = autoPrice(asset, "buy");
|
||||
const basePrice = getBaseBuyPrice(asset);
|
||||
if (basePrice == null) continue;
|
||||
|
||||
// Supply/demand modifier from the economy ledger (neutral 1 when the
|
||||
// item has no market-state doc yet), then the vendor's own modifier.
|
||||
const priceCtx = await getMarketPriceContext(payload, "asset", asset.id);
|
||||
const modifiedPrice = Math.max(1, Math.round(basePrice * (priceCtx?.priceModifier ?? 1)));
|
||||
|
||||
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
|
||||
// fall back to generating a placeholder NPC when none sell this good.
|
||||
const npc = await resolveVendorNpc(payload, asset.id);
|
||||
if (npc.isGenerated === true) generatedNpcCount++;
|
||||
const price = applyNpcPriceModifier(basePrice, npc);
|
||||
const price = applyNpcPriceModifier(modifiedPrice, npc);
|
||||
if (price <= 0) continue;
|
||||
|
||||
const quantity = autoQuantity(asset);
|
||||
// Vendors restock less under shortage; bounded so they never stock zero.
|
||||
const demand = priceCtx ? priceCtx.artificialDemand + priceCtx.realDemand : 0;
|
||||
const quantity = priceCtx
|
||||
? scaleQuantityForShortage(autoQuantity(asset), priceCtx.currentSupply, demand)
|
||||
: autoQuantity(asset);
|
||||
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
|
||||
const minPrice = Math.max(1, Math.round(price * 0.6));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ interface GameEventInput {
|
|||
| "wiki-revisions"
|
||||
| "wiki-templates"
|
||||
| "game-servers"
|
||||
| "assignment-transfers";
|
||||
| "assignment-transfers"
|
||||
| "market-state"
|
||||
| "voice-scripts"
|
||||
| "voice-submissions";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ export const EventTypes = {
|
|||
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||
MinigameAimTrainer: "minigame:aim-trainer",
|
||||
|
||||
// Voiceover
|
||||
VoiceoverSubmit: "voiceover:submit",
|
||||
VoiceoverAccepted: "voiceover:accepted",
|
||||
VoiceoverRejected: "voiceover:rejected",
|
||||
|
||||
// Market
|
||||
MarketListingCreate: "market:listing-create",
|
||||
MarketListingCancel: "market:listing-cancel",
|
||||
|
|
@ -90,6 +95,11 @@ export const EventTypes = {
|
|||
MarketOfferCancel: "market:offer-cancel",
|
||||
MarketOfferClosed: "market:offer-closed",
|
||||
|
||||
// Economy
|
||||
EconomyTick: "economy:tick",
|
||||
EconomyPriceChange: "economy:price-change",
|
||||
EconomyReset: "economy:reset",
|
||||
|
||||
// NPCs
|
||||
NpcGenerate: "npc:generate",
|
||||
|
||||
|
|
|
|||
264
tests/int/economy-consumption.int.spec.ts
Normal file
264
tests/int/economy-consumption.int.spec.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Asset, BankAccount, MarketListing, User } from "@/payload-types";
|
||||
import { buyListing } from "@/lib/market";
|
||||
import { resolveVendorNpc } from "@/lib/market/npcs";
|
||||
import { ensureLockerStorage } from "@/lib/locker";
|
||||
import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking";
|
||||
import { scaleQuantityForShortage } from "@/lib/economy/math";
|
||||
import {
|
||||
getMarketPriceContext,
|
||||
recordPurchase,
|
||||
resetMarketStateDocs,
|
||||
} from "@/lib/economy/consumption";
|
||||
import { ensureMarketStateDocs } from "@/lib/economy/tick";
|
||||
|
||||
/**
|
||||
* Pure tests for the shortage-scaling helper. The price composition itself
|
||||
* (round(base × modifier) then applyNpcPriceModifier) lives in the bin script,
|
||||
* which is not unit-testable; the lib-level pieces below cover the testable
|
||||
* seams: restock scaling, purchase ingestion, and GM reset.
|
||||
*/
|
||||
describe("Economy consumption: scaleQuantityForShortage", () => {
|
||||
it("keeps the quantity unchanged when supply meets demand", () => {
|
||||
expect(scaleQuantityForShortage(5, 100, 50)).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps the quantity unchanged when demand is zero", () => {
|
||||
expect(scaleQuantityForShortage(4, 0, 0)).toBe(4);
|
||||
});
|
||||
|
||||
it("scales the quantity down proportionally under shortage", () => {
|
||||
// supply/demand = 50/100 = 0.5 → 5 × 0.5 = 2.5 → floor 2
|
||||
expect(scaleQuantityForShortage(5, 50, 100)).toBe(2);
|
||||
});
|
||||
|
||||
it("never scales below the 0.25 floor factor", () => {
|
||||
// supply/demand = 10/100 = 0.1 → clamped to 0.25 → 8 × 0.25 = 2
|
||||
expect(scaleQuantityForShortage(8, 10, 100)).toBe(2);
|
||||
});
|
||||
|
||||
it("never returns zero, even under extreme shortage", () => {
|
||||
expect(scaleQuantityForShortage(1, 1, 1000)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Economy consumption: DB integration", () => {
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `econ-cons-${Date.now().toString(36)}`;
|
||||
let assetId: number;
|
||||
let buyer: User;
|
||||
let buyerAccount: BankAccount;
|
||||
|
||||
const findStateDoc = async (id: number) => {
|
||||
const docs = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: id } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = docs.docs[0];
|
||||
if (!doc) throw new Error("market-state doc not found for fixture asset");
|
||||
return doc;
|
||||
};
|
||||
|
||||
const createFixtureAsset = async (): Promise<number> => {
|
||||
const created = (await payload.create({
|
||||
collection: "assets",
|
||||
data: {
|
||||
name: `${RUN} Asset`,
|
||||
className: "test",
|
||||
assetType: "miscellaneous",
|
||||
approvalStatus: "in_progress",
|
||||
tradeable: true,
|
||||
isLive: true,
|
||||
marketTrading: { Rarity: "common", perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 } },
|
||||
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
|
||||
storageDimensions: { gridWidth: 1, gridHeight: 1 },
|
||||
economy: { enabled: true, baselineSupply: 100, baselineDemand: 50, maxModifier: 0.5 },
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Asset;
|
||||
return created.id;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
assetId = await createFixtureAsset();
|
||||
|
||||
buyer = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-buyer`,
|
||||
discordUsername: `${RUN}-buyer`,
|
||||
displayName: "BUYER",
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
|
||||
await ensureLockerStorage(payload, buyer.id);
|
||||
buyerAccount = await createAccount(payload, {
|
||||
name: `${RUN} buyer wallet`,
|
||||
accountType: "personal",
|
||||
ownerUserId: buyer.id,
|
||||
});
|
||||
await applyTransaction(payload, {
|
||||
type: "deposit",
|
||||
toAccountId: buyerAccount.id,
|
||||
amount: 10_000,
|
||||
memo: "economy consumption test funding",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const cleanup = async (fn: () => Promise<unknown>) => {
|
||||
await fn().catch(() => {});
|
||||
};
|
||||
|
||||
const listings = await payload
|
||||
.find({
|
||||
collection: "market-listings",
|
||||
where: { asset: { equals: assetId } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as MarketListing[] }));
|
||||
for (const listing of listings.docs) {
|
||||
await cleanup(() =>
|
||||
payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }),
|
||||
);
|
||||
}
|
||||
|
||||
await cleanup(async () => {
|
||||
const doc = await findStateDoc(assetId);
|
||||
await payload.delete({ collection: "market-state", id: doc.id, overrideAccess: true });
|
||||
});
|
||||
await cleanup(() => payload.delete({ collection: "assets", id: assetId, overrideAccess: true }));
|
||||
await cleanup(() =>
|
||||
payload.delete({ collection: "users", id: buyer.id, overrideAccess: true }),
|
||||
);
|
||||
await cleanup(() =>
|
||||
payload.delete({ collection: "bank-accounts", id: buyerAccount.id, overrideAccess: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds a market-state doc from the asset's GM baselines", async () => {
|
||||
const result = await ensureMarketStateDocs(payload, {
|
||||
items: [{ itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
expect(result.created).toBe(1);
|
||||
|
||||
const doc = await findStateDoc(assetId);
|
||||
expect(doc.gmBaselineSupply).toBe(100);
|
||||
expect(doc.gmBaselineDemand).toBe(50);
|
||||
expect(doc.currentSupply).toBe(100);
|
||||
expect(doc.artificialDemand).toBe(50);
|
||||
expect(doc.realDemand).toBe(0);
|
||||
expect(doc.priceModifier).toBe(1);
|
||||
});
|
||||
|
||||
it("recordPurchase adds quantity-weighted real demand", async () => {
|
||||
await recordPurchase(payload, { itemId: assetId, quantity: 3 });
|
||||
const doc = await findStateDoc(assetId);
|
||||
expect(doc.realDemand).toBe(3);
|
||||
|
||||
await recordPurchase(payload, { itemId: assetId, quantity: 2 });
|
||||
const after = await findStateDoc(assetId);
|
||||
expect(after.realDemand).toBe(5);
|
||||
});
|
||||
|
||||
it("getMarketPriceContext returns the ledger state for an item", async () => {
|
||||
const ctx = await getMarketPriceContext(payload, "asset", assetId);
|
||||
expect(ctx).not.toBeNull();
|
||||
if (!ctx) throw new Error("expected price context");
|
||||
expect(ctx.priceModifier).toBe(1);
|
||||
expect(ctx.currentSupply).toBe(100);
|
||||
|
||||
const missing = await getMarketPriceContext(payload, "asset", assetId + 999_999);
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it("buyListing of an NPC listing ingests real demand", async () => {
|
||||
const npc = await resolveVendorNpc(payload, assetId);
|
||||
const currencyId = await getMainCurrencyId(payload);
|
||||
const nowIso = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// The previous test left real demand on the doc; this test needs a
|
||||
// clean ledger to assert the exact ingested quantity.
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: (await findStateDoc(assetId)).id,
|
||||
data: { realDemand: 0 },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const created = (await payload.create({
|
||||
collection: "market-listings",
|
||||
data: {
|
||||
asset: assetId,
|
||||
seller: null,
|
||||
npc: npc.id,
|
||||
quantity: 5,
|
||||
price: 100,
|
||||
desiredPrice: 100,
|
||||
minPrice: 60,
|
||||
currency: currencyId ?? null,
|
||||
status: "active",
|
||||
isAutoGenerated: true,
|
||||
listedAt: nowIso,
|
||||
expiresAt,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MarketListing;
|
||||
|
||||
const populated = await payload.findByID({
|
||||
collection: "market-listings",
|
||||
id: created.id,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await buyListing(payload, populated, buyer);
|
||||
|
||||
const doc = await findStateDoc(assetId);
|
||||
expect(doc.realDemand).toBe(5);
|
||||
|
||||
await payload.delete({ collection: "market-listings", id: created.id, overrideAccess: true });
|
||||
});
|
||||
|
||||
it("resetMarketStateDocs rebuilds the ledger from GM baselines", async () => {
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: (await findStateDoc(assetId)).id,
|
||||
data: { realDemand: 10, currentSupply: 3, priceModifier: 1.5 },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const result = await resetMarketStateDocs(payload, {
|
||||
items: [{ itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
expect(result.reset).toBe(1);
|
||||
|
||||
const doc = await findStateDoc(assetId);
|
||||
expect(doc.currentSupply).toBe(100);
|
||||
expect(doc.artificialDemand).toBe(50);
|
||||
expect(doc.realDemand).toBe(0);
|
||||
expect(doc.priceModifier).toBe(1);
|
||||
});
|
||||
});
|
||||
292
tests/int/economy-events.int.spec.ts
Normal file
292
tests/int/economy-events.int.spec.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* Integration tests for economy-tick determinism (seeded rng, period seeding),
|
||||
* price-change emission threshold, and the economy event log wiring.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Payload } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { emitEconomyEvents } from "@/lib/economy/events";
|
||||
import {
|
||||
createSeededRng,
|
||||
driftArtificialDemand,
|
||||
periodSeed,
|
||||
PRICE_CHANGE_THRESHOLD,
|
||||
shouldEmitPriceChange,
|
||||
} from "@/lib/economy/math";
|
||||
import { ECONOMY_DEFAULTS } from "@/lib/economy/types";
|
||||
import { ensureMarketStateDocs, processEconomyTick } from "@/lib/economy/tick";
|
||||
|
||||
describe("economy seeded rng + period seeding (pure)", () => {
|
||||
it("createSeededRng produces identical sequences for the same seed", () => {
|
||||
const a = createSeededRng(42);
|
||||
const b = createSeededRng(42);
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
});
|
||||
|
||||
it("createSeededRng produces different sequences for different seeds", () => {
|
||||
const a = createSeededRng(1);
|
||||
const b = createSeededRng(2);
|
||||
expect([a(), a(), a()]).not.toEqual([b(), b(), b()]);
|
||||
});
|
||||
|
||||
it("periodSeed is stable within a period and changes across periods", () => {
|
||||
const now = new Date("2026-09-06T12:34:56.000Z");
|
||||
const withinPeriod = new Date("2026-09-06T12:35:59.000Z");
|
||||
const nextPeriod = new Date("2026-09-06T13:35:59.000Z");
|
||||
// 60-minute periods: 12:34 and 12:35 fall in the same period window.
|
||||
expect(periodSeed(now, 60)).toBe(periodSeed(withinPeriod, 60));
|
||||
expect(periodSeed(now, 60)).not.toBe(periodSeed(nextPeriod, 60));
|
||||
});
|
||||
|
||||
it("seeded rng drives driftArtificialDemand deterministically", () => {
|
||||
const seed = periodSeed(new Date("2026-09-06T00:00:00Z"), 60);
|
||||
const first = driftArtificialDemand(50, 50, 0.05, createSeededRng(seed));
|
||||
const again = driftArtificialDemand(50, 50, 0.05, createSeededRng(seed));
|
||||
expect(first).toBe(again);
|
||||
});
|
||||
});
|
||||
|
||||
describe("price-change emission threshold (pure)", () => {
|
||||
it("PRICE_CHANGE_THRESHOLD is 0.05", () => {
|
||||
expect(PRICE_CHANGE_THRESHOLD).toBe(0.05);
|
||||
});
|
||||
|
||||
it("shouldEmitPriceChange emits for changes >= threshold", () => {
|
||||
expect(shouldEmitPriceChange(1, 1.06)).toBe(true);
|
||||
expect(shouldEmitPriceChange(1, 0.9)).toBe(true);
|
||||
});
|
||||
|
||||
it("shouldEmitPriceChange is quiet below the threshold", () => {
|
||||
expect(shouldEmitPriceChange(1, 1.001)).toBe(false);
|
||||
expect(shouldEmitPriceChange(1, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("economy events (DB)", () => {
|
||||
let payload: Payload;
|
||||
const run = `econ-ev-${Date.now().toString(36)}`;
|
||||
let resourceId: number | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Remove fixtures in reverse dependency order; tolerate missing docs.
|
||||
if (resourceId !== null) {
|
||||
const states = await payload
|
||||
.find({
|
||||
collection: "market-state",
|
||||
where: {
|
||||
and: [
|
||||
{ itemType: { equals: "resource" } },
|
||||
{ itemId: { equals: resourceId } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 10,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const doc of states?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "market-state", id: doc.id })
|
||||
.catch(() => {});
|
||||
}
|
||||
await payload
|
||||
.delete({ collection: "resources", id: resourceId })
|
||||
.catch(() => {});
|
||||
}
|
||||
const events = await payload
|
||||
.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { in: ["economy:tick", "economy:price-change"] } },
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const doc of events?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "game-event-logs", id: doc.id })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
async function createFixtureResource(): Promise<number> {
|
||||
const doc = await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${run} Res`,
|
||||
codeName: `${run}-res`,
|
||||
baseValue: 100,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
tradeable: true,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
economy: { enabled: true, maxModifier: 0.5 },
|
||||
},
|
||||
});
|
||||
resourceId = doc.id;
|
||||
return doc.id;
|
||||
}
|
||||
|
||||
async function findStateDoc(resourceIdValue: number) {
|
||||
const states = await payload.find({
|
||||
collection: "market-state",
|
||||
where: {
|
||||
and: [
|
||||
{ itemType: { equals: "resource" } },
|
||||
{ itemId: { equals: resourceIdValue } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = states.docs[0];
|
||||
expect(doc).toBeDefined();
|
||||
return doc as NonNullable<typeof doc>;
|
||||
}
|
||||
|
||||
/** Give each DB test a clean event slate so counts are exact and independent. */
|
||||
async function deleteEconomyEvents(): Promise<void> {
|
||||
const events = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { in: ["economy:tick", "economy:price-change"] } },
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const doc of events.docs) {
|
||||
await payload
|
||||
.delete({ collection: "game-event-logs", id: doc.id })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
it("processEconomyTick skips already-computed docs within the same period", async () => {
|
||||
const id = await createFixtureResource();
|
||||
await ensureMarketStateDocs(payload, {
|
||||
items: [{ itemType: "resource", itemId: id }],
|
||||
});
|
||||
const periodStart = new Date("2026-09-06T12:00:00.000Z");
|
||||
const first = await processEconomyTick(payload, {
|
||||
now: periodStart,
|
||||
rng: createSeededRng(7),
|
||||
items: [{ itemType: "resource", itemId: id }],
|
||||
masterGate: true,
|
||||
periodStart,
|
||||
});
|
||||
expect(first.processed).toBeGreaterThan(0);
|
||||
expect(first.skipped).toBe(0);
|
||||
const second = await processEconomyTick(payload, {
|
||||
now: periodStart,
|
||||
rng: createSeededRng(7),
|
||||
items: [{ itemType: "resource", itemId: id }],
|
||||
masterGate: true,
|
||||
periodStart,
|
||||
});
|
||||
expect(second.processed).toBe(0);
|
||||
expect(second.skipped).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("emits economy:price-change event when the modifier moves beyond the threshold", async () => {
|
||||
const id = resourceId;
|
||||
if (id === null) throw new Error("fixture resource missing");
|
||||
// Force a large ratio so the modifier jumps by >= PRICE_CHANGE_THRESHOLD.
|
||||
const stateDoc = await findStateDoc(id);
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: stateDoc.id,
|
||||
data: { realDemand: 10, currentSupply: 10 },
|
||||
overrideAccess: true,
|
||||
});
|
||||
const result = await processEconomyTick(payload, {
|
||||
now: new Date("2026-09-06T13:00:00.000Z"),
|
||||
rng: createSeededRng(11),
|
||||
items: [{ itemType: "resource", itemId: id }],
|
||||
masterGate: true,
|
||||
});
|
||||
const change = result.priceChanges.find((pc) => pc.itemId === id);
|
||||
expect(change).toBeDefined();
|
||||
if (!change) throw new Error("expected a price change");
|
||||
expect(change.stateId).toBe(stateDoc.id);
|
||||
expect(shouldEmitPriceChange(change.from, change.to)).toBe(true);
|
||||
|
||||
await deleteEconomyEvents();
|
||||
await emitEconomyEvents(payload, result);
|
||||
|
||||
const logged = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { equals: EventTypes.EconomyPriceChange } },
|
||||
depth: 0,
|
||||
limit: 10,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const mine = logged.docs.find(
|
||||
(d) => d.targetId === stateDoc.id && d.targetCollection === "market-state",
|
||||
);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine?.system).toBe(true);
|
||||
expect(mine?.data).toMatchObject({ itemType: "resource", itemId: id });
|
||||
});
|
||||
|
||||
it("emits only the batch economy:tick summary when prices stay quiet", async () => {
|
||||
const id = resourceId;
|
||||
if (id === null) throw new Error("fixture resource missing");
|
||||
// Reset to balanced numbers, neutral modifier, and zero real demand so
|
||||
// the cold-start gate keeps the modifier at exactly 1.
|
||||
const stateDoc = await findStateDoc(id);
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: stateDoc.id,
|
||||
data: {
|
||||
gmBaselineDemand: 100,
|
||||
artificialDemand: 100,
|
||||
currentSupply: 100,
|
||||
realDemand: 0,
|
||||
priceModifier: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
const result = await processEconomyTick(payload, {
|
||||
now: new Date("2026-09-06T14:00:00.000Z"),
|
||||
rng: createSeededRng(23),
|
||||
items: [{ itemType: "resource", itemId: id }],
|
||||
masterGate: true,
|
||||
knobs: { ...ECONOMY_DEFAULTS, artificialDemandDrift: 0.001 },
|
||||
});
|
||||
expect(result.priceChanges.length).toBe(0);
|
||||
|
||||
await deleteEconomyEvents();
|
||||
await emitEconomyEvents(payload, result);
|
||||
|
||||
const priceChanges = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { equals: EventTypes.EconomyPriceChange } },
|
||||
depth: 0,
|
||||
limit: 10,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(priceChanges.docs.length).toBe(0);
|
||||
|
||||
const summaries = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: { type: { equals: EventTypes.EconomyTick } },
|
||||
depth: 0,
|
||||
limit: 10,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(summaries.docs.length).toBe(1);
|
||||
expect(summaries.docs[0]?.system).toBe(true);
|
||||
});
|
||||
});
|
||||
267
tests/int/economy.int.spec.ts
Normal file
267
tests/int/economy.int.spec.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getPayload, type CollectionSlug, type Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import {
|
||||
applyEconomyTick,
|
||||
computePriceModifier,
|
||||
decayRealDemand,
|
||||
driftArtificialDemand,
|
||||
priceModifierFromRatio,
|
||||
round4,
|
||||
} from "@/lib/economy/math";
|
||||
import type { EconomyKnobs, MarketStateSnapshot } from "@/lib/economy/types";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `econ-${Date.now().toString(36)}`;
|
||||
|
||||
const KNOBS: EconomyKnobs = {
|
||||
defaultPriceBand: 0.5,
|
||||
realDemandWindowTicks: 24,
|
||||
realDemandDecay: 0.5,
|
||||
artificialDemandDrift: 0.05,
|
||||
coldStartObservations: 5,
|
||||
tickIntervalHintMinutes: 60,
|
||||
};
|
||||
|
||||
let resourceId: number;
|
||||
let assetId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const cleanup = async (collection: CollectionSlug, id: number) => {
|
||||
await payload.delete({ collection, id }).catch(() => {});
|
||||
};
|
||||
const cleanupState = async () => {
|
||||
for (const [itemType, itemId] of [
|
||||
["resource", resourceId],
|
||||
["asset", assetId],
|
||||
] as const) {
|
||||
if (itemId === undefined) continue;
|
||||
const { docs } = await payload
|
||||
.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
})
|
||||
.catch(() => ({ docs: [] as { id: number }[] }));
|
||||
for (const doc of docs) {
|
||||
await payload.delete({ collection: "market-state", id: doc.id }).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
await cleanupState();
|
||||
if (resourceId !== undefined) await cleanup("resources", resourceId);
|
||||
if (assetId !== undefined) await cleanup("assets", assetId);
|
||||
});
|
||||
|
||||
describe("economy math (pure)", () => {
|
||||
it("rounds to 4 decimal places", () => {
|
||||
expect(round4(0.123456789)).toBe(0.1235);
|
||||
expect(round4(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("decays real demand by the decay factor", () => {
|
||||
expect(decayRealDemand(100, 0.5)).toBe(50);
|
||||
expect(decayRealDemand(100, 0)).toBe(100);
|
||||
expect(decayRealDemand(0, 0.5)).toBe(0);
|
||||
expect(decayRealDemand(10, 1 / 3)).toBe(round4(10 * (2 / 3)));
|
||||
});
|
||||
|
||||
it("drifts artificial demand deterministically from the rng", () => {
|
||||
// rng() = 1 → full upward step of drift * baseline
|
||||
expect(driftArtificialDemand(50, 100, 0.05, () => 1)).toBe(55);
|
||||
// rng() = 0 → full downward step
|
||||
expect(driftArtificialDemand(50, 100, 0.05, () => 0)).toBe(45);
|
||||
// rng() = 0.5 → zero step
|
||||
expect(driftArtificialDemand(50, 100, 0.05, () => 0.5)).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps artificial demand to [0, 2 × baseline]", () => {
|
||||
expect(driftArtificialDemand(195, 100, 0.05, () => 1)).toBe(200);
|
||||
expect(driftArtificialDemand(3, 100, 0.05, () => 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("derives the price modifier from the demand/supply ratio, clamped to the band", () => {
|
||||
expect(priceModifierFromRatio(100, 100, 0.5)).toBe(1);
|
||||
// shortage: demand 200 / supply 100 → 2 clamped to 1.5
|
||||
expect(priceModifierFromRatio(100, 200, 0.5)).toBe(1.5);
|
||||
// surplus: demand 50 / supply 100 → 0.5
|
||||
expect(priceModifierFromRatio(100, 50, 0.5)).toBe(0.5);
|
||||
// zero supply → maximum price
|
||||
expect(priceModifierFromRatio(0, 100, 0.5)).toBe(1.5);
|
||||
// band larger than 1 is clamped to [0, 1]
|
||||
expect(priceModifierFromRatio(100, 300, 5)).toBe(2);
|
||||
});
|
||||
|
||||
it("holds the modifier neutral during cold start", () => {
|
||||
expect(computePriceModifier(100, 60, 4, 0.5, KNOBS)).toBe(1);
|
||||
expect(computePriceModifier(100, 60, 5, 0.5, KNOBS)).toBe(0.6);
|
||||
});
|
||||
|
||||
it("applies a full economy tick to a snapshot", () => {
|
||||
const state: MarketStateSnapshot = {
|
||||
currentSupply: 100,
|
||||
artificialDemand: 50,
|
||||
realDemand: 10,
|
||||
priceModifier: 1.5,
|
||||
};
|
||||
const next = applyEconomyTick(state, { baselineDemand: 100, priceBand: 0.5 }, KNOBS, () => 0.5);
|
||||
// realDemand decays 10 → 5; artificialDemand unchanged (rng 0.5 = zero step)
|
||||
expect(next.realDemand).toBe(5);
|
||||
expect(next.artificialDemand).toBe(50);
|
||||
// total demand 50 + 5 = 55, ratio 55/100 = 0.55 within band; supply untouched
|
||||
expect(next.currentSupply).toBe(100);
|
||||
expect(next.priceModifier).toBe(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("economy engine (database)", () => {
|
||||
it("ensures market-state docs for enabled tradeable items, copying GM baselines", async () => {
|
||||
const resource = await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Resource`,
|
||||
codeName: `${RUN}-res`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 10,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "units",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
tradeable: true,
|
||||
economy: { enabled: true, baselineSupply: 120, baselineDemand: 60 },
|
||||
},
|
||||
});
|
||||
resourceId = resource.id;
|
||||
|
||||
const asset = await payload.create({
|
||||
collection: "assets",
|
||||
data: {
|
||||
name: `${RUN} Asset`,
|
||||
className: "test",
|
||||
assetType: "miscellaneous",
|
||||
approvalStatus: "in_progress",
|
||||
tradeable: true,
|
||||
isLive: true,
|
||||
marketTrading: {
|
||||
Rarity: "common",
|
||||
perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 },
|
||||
},
|
||||
economy: { enabled: true, baselineSupply: 80, baselineDemand: 40 },
|
||||
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
|
||||
storageDimensions: { gridWidth: 1, gridHeight: 1 },
|
||||
},
|
||||
});
|
||||
assetId = asset.id;
|
||||
|
||||
const { ensureMarketStateDocs } = await import("@/lib/economy/tick");
|
||||
// Scoped to this spec's fixtures: an unfiltered ensure would create/see
|
||||
// docs belonging to other test files sharing this database.
|
||||
const summary = await ensureMarketStateDocs(payload, {
|
||||
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
expect(summary.created).toBe(2);
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "resource" } }, { itemId: { equals: resourceId } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
expect(docs).toHaveLength(1);
|
||||
expect(docs[0].gmBaselineSupply).toBe(120);
|
||||
expect(docs[0].gmBaselineDemand).toBe(60);
|
||||
expect(docs[0].currentSupply).toBe(120);
|
||||
expect(docs[0].artificialDemand).toBe(60);
|
||||
expect(docs[0].realDemand).toBe(0);
|
||||
expect(docs[0].priceModifier).toBe(1);
|
||||
});
|
||||
|
||||
it("is idempotent — a second ensure pass creates nothing new", async () => {
|
||||
const { ensureMarketStateDocs } = await import("@/lib/economy/tick");
|
||||
const summary = await ensureMarketStateDocs(payload, {
|
||||
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
expect(summary.created).toBe(0);
|
||||
});
|
||||
|
||||
it("processes a tick: decays real demand, recomputes the modifier, stamps lastComputedAt", async () => {
|
||||
const { processEconomyTick } = await import("@/lib/economy/tick");
|
||||
const { docs } = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "resource" } }, { itemId: { equals: resourceId } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
await payload.update({
|
||||
collection: "market-state",
|
||||
id: docs[0].id,
|
||||
data: { realDemand: 10 },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const now = new Date("2026-09-06T00:00:00Z");
|
||||
const result = await processEconomyTick(payload, {
|
||||
now,
|
||||
knobs: KNOBS,
|
||||
masterGate: true,
|
||||
rng: () => 0.5,
|
||||
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.processed).toBeGreaterThanOrEqual(2);
|
||||
const resourceChange = result.priceChanges.find(
|
||||
(c) => c.itemType === "resource" && c.itemId === resourceId,
|
||||
);
|
||||
// demand 60 + 5 = 65, supply 120 → 65/120 = 0.5417 within band
|
||||
expect(resourceChange).toBeDefined();
|
||||
expect(resourceChange?.from).toBe(1);
|
||||
expect(resourceChange?.to).toBe(0.5417);
|
||||
|
||||
const after = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
|
||||
expect(after.realDemand).toBe(5);
|
||||
expect(after.priceModifier).toBe(0.5417);
|
||||
expect(new Date(after.lastComputedAt as unknown as string).toISOString()).toBe(
|
||||
now.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not move the price during cold start", async () => {
|
||||
const { processEconomyTick } = await import("@/lib/economy/tick");
|
||||
const { docs } = await payload.find({
|
||||
collection: "market-state",
|
||||
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: assetId } }] },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
const before = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
|
||||
expect(before.realDemand).toBe(0);
|
||||
|
||||
await processEconomyTick(payload, {
|
||||
now: new Date("2026-09-06T00:01:00Z"),
|
||||
knobs: KNOBS,
|
||||
masterGate: true,
|
||||
rng: () => 0.5,
|
||||
items: [{ itemType: "asset", itemId: assetId }],
|
||||
});
|
||||
|
||||
const after = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
|
||||
expect(after.priceModifier).toBe(1);
|
||||
});
|
||||
|
||||
it("respects the master gate — disabled economy is a no-op", async () => {
|
||||
const { processEconomyTick } = await import("@/lib/economy/tick");
|
||||
const result = await processEconomyTick(payload, { now: new Date(), masterGate: false });
|
||||
expect(result.disabled).toBe(true);
|
||||
expect(result.processed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,6 @@ import config from "@/payload.config";
|
|||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types";
|
||||
import {
|
||||
autoPrice,
|
||||
autoQuantity,
|
||||
buyListing,
|
||||
countCleanQuantity,
|
||||
|
|
@ -445,16 +444,13 @@ describe("Marketplace", () => {
|
|||
expect(countCleanQuantity(after, assetId)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("autoPrice/autoQuantity produce sane vendor entries", async () => {
|
||||
it("autoQuantity produces sane vendor entries", async () => {
|
||||
const asset = (await payload.findByID({
|
||||
collection: "assets",
|
||||
id: assetId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as Asset;
|
||||
const price = autoPrice(asset, "buy");
|
||||
expect(price).not.toBeNull();
|
||||
expect(price as number).toBeGreaterThan(0);
|
||||
expect(autoQuantity(asset)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue