1
0
Fork 0

Compare commits

..

No commits in common. "9f35dc7ddd7a6b07b74d9e9415b909c5f8e2708f" and "56b8b9d3209dd168783601e4d749ed4ad814618c" have entirely different histories.

61 changed files with 132 additions and 73743 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, MarketState (supply/demand ledger, one doc per tradeable resource/asset)
- **market/** — MarketListings, MarketNegotiations
- **locker/** — LockerStorages, Loadouts
- **world/** — Maps, NarrativeEvents
- **server/** — MissionFiles, ModLists, GameServers, ArmaCommands, ArmaSyncEvents
@ -158,10 +158,8 @@ 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 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.
- **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.
- **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.
@ -359,8 +357,6 @@ 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

@ -1,98 +0,0 @@
# 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 link internally with wikilinks like
and `inline code`. You can also link out — 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";` can appear in body text. Fenced
Inline commands like `hint "Hello world";` sit comfortably in prose. 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.1",
"version": "0.2.0",
"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,8 +17,6 @@ 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,
@ -1001,74 +999,3 @@ 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,7 +77,6 @@ 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 (
@ -100,7 +99,6 @@ export default async function MarketPage() {
currentUserId={userId}
currencyConfig={currencyConfig}
isManager={isManager}
canResetEconomy={canResetEconomy}
/>
</div>
);

View file

@ -1,62 +0,0 @@
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

@ -1,303 +0,0 @@
"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

@ -1,174 +0,0 @@
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,7 +24,6 @@ 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" },
@ -32,8 +31,6 @@ 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,88 +141,5 @@ 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

@ -398,59 +398,9 @@ 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",
type: "group",
{
name: "crafting",
type: "group",
fields: [
{
name: "craftable",

View file

@ -254,58 +254,6 @@ 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

@ -1,121 +0,0 @@
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,7 +132,6 @@ 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) => {
@ -146,7 +145,6 @@ 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
@ -188,8 +186,6 @@ export const Profiles: CollectionConfig = {
xpAcc += eodXp;
xpAcc += radioTrafficXp;
xpAcc += aimTrainerXp;
// Voiceover submissions: XP awarded per accepted submission
xpAcc += voiceoverXp;
return xpAcc;
},
@ -567,19 +563,6 @@ export const Profiles: CollectionConfig = {
},
],
},
{
name: "voiceover",
label: "Voiceover",
type: "group",
fields: [
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
],
},
],
},
],

View file

@ -1,57 +0,0 @@
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

@ -1,78 +0,0 @@
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,10 +138,20 @@ 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} />
<NavCore
label="Intelligence"
items={data.navIntelligence.map((i) =>
typeof i.items === "object" ? { ...i, items: [...i.items] } : { ...i },
)}
/>
)}
{hasLogistics && (
<NavCore label="Infrastructure" items={data.navLogistics} />
<NavCore
label="Infrastructure"
items={data.navLogistics.map((i) =>
typeof i.items === "object" ? { ...i, items: [...i.items] } : { ...i },
)}
/>
)}
<NavCore label="Helpdesk" items={data.navHelpdesk} />
<NavCore label="Commerce" items={data.navCommerce} />

View file

@ -9,7 +9,6 @@ import {
FileTextIcon,
Gamepad2,
LogOut,
Mic,
Radio,
Users2,
} from "lucide-react";
@ -171,16 +170,6 @@ 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,24 +1,16 @@
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 = {
@ -41,51 +33,56 @@ export const sidebarData = {
],
navIntelligence: [
{
title: "Campaigns",
url: "/intelligence/campaigns",
icon: Swords,
},
{
title: "Missions",
url: "/intelligence/missions",
title: "Intelligence",
url: "#",
icon: Crosshair,
},
{
title: "Factions",
url: "/intelligence/factions",
icon: Flag,
},
{
title: "Wiki",
url: "/wiki",
icon: BookOpen,
items: [
{
title: "Campaigns",
url: "/intelligence/campaigns",
},
{
title: "Missions",
url: "/intelligence/missions",
},
{
title: "Factions",
url: "/intelligence/factions",
},
{
title: "Wiki",
url: "/wiki",
},
],
},
],
navLogistics: [
{
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,
title: "Logistics",
url: "#",
icon: Warehouse,
items: [
{
title: "Structures",
url: "/logistics",
},
{
title: "Resources",
url: "/logistics/resources",
},
{
title: "Vehicles",
url: "/logistics/vehicles",
},
{
title: "Shipments",
url: "/logistics/shipments",
},
{
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
},
],
},
],
navCommerce: [
@ -102,14 +99,19 @@ export const sidebarData = {
],
navPersonnel: [
{
title: "Contacts",
url: "/personnel/contacts",
title: "Personnel",
url: "#",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
icon: BarChart3,
items: [
{
title: "Contacts",
url: "/personnel/contacts",
},
{
title: "Statistics",
url: "/personnel/statistics",
},
],
},
],
navPersonal: [

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset, LockerStorage } from "@/payload-types";
import { PlusIcon } from "lucide-react";
@ -26,7 +26,6 @@ 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 {
@ -62,7 +61,6 @@ 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 [];
@ -99,19 +97,6 @@ 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");
@ -292,9 +277,6 @@ 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, resetEconomyState } from "@/app/(frontend)/logistics/market/actions";
import { buyMarketListing, cancelMarketListing } 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,7 +30,6 @@ interface ListingCardProps {
currentUserId: number;
currencyConfig: CurrencyConfig;
canManage: boolean;
canResetEconomy?: boolean;
}
function npcLabel(listing: MarketListing): string {
@ -57,10 +56,9 @@ export function ListingCard({
currentUserId,
currencyConfig,
canManage,
canResetEconomy = false,
}: ListingCardProps) {
const router = useRouter();
const [busy, setBusy] = useState<"buy" | "cancel" | "reset" | null>(null);
const [busy, setBusy] = useState<"buy" | "cancel" | null>(null);
const [error, setError] = useState<string | null>(null);
const [qty, setQty] = useState(1);
@ -109,19 +107,6 @@ 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 })
@ -207,25 +192,12 @@ 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>
{canCancel && (
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
<XIcon className="size-4" />
{busy === "cancel" ? "..." : "Cancel"}
</Button>
)}
</ItemActions>
)}
</Item>

View file

@ -64,7 +64,6 @@ interface MarketViewProps {
currentUserId: number;
currencyConfig: CurrencyConfig;
isManager: boolean;
canResetEconomy: boolean;
}
export function MarketView({
@ -75,7 +74,6 @@ export function MarketView({
currentUserId,
currencyConfig,
isManager,
canResetEconomy,
}: MarketViewProps) {
const [search, setSearch] = useState("");
const [rarity, setRarity] = useState("all");
@ -185,7 +183,6 @@ export function MarketView({
currentUserId={currentUserId}
currencyConfig={currencyConfig}
canManage={isManager}
canResetEconomy={canResetEconomy}
/>
))}
</div>
@ -214,7 +211,6 @@ export function MarketView({
currentUserId={currentUserId}
currencyConfig={currencyConfig}
canManage={isManager}
canResetEconomy={canResetEconomy}
/>
))}
</div>

View file

@ -1,373 +0,0 @@
"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

@ -1,425 +0,0 @@
"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

@ -1,112 +0,0 @@
"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

@ -1,116 +0,0 @@
"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,8 +215,6 @@ export function WikiEditor({
onLinkDialogChange={setLinkDialogOpen}
pending={pending}
templates={templates}
templateCssMap={templateCssMap}
templateHtmlMap={templateHtmlMap}
/>
</div>

View file

@ -63,8 +63,6 @@ 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({
@ -76,8 +74,6 @@ export function WikiEditorBody({
onLinkDialogChange,
pending,
templates,
templateCssMap,
templateHtmlMap,
}: WikiEditorBodyProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
@ -433,8 +429,6 @@ 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,9 +14,6 @@ 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;
@ -24,8 +21,6 @@ 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 {
@ -53,14 +48,9 @@ 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] ?? "") : "";
@ -72,35 +62,11 @@ 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);
}
@ -177,48 +143,12 @@ 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>
)}
<span className="text-xs font-medium text-muted-foreground">
Preview
</span>
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
{filled}
</pre>
</div>
<div className={cn("flex justify-end gap-2")}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>

View file

@ -16,13 +16,7 @@ const EMPTY: VersionInfo = {
};
function resolveVersionLabel(info: VersionInfo): string {
// 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";
return info.gitDescribe ?? info.tag ?? info.version ?? "unknown version";
}
function formatCommitDate(iso: string | null): string | null {

View file

@ -1,115 +0,0 @@
/**
* 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

@ -1,120 +0,0 @@
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 };
}

View file

@ -1,43 +0,0 @@
/**
* 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,
},
});
}
}

View file

@ -1,164 +0,0 @@
/**
* 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

@ -1,62 +0,0 @@
/**
* 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 } }],
}),
),
};
}

View file

@ -1,226 +0,0 @@
/**
* 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 };
}

View file

@ -1,53 +0,0 @@
/**
* 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,5 +1,4 @@
import type { Asset, LockerStorage, MarketListing, User } from "@/payload-types";
import { recordPurchase } from "@/lib/economy/consumption";
import {
ensureLockerStorage,
findLockerEmptySpot,
@ -322,13 +321,6 @@ 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,
@ -371,6 +363,18 @@ 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

@ -1,105 +0,0 @@
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,
);
}

View file

@ -1,128 +0,0 @@
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);
}

View file

@ -1,120 +0,0 @@
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

View file

@ -1,18 +0,0 @@
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

@ -1,77 +0,0 @@
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,9 +32,6 @@ 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 = [
{
@ -192,34 +189,19 @@ export const migrations = [
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field',
},
{
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_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_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
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'
name: '20260906_065405_add_wiki_template_html'
},
];

View file

@ -104,9 +104,6 @@ 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;
@ -175,9 +172,6 @@ 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>;
@ -709,27 +703,6 @@ 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.)
*/
@ -937,27 +910,6 @@ 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;
/**
@ -1576,11 +1528,6 @@ 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'
@ -1872,7 +1819,6 @@ export interface GameEventLog {
| 'loadouts'
| 'market-listings'
| 'market-negotiations'
| 'market-state'
| 'evaluations'
| 'tickets'
| 'wiki-pages'
@ -1880,8 +1826,6 @@ export interface GameEventLog {
| 'wiki-templates'
| 'game-servers'
| 'assignment-transfers'
| 'voice-scripts'
| 'voice-submissions'
)
| null;
targetId?: number | null;
@ -2032,9 +1976,6 @@ export interface Profile {
xpEarned?: number | null;
};
};
voiceover?: {
xpEarned?: number | null;
};
};
updatedAt: string;
createdAt: string;
@ -3034,103 +2975,6 @@ 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".
@ -3821,18 +3665,6 @@ 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;
@ -4324,11 +4156,6 @@ export interface ProfilesSelect<T extends boolean = true> {
xpEarned?: T;
};
};
voiceover?:
| T
| {
xpEarned?: T;
};
};
updatedAt?: T;
createdAt?: T;
@ -4733,14 +4560,6 @@ export interface AssetsSelect<T extends boolean = true> {
baseSellPrice?: T;
};
};
economy?:
| T
| {
enabled?: T;
baselineSupply?: T;
baselineDemand?: T;
maxModifier?: T;
};
crafting?:
| T
| {
@ -4802,14 +4621,6 @@ 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;
@ -5179,56 +4990,6 @@ 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".
@ -5635,39 +5396,6 @@ 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;
}
@ -5749,17 +5477,6 @@ 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,9 +48,6 @@ 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";
@ -199,11 +196,6 @@ const collections = [
// Market
MarketListings,
MarketNegotiations,
MarketState,
// Voiceover
VoiceScripts,
VoiceSubmissions,
// Helpdesk
Tickets,
@ -330,10 +322,6 @@ 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,16 +347,6 @@ 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 ------------------------------------------------------------
{
@ -749,11 +739,6 @@ 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

@ -1,66 +0,0 @@
/**
* 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 && commitHash ? `${version}+${commitHash}` : null) ?? version ?? "unknown";
const label = gitDescribe ?? tag ?? version ?? "unknown";
console.log(`[version-info] wrote src/generated/versionInfo.json (${label})`);
}

View file

@ -3,11 +3,9 @@ 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, autoQuantity, expireListing, getBaseBuyPrice } from "@/lib/market";
import { AUTO_LISTING_DURATION_MS, autoPrice, autoQuantity, expireListing } 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";
@ -96,26 +94,17 @@ 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 = getBaseBuyPrice(asset);
const basePrice = autoPrice(asset, "buy");
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(modifiedPrice, npc);
const price = applyNpcPriceModifier(basePrice, npc);
if (price <= 0) continue;
// 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 quantity = 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,10 +35,7 @@ interface GameEventInput {
| "wiki-revisions"
| "wiki-templates"
| "game-servers"
| "assignment-transfers"
| "market-state"
| "voice-scripts"
| "voice-submissions";
| "assignment-transfers";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -77,11 +77,6 @@ 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",
@ -95,11 +90,6 @@ 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

@ -1,264 +0,0 @@
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

@ -1,292 +0,0 @@
/**
* 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

@ -1,267 +0,0 @@
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,6 +4,7 @@ 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,
@ -444,13 +445,16 @@ describe("Marketplace", () => {
expect(countCleanQuantity(after, assetId)).toBe(before + 1);
});
it("autoQuantity produces sane vendor entries", async () => {
it("autoPrice/autoQuantity produce 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);
});