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 type { Asset, LockerStorage, MarketListing, User } from "@/payload-types";
|
||||||
|
import { recordPurchase } from "@/lib/economy/consumption";
|
||||||
import {
|
import {
|
||||||
ensureLockerStorage,
|
ensureLockerStorage,
|
||||||
findLockerEmptySpot,
|
findLockerEmptySpot,
|
||||||
|
|
@ -321,6 +322,13 @@ export async function buyListing(
|
||||||
depth: 1,
|
depth: 1,
|
||||||
})) as unknown as MarketListing;
|
})) 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 {
|
return {
|
||||||
transactionId: transaction.id,
|
transactionId: transaction.id,
|
||||||
total,
|
total,
|
||||||
|
|
@ -363,18 +371,6 @@ export async function expireListing(
|
||||||
})) as unknown as MarketListing;
|
})) 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. */
|
/** Suggested auto-generated quantity: a few units for stackables, else 1. */
|
||||||
export function autoQuantity(asset: Asset): number {
|
export function autoQuantity(asset: Asset): number {
|
||||||
if (lockerIsStackable(asset)) {
|
if (lockerIsStackable(asset)) {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { getPayload } from "payload";
|
||||||
import type { Asset, MarketListing } from "@/payload-types";
|
import type { Asset, MarketListing } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
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 { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
|
||||||
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
|
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
|
||||||
|
import { getMarketPriceContext } from "@/lib/economy/consumption";
|
||||||
|
import { scaleQuantityForShortage } from "@/lib/economy/math";
|
||||||
import { resolveAssetId } from "@/lib/locker";
|
import { resolveAssetId } from "@/lib/locker";
|
||||||
import { getMainCurrencyId } from "@/lib/banking";
|
import { getMainCurrencyId } from "@/lib/banking";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
|
|
@ -94,17 +96,26 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
let generatedNpcCount = 0;
|
let generatedNpcCount = 0;
|
||||||
for (const asset of assets.docs as unknown as Asset[]) {
|
for (const asset of assets.docs as unknown as Asset[]) {
|
||||||
if (activeAssetIds.has(asset.id)) continue;
|
if (activeAssetIds.has(asset.id)) continue;
|
||||||
const basePrice = autoPrice(asset, "buy");
|
const basePrice = getBaseBuyPrice(asset);
|
||||||
if (basePrice == null) continue;
|
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,
|
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
|
||||||
// fall back to generating a placeholder NPC when none sell this good.
|
// fall back to generating a placeholder NPC when none sell this good.
|
||||||
const npc = await resolveVendorNpc(payload, asset.id);
|
const npc = await resolveVendorNpc(payload, asset.id);
|
||||||
if (npc.isGenerated === true) generatedNpcCount++;
|
if (npc.isGenerated === true) generatedNpcCount++;
|
||||||
const price = applyNpcPriceModifier(basePrice, npc);
|
const price = applyNpcPriceModifier(modifiedPrice, npc);
|
||||||
if (price <= 0) continue;
|
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 expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
|
||||||
const minPrice = Math.max(1, Math.round(price * 0.6));
|
const minPrice = Math.max(1, Math.round(price * 0.6));
|
||||||
try {
|
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 { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||||
import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types";
|
import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types";
|
||||||
import {
|
import {
|
||||||
autoPrice,
|
|
||||||
autoQuantity,
|
autoQuantity,
|
||||||
buyListing,
|
buyListing,
|
||||||
countCleanQuantity,
|
countCleanQuantity,
|
||||||
|
|
@ -445,16 +444,13 @@ describe("Marketplace", () => {
|
||||||
expect(countCleanQuantity(after, assetId)).toBe(before + 1);
|
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({
|
const asset = (await payload.findByID({
|
||||||
collection: "assets",
|
collection: "assets",
|
||||||
id: assetId,
|
id: assetId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})) as unknown as Asset;
|
})) as unknown as Asset;
|
||||||
const price = autoPrice(asset, "buy");
|
|
||||||
expect(price).not.toBeNull();
|
|
||||||
expect(price as number).toBeGreaterThan(0);
|
|
||||||
expect(autoQuantity(asset)).toBeGreaterThan(0);
|
expect(autoQuantity(asset)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue