import { getPayload, Payload } from "payload"; 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, creditLockerQuantity, deductLockerQuantity, expireListing, isEntryClean, isNegotiable, listingTotal, negotiationRange, } from "@/lib/market"; import { npcDialogue, npcResponseToOffer, NPC_PATIENCE } from "@/lib/market/negotiations"; import { applyNpcPriceModifier, createGeneratedNpc, randomNpcName, resolveVendorNpc, } from "@/lib/market/npcs"; import { ensureLockerStorage, resolveAssetId } from "@/lib/locker"; import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking"; import { notifyUser } from "@/lib/notifications"; let payload: Payload; const RUN = `mt-${Date.now().toString(36)}`; describe("Marketplace", () => { let assetId: number; let seller: User; let buyer: User; let sellerLocker: LockerStorage; let buyerLocker: LockerStorage; let sellerAccount: BankAccount; let buyerAccount: BankAccount; let currencyId: number | null; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); // Always create a fresh, live, tradeable asset for the test. The // "approved" approvalStatus can't be set from an unauthenticated payload // context (filterOptions restrict non-developers), so use the default. 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 }, }, overrideAccess: true, depth: 0, })) as unknown as Asset; assetId = created.id; const makeUser = async (label: string): Promise => { const tag = `${RUN}-${label}`; return (await payload.create({ collection: "users", data: { username: tag, discordUsername: tag, displayName: label.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; }; seller = await makeUser("seller"); buyer = await makeUser("buyer"); sellerLocker = await ensureLockerStorage(payload, seller.id); buyerLocker = await ensureLockerStorage(payload, buyer.id); // Seed the seller's locker with 5 clean units of the asset. sellerLocker = (await payload.update({ collection: "locker-storages", id: sellerLocker.id, data: { items: [ { asset: assetId, quantity: 5, gridX: 0, gridY: 0, rotated: false }, ], }, overrideAccess: true, depth: 1, })) as unknown as LockerStorage; currencyId = await getMainCurrencyId(payload); if (!currencyId) throw new Error("Test requires a main currency in Game Rules."); sellerAccount = await createAccount(payload, { name: `${RUN} Seller Account`, accountType: "personal", ownerUserId: seller.id, }); buyerAccount = await createAccount(payload, { name: `${RUN} Buyer Account`, accountType: "personal", ownerUserId: buyer.id, }); // Fund the buyer. await applyTransaction(payload, { type: "deposit", toAccountId: buyerAccount.id, amount: 10000, memo: `${RUN} test funding`, }); }); afterAll(async () => { if (!payload) return; const listings = await payload.find({ collection: "market-listings", where: { listedAt: { greater_than: new Date(Date.now() - 3_600_000).toISOString() } }, limit: 200, depth: 0, overrideAccess: true, }); const listingIds = listings.docs.map((l) => l.id); // Delete referencing negotiations first — Payload nulls out the listing FK // on listing delete, which violates the NOT NULL constraint. if (listingIds.length > 0) { const negotiations = await payload.find({ collection: "market-negotiations", where: { listing: { in: listingIds } }, limit: 200, depth: 0, overrideAccess: true, }); for (const negotiation of negotiations.docs) { await payload.delete({ collection: "market-negotiations", id: negotiation.id, overrideAccess: true }); } } for (const listing of listings.docs) { await payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }); } for (const user of [seller, buyer]) { if (!user) continue; const accounts = await payload.find({ collection: "bank-accounts", where: { ownerUser: { equals: user.id } }, limit: 20, depth: 0, overrideAccess: true, }); for (const account of accounts.docs) { const txns = await payload.find({ collection: "bank-transactions", where: { or: [{ fromAccount: { equals: account.id } }, { toAccount: { equals: account.id } }] }, limit: 50, depth: 0, overrideAccess: true, }); for (const txn of txns.docs) { const entries = await payload.find({ collection: "ledger-entries", where: { transaction: { equals: txn.id } }, limit: 50, depth: 0, overrideAccess: true, }); for (const entry of entries.docs) { await payload.delete({ collection: "ledger-entries", id: entry.id, overrideAccess: true }); } await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }); } await payload.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }); } const lockers = await payload.find({ collection: "locker-storages", where: { ownerUser: { equals: user.id } }, limit: 5, depth: 0, overrideAccess: true, }); for (const locker of lockers.docs) { await payload.delete({ collection: "locker-storages", id: locker.id, overrideAccess: true }); } // profiles.user and user-notifications.user are NOT NULL — delete them before the user. const profiles = await payload.find({ collection: "profiles", where: { user: { equals: user.id } }, limit: 5, depth: 0, overrideAccess: true, }); for (const profile of profiles.docs) { await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }); } const notifications = await payload.find({ collection: "user-notifications", where: { user: { equals: user.id } }, limit: 100, depth: 0, overrideAccess: true, }); for (const notification of notifications.docs) { await payload.delete({ collection: "user-notifications", id: notification.id, overrideAccess: true }); } await payload.delete({ collection: "users", id: user.id, overrideAccess: true }); } // Delete the asset created for this test. if (assetId) { await payload.delete({ collection: "assets", id: assetId, overrideAccess: true }); } // Delete any NPCs created for this test (generated + GM vendor). const npcs = await payload.find({ collection: "game-npcs", where: { "vendor.sells": { equals: assetId } }, limit: 50, depth: 0, overrideAccess: true, }); for (const npc of npcs.docs) { await payload.delete({ collection: "game-npcs", id: npc.id, overrideAccess: true }); } }); it("isEntryClean treats equipment-bearing entries as unsellable", () => { expect( isEntryClean({ asset: assetId, quantity: 1, gridX: 0, gridY: 0 }), ).toBe(true); expect( isEntryClean({ asset: assetId, quantity: 1, gridX: 0, gridY: 0, equipment: { attachments: [{ asset: assetId }], skin: null }, }), ).toBe(false); expect( isEntryClean({ asset: assetId, quantity: 1, gridX: 0, gridY: 0, equipment: { skin: assetId }, }), ).toBe(false); }); it("countCleanQuantity and deductLockerQuantity only consume clean stock", async () => { const locker = (await payload.findByID({ collection: "locker-storages", id: sellerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; expect(countCleanQuantity(locker, assetId)).toBe(5); const items = deductLockerQuantity(locker, assetId, 5); expect(items.reduce((sum, e) => sum + (e.quantity ?? 0), 0)).toBe(0); expect(() => deductLockerQuantity(locker, assetId, 6)).toThrow(); }); it("buys a listing: funds move, buyer's locker is credited, listing is sold", async () => { // List 2 units. const sellerLockerFull = (await payload.findByID({ collection: "locker-storages", id: sellerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 2); await payload.update({ collection: "locker-storages", id: sellerLocker.id, data: { items: afterDeduct }, overrideAccess: true, depth: 0, }); const listing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: seller.id, quantity: 2, price: 100, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; expect(listingTotal(listing)).toBe(200); const populated = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; const buyerBefore = ( (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount ).balance ?? 0; const sellerBefore = ( (await payload.findByID({ collection: "bank-accounts", id: sellerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount ).balance ?? 0; const result = await buyListing(payload, populated, buyer); expect(result.listing.status).toBe("sold"); expect(result.total).toBe(200); const buyerAccountAfter = (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount; const sellerAccountAfter = (await payload.findByID({ collection: "bank-accounts", id: sellerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount; expect(buyerAccountAfter.balance).toBe(buyerBefore - 200); expect(sellerAccountAfter.balance).toBe(sellerBefore + 200); const buyerLockerAfter = (await payload.findByID({ collection: "locker-storages", id: buyerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; expect(countCleanQuantity(buyerLockerAfter, assetId)).toBe(2); }); it("cannot buy a sold/expired listing or your own listing", async () => { const sold = (await payload.find({ collection: "market-listings", where: { status: { equals: "sold" } }, limit: 1, depth: 1, overrideAccess: true, })) as unknown as { docs: MarketListing[] }; if (sold.docs.length > 0) { await expect(buyListing(payload, sold.docs[0], buyer)).rejects.toThrow(/no longer active/); } // Own listing: re-create one owned by buyer, expect rejection. const ownListing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: buyer.id, quantity: 1, price: 10, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; const ownPopulated = (await payload.findByID({ collection: "market-listings", id: ownListing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; await expect(buyListing(payload, ownPopulated, buyer)).rejects.toThrow(/own listing/); }); it("returns stock to the seller when a listing expires or is cancelled", async () => { const sellerLockerFull = (await payload.findByID({ collection: "locker-storages", id: sellerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; const before = countCleanQuantity(sellerLockerFull, assetId); const listing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: seller.id, quantity: 1, price: 50, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; const populated = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; const expired = await expireListing(payload, populated); expect(expired.status).toBe("expired"); const after = (await payload.findByID({ collection: "locker-storages", id: sellerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; expect(countCleanQuantity(after, assetId)).toBe(before + 1); }); it("autoPrice/autoQuantity produce 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); }); it("resolveAssetId works on id and populated refs", () => { expect(resolveAssetId(assetId)).toBe(assetId); expect(resolveAssetId({ id: assetId } as Asset)).toBe(assetId); }); it("negotiationRange / isNegotiable resolve min, desired, and negotiability", () => { expect(negotiationRange({ price: 100, desiredPrice: 100, minPrice: 60 })).toEqual({ min: 60, desired: 100 }); expect(negotiationRange({ price: 100 })).toEqual({ min: 100, desired: 100 }); expect(negotiationRange({ price: 100, desiredPrice: 90, minPrice: 80 })).toEqual({ min: 80, desired: 90 }); expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 60 })).toBe(true); expect(isNegotiable({ price: 100 })).toBe(false); expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 100 })).toBe(false); }); it("npcResponseToOffer accepts at/above desired, counters toward desired", () => { const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; expect(npcResponseToOffer(listing, 100)).toEqual({ action: "accept", amount: 100, reason: "good" }); expect(npcResponseToOffer(listing, 120)).toEqual({ action: "accept", amount: 100, reason: "overpay" }); expect(npcResponseToOffer(listing, 60).action).toBe("counter"); const counter = npcResponseToOffer(listing, 80); expect(counter.action).toBe("counter"); if (counter.action === "counter") { expect(counter.amount).toBeGreaterThan(80); expect(counter.amount).toBeLessThan(100); } const near = npcResponseToOffer(listing, 95); expect(["accept", "counter"]).toContain(near.action); if (near.action === "counter") { expect(near.amount).toBeGreaterThan(95); } }); it("lowball offers burn the vendor's patience fast and eventually close", () => { const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 }; const first = npcResponseToOffer(listing, 1); expect(first.action).toBe("counter"); if (first.action === "counter") { expect(first.closed).toBe(false); expect(first.patience).toBe(NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty); } const second = npcResponseToOffer(listing, 1, { patience: first.action === "counter" ? first.patience : 0 }); expect(second.action).toBe("counter"); if (second.action === "counter") expect(second.closed).toBe(false); const third = npcResponseToOffer(listing, 1, { patience: second.action === "counter" ? second.patience : 0 }); expect(third.action).toBe("counter"); if (third.action === "counter") expect(third.closed).toBe(true); }); it("reasonable offers cost a little patience; near-asking offers relieve it", () => { const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; const mid = npcResponseToOffer(listing, 80); if (mid.action === "counter") expect(mid.patience).toBe(NPC_PATIENCE.roundCost); const good = npcResponseToOffer(listing, 90); if (good.action === "counter") expect(good.patience).toBe(0); const bad = npcResponseToOffer(listing, 50, { patience: 50 }); if (bad.action === "counter") expect(bad.patience).toBe(50 + NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty); }); it("bargaining in the zone eventually exhausts the vendor (finite rounds)", () => { const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; let patience = 0; let closed = false; let rounds = 0; for (let i = 0; i < 20; i++) { const response = npcResponseToOffer(listing, 80, { patience }); if (response.action === "counter") { patience = response.patience; closed = response.closed; rounds++; if (closed) break; } } expect(closed).toBe(true); expect(rounds).toBe(Math.ceil(NPC_PATIENCE.cap / NPC_PATIENCE.roundCost)); }); it("vendor counters are monotonic: never rise past the last counter", () => { const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; const r1 = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); expect(r1.action).toBe("counter"); const c1 = r1.action === "counter" ? r1.amount : 0; expect(c1).toBeGreaterThan(3500); expect(c1).toBeLessThan(4300); const r2 = npcResponseToOffer(listing, 3800, { patience: r1.action === "counter" ? r1.patience : 0, lastCounter: c1, lastBuyerOffer: 3500, }); expect(r2.action).toBe("counter"); if (r2.action === "counter") { expect(r2.amount).toBeLessThanOrEqual(c1); expect(r2.amount).toBeGreaterThan(3800); } }); it("counter responses carry a reason and npcDialogue produces vendor lines", () => { const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; const concede = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); expect(concede.action).toBe("counter"); if (concede.action === "counter") { expect(concede.reason).toBe("concede"); expect(npcDialogue(concede.reason, concede.amount)).toContain(concede.amount.toLocaleString()); } const backward = npcResponseToOffer(listing, 3200, { patience: 12, lastCounter: 4060, lastBuyerOffer: 3800 }); expect(backward.action).toBe("counter"); if (backward.action === "counter") { expect(backward.reason).toBe("backward"); expect(backward.amount).toBe(4060); } const lowball = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null }); expect(lowball.action).toBe("counter"); if (lowball.action === "counter") expect(lowball.reason).toBe("lowball"); const firm = npcResponseToOffer( listing, 4300 - Math.round(4300 * 0.02), { patience: 0, lastCounter: null, lastBuyerOffer: null }, () => 0.99, ); expect(firm.action).toBe("counter"); if (firm.action === "counter") expect(firm.reason).toBe("firm"); expect(npcDialogue("accept", undefined, undefined, () => 0)).toBe( "I think that price is acceptable. Let's shake on it.", ); expect(npcDialogue("closed", undefined, 4300)).toContain("4,300"); }); it("vendor counters never drop below the min acceptable price", () => { const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 }; const r1 = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null }); expect(r1.action).toBe("counter"); if (r1.action === "counter") { expect(r1.amount).toBe(4100); expect(r1.reason).toBe("lowball"); } // Even after more bargaining, the vendor holds the floor. const r2 = npcResponseToOffer(listing, 3000, { patience: r1.action === "counter" ? r1.patience : 0, lastCounter: r1.action === "counter" ? r1.amount : null, lastBuyerOffer: 1000, }); expect(r2.action).toBe("counter"); if (r2.action === "counter") expect(r2.amount).toBe(4100); }); it("tiny offer increases are stalls: flagged as such and burn the vendor's patience", () => { const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 }; // First offer of 3500 (above the floor) draws a normal concession. const first = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); expect(first.action).toBe("counter"); if (first.action === "counter") { expect(first.reason).toBe("concede"); expect(first.patience).toBe(NPC_PATIENCE.roundCost); } // Bumping by 1 (< 1% of the asking price) is a stall: big patience hit. const second = npcResponseToOffer(listing, 3501, { patience: first.action === "counter" ? first.patience : 0, lastCounter: first.action === "counter" ? first.amount : null, lastBuyerOffer: 3500, }); expect(second.action).toBe("counter"); if (second.action === "counter") { expect(second.reason).toBe("stall"); expect(second.patience).toBe( NPC_PATIENCE.roundCost + NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty, ); } expect(npcDialogue("stall", undefined, undefined, () => 0)).toBe( "Really? That's hardly different. Negotiate like you mean it.", ); }); it("meaningful offer increases are not stalls", () => { const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 }; const r = npcResponseToOffer(listing, 4200, { patience: 12, lastCounter: 4550, lastBuyerOffer: 3500, }); expect(r.action).toBe("counter"); if (r.action === "counter") { expect(r.reason).toBe("concede"); expect(r.patience).toBe(12 + NPC_PATIENCE.roundCost); } }); it("the vendor holds firm when the buyer moves backward", () => { const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; const c1 = 4060; // the vendor's counter after the buyer offered 3500 const r = npcResponseToOffer(listing, 3000, { patience: 12, lastCounter: c1, lastBuyerOffer: 3500 }); expect(r.action).toBe("counter"); if (r.action === "counter") expect(r.amount).toBe(c1); }); it("accepts an offer at or above the vendor's stance regardless of the roll", () => { const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 }; expect(npcResponseToOffer(listing, 3900, state, () => 0.99).action).toBe("accept"); expect(npcResponseToOffer(listing, 4000, state, () => 0.99).action).toBe("accept"); }); it("accepts with high chance when the offer is within margin of the stance, else holds", () => { const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 }; // 2% of 3900 = 78 → threshold 3822; 3850 is inside the margin. const yes = npcResponseToOffer(listing, 3850, state, () => 0); expect(yes.action).toBe("accept"); if (yes.action === "accept") expect(yes.amount).toBe(3850); const no = npcResponseToOffer(listing, 3850, state, () => 0.99); expect(no.action).toBe("counter"); if (no.action === "counter") expect(no.amount).toBe(3900); }); it("buys at a negotiated unit price when one is supplied", async () => { const sellerLockerFull = (await payload.findByID({ collection: "locker-storages", id: sellerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 1); await payload.update({ collection: "locker-storages", id: sellerLocker.id, data: { items: afterDeduct }, overrideAccess: true, depth: 0, }); const listing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: seller.id, quantity: 2, price: 100, desiredPrice: 100, minPrice: 60, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; const populated = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; const buyerBefore = ( (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount ).balance ?? 0; const sellerBefore = ( (await payload.findByID({ collection: "bank-accounts", id: sellerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount ).balance ?? 0; const result = await buyListing(payload, populated, buyer, { unitPrice: 80 }); expect(result.listing.status).toBe("sold"); expect(result.total).toBe(160); expect(result.unitPrice).toBe(80); const buyerAfter = (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount; const sellerAfter = (await payload.findByID({ collection: "bank-accounts", id: sellerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount; expect(buyerAfter.balance).toBe(buyerBefore - 160); expect(sellerAfter.balance).toBe(sellerBefore + 160); const buyerLockerAfter = (await payload.findByID({ collection: "locker-storages", id: buyerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(2); }); it("creates in-app notifications for a user", async () => { await notifyUser(payload, { userId: buyer.id, type: "market:offer", title: "Test notification", message: "A seller replied to your offer.", link: "/logistics/market", }); const found = await payload.find({ collection: "user-notifications", where: { and: [{ user: { equals: buyer.id } }, { type: { equals: "market:offer" } }] }, limit: 5, depth: 0, overrideAccess: true, }); const doc = found.docs.find((n) => n.title === "Test notification"); expect(doc).toBeDefined(); expect(doc?.read).toBe(false); expect(doc?.link).toBe("/logistics/market"); if (doc?.id) { await payload.delete({ collection: "user-notifications", id: doc.id, overrideAccess: true }); } }); it("assigns auto stock to NPCs and prefers GM-assigned vendors", async () => { const generated = await createGeneratedNpc(payload, { assetId }); expect(generated.isGenerated).toBe(true); expect(generated.isInGame).toBe(false); expect(generated.vendor?.enabled).toBe(true); expect((generated.vendor?.sells ?? []).map(resolveAssetId)).toContain(assetId); expect(generated.name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); expect(randomNpcName()).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); const vendor = (await payload.create({ collection: "game-npcs", data: { name: "GM Vendor", occupation: "Weapons Dealer", isGenerated: false, isInGame: true, vendor: { enabled: true, sells: [assetId] }, }, overrideAccess: true, depth: 0, })) as unknown as GameNpc; const picked = await resolveVendorNpc(payload, assetId); expect(picked.id).toBe(vendor.id); expect(applyNpcPriceModifier(100, vendor)).toBe(100); expect( applyNpcPriceModifier(100, { ...vendor, vendor: { enabled: true, priceModifier: 0.5, sells: [assetId] }, } as GameNpc), ).toBe(50); }); it("buys a partial quantity and keeps the listing active", async () => { const listing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: seller.id, quantity: 3, price: 100, desiredPrice: 100, minPrice: 60, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; const populated = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; const buyerBefore = ( (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount ).balance ?? 0; const result = await buyListing(payload, populated, buyer, { quantity: 1 }); expect(result.quantity).toBe(1); expect(result.total).toBe(100); expect(result.unitPrice).toBe(100); expect(result.listing.status).toBe("active"); expect(result.listing.quantity).toBe(2); const buyerAfter = (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as unknown as BankAccount; expect(buyerAfter.balance).toBe(buyerBefore - 100); const buyerLockerAfter = (await payload.findByID({ collection: "locker-storages", id: buyerLocker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(1); // Buying the remaining stock marks the listing sold. const reFetched = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; expect(reFetched.quantity).toBe(2); const sold = await buyListing(payload, reFetched, buyer); expect(sold.quantity).toBe(2); expect(sold.total).toBe(200); expect(sold.listing.status).toBe("sold"); }); it("rejects invalid buy quantities", async () => { const listing = (await payload.create({ collection: "market-listings", data: { asset: assetId, seller: seller.id, quantity: 3, price: 100, desiredPrice: 100, minPrice: 60, currency: currencyId, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; const populated = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; await expect(buyListing(payload, populated, buyer, { quantity: 0 })).rejects.toThrow( /Quantity must be between/, ); await expect(buyListing(payload, populated, buyer, { quantity: -1 })).rejects.toThrow( /Quantity must be between/, ); await expect(buyListing(payload, populated, buyer, { quantity: 4 })).rejects.toThrow( /Quantity must be between/, ); await expect(buyListing(payload, populated, buyer, { quantity: 1.5 })).rejects.toThrow( /Quantity must be between/, ); }); });