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>
264 lines
8.7 KiB
TypeScript
264 lines
8.7 KiB
TypeScript
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);
|
||
});
|
||
});
|