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:
parent
447df220f5
commit
d4c8d81e2f
4 changed files with 288 additions and 21 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
264
tests/int/economy-consumption.int.spec.ts
Normal file
264
tests/int/economy-consumption.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue