1
0
Fork 0

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:
Jason Fraley 2026-09-06 17:22:17 -04:00
parent 56b8b9d320
commit ded5d4b341
10 changed files with 23173 additions and 9 deletions

View file

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

View file

@ -141,5 +141,88 @@ export const GameRules: GlobalConfig = {
"Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.", "Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.",
}, },
}, },
{
name: "economy",
type: "group",
label: "Market Economy",
admin: {
description:
"Global knobs for the supply-and-demand market simulation run by the economy tick (docs/economy/supply-demand-design.md).",
},
fields: [
{
name: "enabled",
type: "checkbox",
defaultValue: false,
admin: {
description:
"Master gate for the market economy simulation. When off, the economy tick leaves all prices at their resting values.",
},
},
{
name: "defaultPriceBand",
type: "number",
min: 0,
max: 1,
defaultValue: 0.5,
admin: {
description:
"Default price band as a fraction of base price (e.g. 0.5 = price can move ±50%). Used when an item does not set its own band.",
},
},
{
name: "realDemandWindowTicks",
type: "number",
min: 1,
defaultValue: 24,
admin: {
description:
"How many economy ticks of completed-purchase history feed the real-demand signal.",
},
},
{
name: "realDemandDecay",
type: "number",
min: 0,
max: 1,
defaultValue: 0.5,
admin: {
description:
"Fraction of accumulated real demand that survives each tick (0.5 halves it every tick).",
},
},
{
name: "artificialDemandDrift",
type: "number",
min: 0,
max: 1,
defaultValue: 0.05,
admin: {
description:
"How far the NPC (artificial) demand random walk may move per tick, as a fraction of the GM baseline.",
},
},
{
name: "coldStartObservations",
type: "number",
min: 0,
defaultValue: 5,
admin: {
description:
"Completed purchases needed before an item's price starts moving. Until then items stay at their resting price (modifier 1).",
},
},
{
name: "tickIntervalHintMinutes",
type: "number",
min: 1,
defaultValue: 60,
admin: {
description:
"Suggested economy tick cadence in minutes, for cron documentation only — the tick itself is triggered externally.",
},
},
],
},
], ],
}; };

View file

@ -397,6 +397,56 @@ export const Assets: CollectionConfig = {
], ],
}, },
], ],
},
{
name: "economy",
type: "group",
label: "Supply & Demand",
admin: {
description:
"Baseline world supply and demand used by the market economy simulation (economy tick).",
},
fields: [
{
name: "enabled",
type: "checkbox",
defaultValue: true,
admin: {
description:
"Whether the economy simulation tracks this asset. Disabled items keep their resting price.",
},
},
{
name: "baselineSupply",
type: "number",
min: 0,
defaultValue: 100,
admin: {
description:
"GM-set resting world supply. Used as the starting point of this asset's market-state ledger.",
},
},
{
name: "baselineDemand",
type: "number",
min: 0,
defaultValue: 50,
admin: {
description:
"GM-set resting demand (NPC faction pressure). Used as the starting point of this asset's market-state ledger.",
},
},
{
name: "maxModifier",
type: "number",
min: 0,
max: 1,
admin: {
description:
"Price band, as a fraction of the base price (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
},
},
],
}, },
{ {
name: "crafting", name: "crafting",

View file

@ -254,6 +254,58 @@ export const Resources: CollectionConfig = {
], ],
}, },
{
name: "economy",
type: "group",
label: "Supply & Demand",
admin: {
description:
"Baseline world supply and demand used by the market economy simulation (economy tick).",
condition: (_, siblingData) => siblingData?.tradeable === true,
},
fields: [
{
name: "enabled",
type: "checkbox",
defaultValue: true,
admin: {
description:
"Whether the economy simulation tracks this resource. Disabled items keep their resting price.",
},
},
{
name: "baselineSupply",
type: "number",
min: 0,
defaultValue: 100,
admin: {
description:
"GM-set resting world supply. Used as the starting point of this resource's market-state ledger.",
},
},
{
name: "baselineDemand",
type: "number",
min: 0,
defaultValue: 50,
admin: {
description:
"GM-set resting demand (NPC faction pressure). Used as the starting point of this resource's market-state ledger.",
},
},
{
name: "maxModifier",
type: "number",
min: 0,
max: 1,
admin: {
description:
"Price band, as a fraction of the base value (e.g. 0.5 = price can move ±50%). Leave empty to use the global default band from Game Rules.",
},
},
],
},
{ {
label: "Measurement & Storage", label: "Measurement & Storage",
type: "group", type: "group",

View file

@ -0,0 +1,121 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
/**
* Abstract per-item supply/demand ledger, one doc per market-simulated item
* (resource or tradeable asset). Maintained by the economy tick (overrideAccess)
* and resettable by GMs from the admin panel. Never derived from physical
* stockpiles — see docs/economy/supply-demand-design.md.
*/
export const MarketState: CollectionConfig = {
slug: "market-state",
admin: {
group: "Market",
description:
"Supply/demand ledger per item, maintained by the economy tick. Baselines are copied from the item's Supply & Demand fields on first creation.",
},
access: {
read: ({ req }) => !!req.user,
create: requirePermission("market-state:create"),
update: requirePermission("market-state:update"),
delete: requirePermission("market-state:delete"),
},
indexes: [{ fields: ["itemType", "itemId"], unique: true }],
fields: [
{
name: "itemType",
type: "select",
required: true,
index: true,
options: [
{ label: "Resource", value: "resource" },
{ label: "Asset", value: "asset" },
],
admin: {
description: "Which collection this ledger entry tracks.",
},
},
{
name: "itemId",
type: "number",
required: true,
index: true,
admin: {
description: "ID of the tracked resource or asset document.",
},
},
{
name: "gmBaselineSupply",
type: "number",
required: true,
min: 0,
defaultValue: 100,
admin: {
description:
"GM-set resting supply. Copied from the item's Supply & Demand baseline when this entry is first created.",
},
},
{
name: "gmBaselineDemand",
type: "number",
required: true,
min: 0,
defaultValue: 50,
admin: {
description:
"GM-set resting demand. Copied from the item's Supply & Demand baseline when this entry is first created.",
},
},
{
name: "currentSupply",
type: "number",
required: true,
min: 0,
defaultValue: 100,
admin: {
description:
"Living abstract supply. Decremented by unit purchases from NPC listings, incremented by production/sales (later phases).",
},
},
{
name: "artificialDemand",
type: "number",
required: true,
min: 0,
defaultValue: 50,
admin: {
description:
"NPC faction demand pressure. Slow-drifting random walk bounded around the GM baseline, updated per economy tick.",
},
},
{
name: "realDemand",
type: "number",
required: true,
min: 0,
defaultValue: 0,
admin: {
description:
"Decaying rolling aggregate of completed unit purchases (quantity-weighted). Decayed each tick by the Game Rules decay factor.",
},
},
{
name: "priceModifier",
type: "number",
required: true,
min: 0,
defaultValue: 1,
admin: {
description:
"Computed price multiplier applied to the item's base price when generating NEW NPC listings only (1 = resting price). Existing listings and open negotiations are never repriced.",
},
},
{
name: "lastComputedAt",
type: "date",
admin: {
description: "When the economy tick last recomputed this entry.",
},
},
],
};

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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