tickets: makeUser now assigns roleDocs because getStaffUserIds queries the RBAC relationship and the legacy roles select field is not synced at runtime. Both specs: teardown deletes profiles (and user-notifications in market) before users, since those relationships are NOT NULL.
957 lines
32 KiB
TypeScript
957 lines
32 KiB
TypeScript
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<User> => {
|
|
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/,
|
|
);
|
|
});
|
|
});
|