Files
Dune/tests/nexus-emperor-seller-purchase.test.ts
T

230 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer, handLimit, type Game } from '../game/engine';
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
import { richeseCards } from '../game/richese-cards';
import { quoteRicheseSettlement } from '../game/richese-settlement';
import {
EmperorNexusPurchaseError, createEmperorNexusSellerPurchase,
emperorNexusSellerPurchaseEvent, emperorNexusPurchaseEvent, quoteEmperorNexusSellerPurchase,
signEmperorNexusPurchase, validateEmperorNexusPurchase,
type EmperorNexusBankPurchase, type EmperorNexusSellerPurchaseInput, type EmperorNexusSellerPurchase,
} from '../game/nexus-emperor-purchase';
import {
auctionEmperorIncomeAmount, quoteAuctionContinuation, AuctionContinuationError,
type AuctionContinuationInput,
} from '../game/auction-continuation-quote';
function original(source: 'cache' | 'blackMarket' = 'cache', self = false) {
const g = createGame('SELLERQUOTE', newPlayer('r', 'Richese', 'richese'), source === 'blackMarket', ['choam']);
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('c', 'CHOAM', 'choam'));
g.order = g.players.map(p => p.id);
g.status = 'playing';
g.phase = 3;
g.turn = 2;
for (const p of g.players) { p.hand = []; p.spice = 10; }
g.deck = [];
g.discard = [];
g.auction = null;
const card = source === 'cache' ? richeseCards()[0] : baseDeck()[0];
g.richeseCache = source === 'cache' ? [card] : [];
if (source === 'blackMarket') g.players[0].hand.push(card);
const winner = self ? 'r' : 'h';
let lot = createRicheseAuction({
event: 'original-lot', cardId: card.id, source, owner: 'r', method: 'silent',
eligible: g.order, order: g.order, tieOrder: g.order,
});
for (const actor of g.order)
lot = submitRicheseBid(lot, { event: lot.event, actor, amount: actor === winner ? 6 : 0 }, 10);
g.richeseAuction = lot;
g.richeseBidding = {
owner: 'r', event: 'original-round', turn: g.turn, stage: 'lot',
position: source === 'cache' ? 'first' : null, normalCount: 3,
blackMarketSold: false, cacheCanceled: false,
};
g.richeseFunding = { [winner]: { amount: 6, allyPayment: 0, donor: null } };
const cards = createNexusCards(g.players, () => 0);
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'emperor')];
g.nexusCards = { cards: drawNexusCard(cards, winner, g.players, () => 0), phase: null };
const buyer = g.players.find(p => p.id === winner)!;
const quote = quoteRicheseSettlement({
lot, owner: { id: 'r', handCount: g.players[0].hand.length, handLimit: 4 },
winner: { id: winner, handCount: buyer.hand.length, handLimit: handLimit(buyer), spice: buyer.spice },
card, funding: g.richeseFunding[winner],
});
assert.equal(quote.kind, 'sold');
if (quote.kind !== 'sold') throw new Error('Expected native original sale.');
const input: EmperorNexusSellerPurchaseInput = { round: g.richeseBidding.event, lot, quote, bank: false };
return { g, input, winner };
}
function continuation(g: Game, input: EmperorNexusSellerPurchaseInput, free = true): AuctionContinuationInput {
return {
status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced,
players: g.players.map(p => ({ ...p, handLimit: handLimit(p) })), order: g.order,
physicalCards: [...g.players.flatMap(p => p.hand), ...(g.richeseCache ?? [])],
auction: null, richeseAuction: input.lot, richeseBidding: g.richeseBidding,
richeseCacheCount: g.richeseCache?.length,
sale: { winner: input.quote.winner, amount: input.quote.amount, free,
origin: input.lot.source, seller: input.lot.owner, ...(input.bank ? { recipient: 'bank' as const } : {}) },
};
}
void test('original cache and Black Market price proofs sign unchanged personally held spice without mutating the source', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { g, input, winner } = original(source), before = structuredClone(g);
const offer = quoteEmperorNexusSellerPurchase(g, input, winner);
assert.equal(offer.blocked, null);
assert.equal(offer.price, 6);
assert.equal(offer.source, source);
const receipt = createEmperorNexusSellerPurchase(g, input);
assert.equal(receipt.version, 2);
assert.equal(receipt.owner, winner);
assert.equal(receipt.seller, 'r');
assert.equal(receipt.beforeSpice, 10);
assert.equal(receipt.afterSpice, 10);
assert.equal(receipt.event, emperorNexusSellerPurchaseEvent(2, winner, 0, 'original-round', 'original-lot', source, 6));
assert.equal(Object.hasOwn(receipt, 'auctionIndex'), false);
assert.deepEqual(g, before);
// Historical evidence deliberately ignores future wallets and card transfers.
g.turn = 4;
g.players.find(p => p.id === winner)!.spice = 0;
g.richeseAuction = null;
g.richeseCache = [];
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
}
});
void test('native Richese self cache purchase can retain its personally affordable price', () => {
const { g, input } = original('cache', true);
assert.equal(createEmperorNexusSellerPurchase(g, input).owner, 'r');
const q = quoteAuctionContinuation(continuation(g, input), { kind: 'sale', free: true });
assert.deepEqual(q.steps, []);
assert.deepEqual(q.next, { kind: 'richeseEnd', source: 'cache', after: 'normalPool' });
});
void test('personal-price, exact source, full hand and physical singleton corruption fail closed', () => {
const changes: ((g: Game, input: EmperorNexusSellerPurchaseInput) => void)[] = [
g => { g.players[1].spice = 5; },
(_g, input) => { input.quote.amount = 5; },
(_g, input) => { input.quote.ownPayment = 5; },
(_g, input) => { input.quote.allyPayment = 1; },
(_g, input) => { input.round = 'other-round'; },
(_g, input) => { input.lot = { ...input.lot, event: 'other-lot' }; },
(_g, input) => { input.lot = { ...input.lot, bid: 5 }; },
g => { g.richeseFunding!.h.donor = 'c'; },
g => { g.richeseFunding!.h.amount = 5; },
g => { g.deck.push(g.richeseCache![0]); },
g => { g.richeseCache = []; },
g => { g.players[1].hand = baseDeck().slice(0, 8); },
g => { g.nexusCards!.cards!.deck.push('emperor'); },
(_g, input) => { input.quote.card = { ...input.quote.card, name: 'substituted face' }; },
(_g, input) => { Object.assign(input, { extra: true }); },
(_g, input) => { Object.assign(input.quote, { extra: true }); },
];
for (const change of changes) {
const { g, input } = original();
change(g, input);
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
}
const { g, input } = original('blackMarket');
g.advanced = false;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
});
void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
const { g, input, winner } = original();
const cards = g.nexusCards!.cards!;
cards.hands[winner] = 'guild';
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'guild')];
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /real Emperor/);
cards.deck = ['guild', ...cards.deck.filter(c => c !== 'emperor')];
cards.hands[winner] = 'emperor';
g.players[1].ally = 'c';
g.players[2].ally = winner;
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /unallied/);
input.quote.allyPayment = 2;
input.quote.ownPayment = 4;
g.richeseFunding!.h = { amount: 6, allyPayment: 2, donor: 'c' };
g.aid.c = { recipient: winner, amount: 2 };
g.players[1].spice = 4;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), /unallied/);
});
void test('free original seller continuation produces no seller credit or income while preserving native bonus and end boundaries', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { g, input } = original(source);
for (const bank of [false, true]) {
input.bank = bank;
const ctx = continuation(g, input), before = structuredClone(ctx);
const q = quoteAuctionContinuation(ctx, { kind: 'sale', free: true });
assert.equal(q.sale.amount, 6);
assert.equal(auctionEmperorIncomeAmount(q.sale), 0);
assert.deepEqual(q.steps, []);
assert.deepEqual(q.next, { kind: 'response', response: { kind: 'harkonnenBonus', owner: 'h', passed: [] } });
const end = quoteAuctionContinuation(ctx, { kind: 'next' });
assert.deepEqual(end.next, source === 'cache'
? { kind: 'richeseEnd', source: 'cache', after: 'normalPool' }
: { kind: 'richeseEnd', source: 'blackMarket', after: 'declaration', blackMarketSold: true });
assert.deepEqual(ctx, before);
const paid = quoteAuctionContinuation(continuation(g, input, false), { kind: 'sale', free: false });
assert.deepEqual(paid.steps, bank ? [] : [{ kind: 'sellerCredit', player: 'r', amount: 6, balance: 16 }]);
}
}
});
void test('a free seller lot cannot fabricate a donor invoice or payer-derived income', () => {
for (const patch of [
{ richeseContribution: 1 }, { richeseContribution: 0 },
{ occupiedContributions: [{ payer: 'h', amount: 6 }] },
{ bureaucratPaymentEvent: 'fake-paid-source' },
{ spiceBankerIncomePayment: {} }, { bureaucratPayment: {} },
]) {
const { g, input } = original();
const ctx = continuation(g, input);
Object.assign(ctx.sale!, patch);
assert.throws(() => quoteAuctionContinuation(ctx, { kind: 'sale', free: true }), AuctionContinuationError);
}
});
void test('strict seller receipt source, price and retained balance corruption rejects even after re-signing', () => {
const { g, input } = original();
const receipt = createEmperorNexusSellerPurchase(g, input);
const invalid = (patch: Partial<EmperorNexusSellerPurchase>, resign = false) => {
const changed = { ...receipt, ...patch };
if (resign) changed.signature = signEmperorNexusPurchase(changed);
assert.throws(() => validateEmperorNexusPurchase(g, changed), EmperorNexusPurchaseError);
};
for (const patch of [
{ origin: 'blackMarket' as const }, { seller: 'c' }, { round: 'other-round' },
{ lot: 'other-lot' }, { price: 7 }, { card: 'other-card' }, { owner: 'c' }, { sequence: 1 },
]) invalid(patch);
for (const patch of [
{ version: 1 as never }, { price: 0 }, { price: 11 }, { price: 1.5 },
{ beforeSpice: 5, afterSpice: 5 }, { afterSpice: 9 }, { afterSpice: 16 },
{ seller: 'c' }, { phase: 4 as never }, { turn: 0 }, { turn: 3 },
{ lot: ' ' }, { round: ' ' }, { auctionIndex: 0 } as never,
{ ownPayment: 6 } as never, { bankSubsidy: 6 } as never,
]) invalid(patch, true);
});
void test('normal bank receipts retain their exact original shape, event and typed signature', () => {
const { g } = original();
const receipt: EmperorNexusBankPurchase = {
kind: 'purchase', event: emperorNexusPurchaseEvent(1, 'h', 0, 2, 6),
owner: 'h', turn: 1, phase: 3, sequence: 0, faction: 'harkonnen', advanced: false,
price: 6, auctionIndex: 2, card: 'historical-bank-card', beforeSpice: 10, afterSpice: 10, signature: '',
};
const expected = JSON.stringify([
'emperorNexusPurchase', 'purchase', receipt.event, 'h', 1, 3, 0, 'harkonnen',
false, 6, 2, 'historical-bank-card', 10, 10,
]);
receipt.signature = signEmperorNexusPurchase(receipt);
assert.equal(receipt.signature, expected);
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
const hybrid = { ...receipt, version: 2, origin: 'cache', seller: 'r', round: 'round', lot: 'lot' } as never;
assert.throws(() => validateEmperorNexusPurchase(g, hybrid), EmperorNexusPurchaseError);
});