1
0
Fork 0

Compare commits

...

14 commits

Author SHA1 Message Date
9f35dc7ddd fix(nav): remove duplicated qualification course menu item 2026-09-06 17:24:50 -04:00
4524f969a6 v0.2.1 2026-09-06 17:23:08 -04:00
dbe8a210fb chore(cms): regenerate payload types for market state and voiceover
Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
b9a619807d docs: refresh AGENTS.md for economy tick, market state, and version bumping
Document the economy tick and consumption pass, the market-state collection, seven-bin file logging, and the version-bump-after-git-workflow guidance.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
db5745d458 fix(version): compose version+commit label when git describe is unavailable
Coolify builds have no git metadata, so fall back to <semver>+<commit sha> in both the version overlay and version-info generator instead of showing a bare version.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
3bc689d572 refactor(nav): flatten sidebar sections into direct links
Replace nested section submenus in Intelligence, Infrastructure, and Personnel with flat per-page links carrying icons; drop the defensive item copying in AppSidebar.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
b558569b8e docs(wiki): reword demo article examples
Simplify punctuation in the wiki demo article (em-dash asides become commas/periods).

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
28250550ab feat(wiki): add rendered template preview to template dialog
Template dialog gains a rendered preview (markdown pipeline + scoped template CSS + HTML templates) alongside the existing source preview, with Rendered/Markdown toggle; editor threads the template maps through.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
44b78ba7d9 feat(voiceover): add submission pages, recorder UI, and server actions
Add /voiceover script list and take detail pages, teleprompter reader, voice capture/recorder components, submission server actions with a 9 MB audio cap, and a Contribute > Voiceover entry in the user menu.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:23:03 -04:00
f83301a923 feat(voiceover): add voice script and submission collections with profile XP
Add VoiceScripts and VoiceSubmissions collections, voiceover event types and event-log targets, the voice-submissions migration, and a voiceover XP group on Profiles counted toward total XP.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:50 -04:00
13f2132e25 feat(market): add economy reference prices and per-listing reset tool
Add getReferencePrices (modifier-applied NPC vendor price tip in CreateListingDialog) and resetEconomyState server action with a permission-gated 'Reset economy' button on listing cards for managers.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:34 -04:00
d4c8d81e2f feat(economy): ingest purchase demand and price vendor listings from the ledger
Market-tick prices new NPC listings from the economy ledger (base x priceModifier, vendor modifier applied) instead of the old autoPrice +/-15% randomness, and scales restock quantity down under shortage. buyListing records real demand from completed purchases of NPC/vendor stock.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:32 -04:00
447df220f5 feat(economy): add economy tick with demand simulation and price modifiers
Add the src/lib/economy service (GM baselines, deterministic per-period demand drift, price modifier computation, event emission) and the economy-tick bin. Gated by GameRules economy.enabled; period-idempotent with a cold-start observation gate.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:30 -04:00
ded5d4b341 feat(economy): add market-state collection with supply/demand baselines
Introduce the market-state ledger (one doc per tradeable resource/asset), GM baseline fields on Assets/Resources, global economy knobs on Game Rules, market-state RBAC permissions, and the market-state event target option.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:17 -04:00
61 changed files with 73743 additions and 132 deletions

View file

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

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

View file

@ -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:
![An aerial reconnaissance photo of the objective](/images/logo.webp "AO recon — taken by Reaper 6 at 0642")
![An aerial reconnaissance photo of the objective](/images/logo.webp "AO recon, taken by Reaper 6 at 0642")
## 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.

View file

@ -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",

View file

@ -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 };
}
}

View file

@ -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>
);

View 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>
);
}

View 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 };
}
}

View 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>
);
}

View file

@ -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 = {

View file

@ -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.",
},
},
],
},
],
};

View file

@ -397,6 +397,56 @@ export const Assets: CollectionConfig = {
],
},
],
},
{
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",

View file

@ -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",

View 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.",
},
},
],
};

View file

@ -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,
},
],
},
],
},
],

View 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",
},
},
],
};

View 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,
};

View file

@ -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} />

View file

@ -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">

View file

@ -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 = {
@ -32,57 +40,52 @@ export const sidebarData = {
},
],
navIntelligence: [
{
title: "Intelligence",
url: "#",
icon: Crosshair,
items: [
{
title: "Campaigns",
url: "/intelligence/campaigns",
icon: Swords,
},
{
title: "Missions",
url: "/intelligence/missions",
icon: Crosshair,
},
{
title: "Factions",
url: "/intelligence/factions",
icon: Flag,
},
{
title: "Wiki",
url: "/wiki",
},
],
icon: BookOpen,
},
],
navLogistics: [
{
title: "Logistics",
url: "#",
icon: Warehouse,
items: [
{
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: [
@ -98,20 +101,15 @@ export const sidebarData = {
},
],
navPersonnel: [
{
title: "Personnel",
url: "#",
icon: Contact,
items: [
{
title: "Contacts",
url: "/personnel/contacts",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
},
],
icon: BarChart3,
},
],
navPersonal: [

View file

@ -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>
)}

View file

@ -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>
<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>

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -215,6 +215,8 @@ export function WikiEditor({
onLinkDialogChange={setLinkDialogOpen}
pending={pending}
templates={templates}
templateCssMap={templateCssMap}
templateHtmlMap={templateHtmlMap}
/>
</div>

View file

@ -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)]">

View file

@ -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">
<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)}>

View file

@ -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
View 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,
};
}

View 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
View 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
View 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));
}

View 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
View 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
View 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;

View file

@ -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)) {

View 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
View 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
View 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 };
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -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";`)
}

File diff suppressed because it is too large Load diff

View 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";`)
}

View file

@ -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'
},
];

View file

@ -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;

View file

@ -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",

View file

@ -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",

View 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);
}
};

View file

@ -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})`);
}

View file

@ -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 {

View file

@ -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>;
}

View file

@ -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",

View 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);
});
});

View 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);
});
});

View 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);
});
});

View file

@ -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);
});