1
0
Fork 0

feat(economy): ingest purchase demand and price vendor listings from the ledger

Market-tick prices new NPC listings from the economy ledger (base x priceModifier, vendor modifier applied) instead of the old autoPrice +/-15% randomness, and scales restock quantity down under shortage. buyListing records real demand from completed purchases of NPC/vendor stock.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 17:22:32 -04:00
parent 447df220f5
commit d4c8d81e2f
4 changed files with 288 additions and 21 deletions

View file

@ -1,4 +1,5 @@
import type { Asset, LockerStorage, MarketListing, User } from "@/payload-types";
import { recordPurchase } from "@/lib/economy/consumption";
import {
ensureLockerStorage,
findLockerEmptySpot,
@ -321,6 +322,13 @@ export async function buyListing(
depth: 1,
})) as unknown as MarketListing;
// Real-demand ingestion: completed purchases of NPC/vendor stock feed the
// economy ledger. The purchase already succeeded, so ingestion must not
// throw — recordPurchase logs and swallows internally.
if (sellerId == null) {
await recordPurchase(payload, { itemId: asset.id, quantity });
}
return {
transactionId: transaction.id,
total,
@ -363,18 +371,6 @@ export async function expireListing(
})) as unknown as MarketListing;
}
/**
* Price an auto-generated entry as a deviation around the asset's base buy
* price. `kind` mirrors the resting valuation: `buy` prices NPC stock near
* the base buy price, `sell` prices it near the base sell price.
*/
export function autoPrice(asset: Asset, kind: "buy" | "sell" = "buy"): number | null {
const base = kind === "buy" ? getBaseBuyPrice(asset) : getBaseSellPrice(asset);
if (base == null || base <= 0) return null;
const deviation = (Math.random() * 2 - 1) * 0.15;
return Math.max(1, Math.round(base * (1 + deviation)));
}
/** Suggested auto-generated quantity: a few units for stackables, else 1. */
export function autoQuantity(asset: Asset): number {
if (lockerIsStackable(asset)) {

View file

@ -3,9 +3,11 @@ import { getPayload } from "payload";
import type { Asset, MarketListing } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { AUTO_LISTING_DURATION_MS, autoPrice, autoQuantity, expireListing } from "@/lib/market";
import { AUTO_LISTING_DURATION_MS, autoQuantity, expireListing, getBaseBuyPrice } from "@/lib/market";
import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
import { getMarketPriceContext } from "@/lib/economy/consumption";
import { scaleQuantityForShortage } from "@/lib/economy/math";
import { resolveAssetId } from "@/lib/locker";
import { getMainCurrencyId } from "@/lib/banking";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
@ -94,17 +96,26 @@ export const script = async (config: SanitizedConfig) => {
let generatedNpcCount = 0;
for (const asset of assets.docs as unknown as Asset[]) {
if (activeAssetIds.has(asset.id)) continue;
const basePrice = autoPrice(asset, "buy");
const basePrice = getBaseBuyPrice(asset);
if (basePrice == null) continue;
// Supply/demand modifier from the economy ledger (neutral 1 when the
// item has no market-state doc yet), then the vendor's own modifier.
const priceCtx = await getMarketPriceContext(payload, "asset", asset.id);
const modifiedPrice = Math.max(1, Math.round(basePrice * (priceCtx?.priceModifier ?? 1)));
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
// fall back to generating a placeholder NPC when none sell this good.
const npc = await resolveVendorNpc(payload, asset.id);
if (npc.isGenerated === true) generatedNpcCount++;
const price = applyNpcPriceModifier(basePrice, npc);
const price = applyNpcPriceModifier(modifiedPrice, npc);
if (price <= 0) continue;
const quantity = autoQuantity(asset);
// Vendors restock less under shortage; bounded so they never stock zero.
const demand = priceCtx ? priceCtx.artificialDemand + priceCtx.realDemand : 0;
const quantity = priceCtx
? scaleQuantityForShortage(autoQuantity(asset), priceCtx.currentSupply, demand)
: autoQuantity(asset);
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
const minPrice = Math.max(1, Math.round(price * 0.6));
try {

View file

@ -0,0 +1,264 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Asset, BankAccount, MarketListing, User } from "@/payload-types";
import { buyListing } from "@/lib/market";
import { resolveVendorNpc } from "@/lib/market/npcs";
import { ensureLockerStorage } from "@/lib/locker";
import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking";
import { scaleQuantityForShortage } from "@/lib/economy/math";
import {
getMarketPriceContext,
recordPurchase,
resetMarketStateDocs,
} from "@/lib/economy/consumption";
import { ensureMarketStateDocs } from "@/lib/economy/tick";
/**
* Pure tests for the shortage-scaling helper. The price composition itself
* (round(base × modifier) then applyNpcPriceModifier) lives in the bin script,
* which is not unit-testable; the lib-level pieces below cover the testable
* seams: restock scaling, purchase ingestion, and GM reset.
*/
describe("Economy consumption: scaleQuantityForShortage", () => {
it("keeps the quantity unchanged when supply meets demand", () => {
expect(scaleQuantityForShortage(5, 100, 50)).toBe(5);
});
it("keeps the quantity unchanged when demand is zero", () => {
expect(scaleQuantityForShortage(4, 0, 0)).toBe(4);
});
it("scales the quantity down proportionally under shortage", () => {
// supply/demand = 50/100 = 0.5 → 5 × 0.5 = 2.5 → floor 2
expect(scaleQuantityForShortage(5, 50, 100)).toBe(2);
});
it("never scales below the 0.25 floor factor", () => {
// supply/demand = 10/100 = 0.1 → clamped to 0.25 → 8 × 0.25 = 2
expect(scaleQuantityForShortage(8, 10, 100)).toBe(2);
});
it("never returns zero, even under extreme shortage", () => {
expect(scaleQuantityForShortage(1, 1, 1000)).toBe(1);
});
});
describe("Economy consumption: DB integration", () => {
let payload: Payload;
const RUN = `econ-cons-${Date.now().toString(36)}`;
let assetId: number;
let buyer: User;
let buyerAccount: BankAccount;
const findStateDoc = async (id: number) => {
const docs = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: id } }] },
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = docs.docs[0];
if (!doc) throw new Error("market-state doc not found for fixture asset");
return doc;
};
const createFixtureAsset = async (): Promise<number> => {
const created = (await payload.create({
collection: "assets",
data: {
name: `${RUN} Asset`,
className: "test",
assetType: "miscellaneous",
approvalStatus: "in_progress",
tradeable: true,
isLive: true,
marketTrading: { Rarity: "common", perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 } },
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1 },
economy: { enabled: true, baselineSupply: 100, baselineDemand: 50, maxModifier: 0.5 },
},
overrideAccess: true,
depth: 0,
})) as unknown as Asset;
return created.id;
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
assetId = await createFixtureAsset();
buyer = (await payload.create({
collection: "users",
data: {
username: `${RUN}-buyer`,
discordUsername: `${RUN}-buyer`,
displayName: "BUYER",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
await ensureLockerStorage(payload, buyer.id);
buyerAccount = await createAccount(payload, {
name: `${RUN} buyer wallet`,
accountType: "personal",
ownerUserId: buyer.id,
});
await applyTransaction(payload, {
type: "deposit",
toAccountId: buyerAccount.id,
amount: 10_000,
memo: "economy consumption test funding",
});
});
afterAll(async () => {
const cleanup = async (fn: () => Promise<unknown>) => {
await fn().catch(() => {});
};
const listings = await payload
.find({
collection: "market-listings",
where: { asset: { equals: assetId } },
limit: 100,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as MarketListing[] }));
for (const listing of listings.docs) {
await cleanup(() =>
payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }),
);
}
await cleanup(async () => {
const doc = await findStateDoc(assetId);
await payload.delete({ collection: "market-state", id: doc.id, overrideAccess: true });
});
await cleanup(() => payload.delete({ collection: "assets", id: assetId, overrideAccess: true }));
await cleanup(() =>
payload.delete({ collection: "users", id: buyer.id, overrideAccess: true }),
);
await cleanup(() =>
payload.delete({ collection: "bank-accounts", id: buyerAccount.id, overrideAccess: true }),
);
});
it("seeds a market-state doc from the asset's GM baselines", async () => {
const result = await ensureMarketStateDocs(payload, {
items: [{ itemType: "asset", itemId: assetId }],
});
expect(result.created).toBe(1);
const doc = await findStateDoc(assetId);
expect(doc.gmBaselineSupply).toBe(100);
expect(doc.gmBaselineDemand).toBe(50);
expect(doc.currentSupply).toBe(100);
expect(doc.artificialDemand).toBe(50);
expect(doc.realDemand).toBe(0);
expect(doc.priceModifier).toBe(1);
});
it("recordPurchase adds quantity-weighted real demand", async () => {
await recordPurchase(payload, { itemId: assetId, quantity: 3 });
const doc = await findStateDoc(assetId);
expect(doc.realDemand).toBe(3);
await recordPurchase(payload, { itemId: assetId, quantity: 2 });
const after = await findStateDoc(assetId);
expect(after.realDemand).toBe(5);
});
it("getMarketPriceContext returns the ledger state for an item", async () => {
const ctx = await getMarketPriceContext(payload, "asset", assetId);
expect(ctx).not.toBeNull();
if (!ctx) throw new Error("expected price context");
expect(ctx.priceModifier).toBe(1);
expect(ctx.currentSupply).toBe(100);
const missing = await getMarketPriceContext(payload, "asset", assetId + 999_999);
expect(missing).toBeNull();
});
it("buyListing of an NPC listing ingests real demand", async () => {
const npc = await resolveVendorNpc(payload, assetId);
const currencyId = await getMainCurrencyId(payload);
const nowIso = new Date().toISOString();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
// The previous test left real demand on the doc; this test needs a
// clean ledger to assert the exact ingested quantity.
await payload.update({
collection: "market-state",
id: (await findStateDoc(assetId)).id,
data: { realDemand: 0 },
overrideAccess: true,
depth: 0,
});
const created = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: null,
npc: npc.id,
quantity: 5,
price: 100,
desiredPrice: 100,
minPrice: 60,
currency: currencyId ?? null,
status: "active",
isAutoGenerated: true,
listedAt: nowIso,
expiresAt,
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = await payload.findByID({
collection: "market-listings",
id: created.id,
depth: 1,
overrideAccess: true,
});
await buyListing(payload, populated, buyer);
const doc = await findStateDoc(assetId);
expect(doc.realDemand).toBe(5);
await payload.delete({ collection: "market-listings", id: created.id, overrideAccess: true });
});
it("resetMarketStateDocs rebuilds the ledger from GM baselines", async () => {
await payload.update({
collection: "market-state",
id: (await findStateDoc(assetId)).id,
data: { realDemand: 10, currentSupply: 3, priceModifier: 1.5 },
overrideAccess: true,
depth: 0,
});
const result = await resetMarketStateDocs(payload, {
items: [{ itemType: "asset", itemId: assetId }],
});
expect(result.reset).toBe(1);
const doc = await findStateDoc(assetId);
expect(doc.currentSupply).toBe(100);
expect(doc.artificialDemand).toBe(50);
expect(doc.realDemand).toBe(0);
expect(doc.priceModifier).toBe(1);
});
});

View file

@ -4,7 +4,6 @@ import config from "@/payload.config";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types";
import {
autoPrice,
autoQuantity,
buyListing,
countCleanQuantity,
@ -445,16 +444,13 @@ describe("Marketplace", () => {
expect(countCleanQuantity(after, assetId)).toBe(before + 1);
});
it("autoPrice/autoQuantity produce sane vendor entries", async () => {
it("autoQuantity produces sane vendor entries", async () => {
const asset = (await payload.findByID({
collection: "assets",
id: assetId,
depth: 0,
overrideAccess: true,
})) as unknown as Asset;
const price = autoPrice(asset, "buy");
expect(price).not.toBeNull();
expect(price as number).toBeGreaterThan(0);
expect(autoQuantity(asset)).toBeGreaterThan(0);
});