feat(economy): add market-state collection with supply/demand baselines
Introduce the market-state ledger (one doc per tradeable resource/asset), GM baseline fields on Assets/Resources, global economy knobs on Game Rules, market-state RBAC permissions, and the market-state event target option. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
56b8b9d320
commit
ded5d4b341
10 changed files with 23173 additions and 9 deletions
|
|
@ -24,6 +24,7 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Loadouts", value: "loadouts" },
|
{ label: "Loadouts", value: "loadouts" },
|
||||||
{ label: "Market Listings", value: "market-listings" },
|
{ label: "Market Listings", value: "market-listings" },
|
||||||
{ label: "Market Negotiations", value: "market-negotiations" },
|
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||||
|
{ label: "Market State", value: "market-state" },
|
||||||
{ label: "Evaluations", value: "evaluations" },
|
{ label: "Evaluations", value: "evaluations" },
|
||||||
{ label: "Tickets", value: "tickets" },
|
{ label: "Tickets", value: "tickets" },
|
||||||
{ label: "Wiki Pages", value: "wiki-pages" },
|
{ label: "Wiki Pages", value: "wiki-pages" },
|
||||||
|
|
|
||||||
|
|
@ -141,5 +141,88 @@ export const GameRules: GlobalConfig = {
|
||||||
"Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.",
|
"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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,56 @@ export const Assets: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "economy",
|
||||||
|
type: "group",
|
||||||
|
label: "Supply & Demand",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Baseline world supply and demand used by the market economy simulation (economy tick).",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "enabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Whether the economy simulation tracks this asset. Disabled items keep their resting price.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "baselineSupply",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 100,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting world supply. Used as the starting point of this asset's market-state ledger.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "baselineDemand",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 50,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting demand (NPC faction pressure). Used as the starting point of this asset's market-state ledger.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxModifier",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Price band, as a fraction of the base price (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "crafting",
|
name: "crafting",
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,58 @@ export const Resources: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "economy",
|
||||||
|
type: "group",
|
||||||
|
label: "Supply & Demand",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Baseline world supply and demand used by the market economy simulation (economy tick).",
|
||||||
|
condition: (_, siblingData) => siblingData?.tradeable === true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "enabled",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Whether the economy simulation tracks this resource. Disabled items keep their resting price.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "baselineSupply",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 100,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting world supply. Used as the starting point of this resource's market-state ledger.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "baselineDemand",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 50,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting demand (NPC faction pressure). Used as the starting point of this resource's market-state ledger.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxModifier",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Price band, as a fraction of the base value (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
label: "Measurement & Storage",
|
label: "Measurement & Storage",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
121
src/collections/market/MarketState.ts
Normal file
121
src/collections/market/MarketState.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { CollectionConfig } from "payload";
|
||||||
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract per-item supply/demand ledger, one doc per market-simulated item
|
||||||
|
* (resource or tradeable asset). Maintained by the economy tick (overrideAccess)
|
||||||
|
* and resettable by GMs from the admin panel. Never derived from physical
|
||||||
|
* stockpiles — see docs/economy/supply-demand-design.md.
|
||||||
|
*/
|
||||||
|
export const MarketState: CollectionConfig = {
|
||||||
|
slug: "market-state",
|
||||||
|
admin: {
|
||||||
|
group: "Market",
|
||||||
|
description:
|
||||||
|
"Supply/demand ledger per item, maintained by the economy tick. Baselines are copied from the item's Supply & Demand fields on first creation.",
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => !!req.user,
|
||||||
|
create: requirePermission("market-state:create"),
|
||||||
|
update: requirePermission("market-state:update"),
|
||||||
|
delete: requirePermission("market-state:delete"),
|
||||||
|
},
|
||||||
|
indexes: [{ fields: ["itemType", "itemId"], unique: true }],
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "itemType",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Resource", value: "resource" },
|
||||||
|
{ label: "Asset", value: "asset" },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
description: "Which collection this ledger entry tracks.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "itemId",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: "ID of the tracked resource or asset document.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gmBaselineSupply",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 100,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting supply. Copied from the item's Supply & Demand baseline when this entry is first created.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gmBaselineDemand",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 50,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"GM-set resting demand. Copied from the item's Supply & Demand baseline when this entry is first created.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "currentSupply",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 100,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Living abstract supply. Decremented by unit purchases from NPC listings, incremented by production/sales (later phases).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artificialDemand",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 50,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"NPC faction demand pressure. Slow-drifting random walk bounded around the GM baseline, updated per economy tick.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "realDemand",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Decaying rolling aggregate of completed unit purchases (quantity-weighted). Decayed each tick by the Game Rules decay factor.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "priceModifier",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
defaultValue: 1,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Computed price multiplier applied to the item's base price when generating NEW NPC listings only (1 = resting price). Existing listings and open negotiations are never repriced.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastComputedAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description: "When the economy tick last recomputed this entry.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
22757
src/migrations/20260906_100908_add_market_economy_state.json
Normal file
22757
src/migrations/20260906_100908_add_market_economy_state.json
Normal file
File diff suppressed because it is too large
Load diff
77
src/migrations/20260906_100908_add_market_economy_state.ts
Normal file
77
src/migrations/20260906_100908_add_market_economy_state.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -32,6 +32,7 @@ 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_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_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_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';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -189,19 +190,24 @@ export const migrations = [
|
||||||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
||||||
name: '20260906_050900_add_evaluation_reminders_field',
|
name: '20260906_050900_add_evaluation_reminders_field',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
|
||||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
|
||||||
name: '20260906_063753_add_personal_excerpt_richtext',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
up: migration_20260906_061451_add_wiki_template_css.up,
|
up: migration_20260906_061451_add_wiki_template_css.up,
|
||||||
down: migration_20260906_061451_add_wiki_template_css.down,
|
down: migration_20260906_061451_add_wiki_template_css.down,
|
||||||
name: '20260906_061451_add_wiki_template_css',
|
name: '20260906_061451_add_wiki_template_css',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||||
|
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||||
|
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_065405_add_wiki_template_html.up,
|
up: migration_20260906_065405_add_wiki_template_html.up,
|
||||||
down: migration_20260906_065405_add_wiki_template_html.down,
|
down: migration_20260906_065405_add_wiki_template_html.down,
|
||||||
name: '20260906_065405_add_wiki_template_html'
|
name: '20260906_065405_add_wiki_template_html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260906_100908_add_market_economy_state.up,
|
||||||
|
down: migration_20260906_100908_add_market_economy_state.down,
|
||||||
|
name: '20260906_100908_add_market_economy_state',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { LockerStorages } from "@/collections/locker/LockerStorages";
|
||||||
import { Loadouts } from "@/collections/locker/Loadouts";
|
import { Loadouts } from "@/collections/locker/Loadouts";
|
||||||
import { MarketListings } from "@/collections/market/MarketListings";
|
import { MarketListings } from "@/collections/market/MarketListings";
|
||||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||||
|
import { MarketState } from "@/collections/market/MarketState";
|
||||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||||
import { Evaluations } from "@/collections/users/Evaluations";
|
import { Evaluations } from "@/collections/users/Evaluations";
|
||||||
import { Tickets } from "@/collections/tickets/Tickets";
|
import { Tickets } from "@/collections/tickets/Tickets";
|
||||||
|
|
@ -196,6 +197,7 @@ const collections = [
|
||||||
// Market
|
// Market
|
||||||
MarketListings,
|
MarketListings,
|
||||||
MarketNegotiations,
|
MarketNegotiations,
|
||||||
|
MarketState,
|
||||||
|
|
||||||
// Helpdesk
|
// Helpdesk
|
||||||
Tickets,
|
Tickets,
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||||
{ value: "admin:market-negotiations:manage", label: "Manage Admin Page" },
|
{ 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 ------------------------------------------------------------
|
// ---- Helpdesk ------------------------------------------------------------
|
||||||
{
|
{
|
||||||
|
|
@ -739,6 +749,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
||||||
"market-negotiations:update",
|
"market-negotiations:update",
|
||||||
"market-negotiations:delete",
|
"market-negotiations:delete",
|
||||||
"admin:market-negotiations:manage",
|
"admin:market-negotiations:manage",
|
||||||
|
"market-state:create",
|
||||||
|
"market-state:read",
|
||||||
|
"market-state:update",
|
||||||
|
"market-state:delete",
|
||||||
|
"admin:market-state:manage",
|
||||||
"projects:create",
|
"projects:create",
|
||||||
"projects:read",
|
"projects:read",
|
||||||
"projects:update",
|
"projects:update",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue