Files
Dune/tests/nexus-emperor-payment.test.ts

307 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import type { Game } from '../game/engine';
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
import {
createEmperorPaymentReceipt,
emperorPaymentReceiptSignature,
emperorPaymentSourceQuote,
emperorPaymentUseBlock,
requiredEmperorPaymentResponders,
validateEmperorPaymentReceipt,
type EmperorPaymentReceipt,
} from '../game/nexus-emperor-payment';
/** Pure helper inputs, not a staged runtime game or an engine-action fixture. */
function obligation(price = 8, spice = 8, prepaid = 0, committed = 0): Game {
const players = [
{ id: 'emperor', faction: 'emperor' as const, ally: 'buyer', spice, hand: [] },
{ id: 'buyer', faction: 'atreides' as const, ally: 'emperor', spice: price, hand: [] },
{ id: 'first', faction: 'harkonnen' as const, ally: null, spice: 0, hand: [] },
{ id: 'owner', faction: 'guild' as const, ally: null, spice: 0, hand: [] },
{ id: 'allied', faction: 'fremen' as const, ally: 'partner', spice: 0, hand: [] },
{ id: 'partner', faction: 'beneGesserit' as const, ally: 'allied', spice: 0, hand: [] },
];
const cards = createNexusCards(players, () => 0.5);
for (const [owner, face] of [['first', 'harkonnen'], ['owner', 'emperor'], ['allied', 'atreides']] as const) {
cards.deck.splice(cards.deck.indexOf(face), 1);
cards.hands[owner] = face;
}
const deck = baseDeck();
const card = deck.shift()!;
return {
status: 'playing', phase: 3, turn: 2, players,
order: ['first', 'emperor', 'allied', 'owner', 'buyer', 'partner'],
active: 'buyer', deck, discard: [],
auction: { cards: [card], index: 0, bid: price, bidder: 'buyer',
active: 'buyer', opener: 0, passed: ['first', 'owner', 'allied', 'partner', 'emperor'],
allyPayment: committed },
aid: prepaid ? { emperor: { recipient: 'buyer', amount: prepaid } } : {},
currentAuctionSale: null, richeseAuction: null, decision: null, response: null,
nexusCards: { cards, phase: null },
} as unknown as Game;
}
function open(g: Game): EmperorPaymentReceipt {
const receipt = createEmperorPaymentReceipt(g, 'original-obligation', requiredEmperorPaymentResponders(g));
g.nexusEmperorPayment = { sequence: 1, current: receipt.event, completed: [] };
g.pendingNexusEmperorPayment = { receipt };
g.active = receipt.required[receipt.passed.length];
g.decision = { kind: 'nexusEmperorPayment', player: g.active, event: receipt.event };
return receipt;
}
function passFirst(g: Game, receipt: EmperorPaymentReceipt): EmperorPaymentReceipt {
const next: EmperorPaymentReceipt = { ...receipt, passed: ['first'] };
next.signature = emperorPaymentReceiptSignature(next);
g.pendingNexusEmperorPayment = { receipt: next };
g.active = 'owner';
g.decision = { kind: 'nexusEmperorPayment', player: 'owner', event: next.event };
return next;
}
void test('full price, not the buyer shortfall, determines native funding without mutation', () => {
const g = obligation(8, 8);
g.players.find(player => player.id === 'buyer')!.spice = 100;
const before = structuredClone(g);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.price, 8);
assert.equal(quote.prepaid, 0);
assert.equal(quote.missing, 8);
assert.equal(quote.emperorSpice, 8);
assert.equal(quote.blocked, null);
assert.deepEqual(g, before);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.equal(g.players.find(player => player.id === 'emperor')!.spice, 8);
assert.equal(g.auction!.bid, 8);
assert.equal(g.auction!.allyPayment, 0);
});
void test('all matching prepaid aid counts, including more than the original committed split', () => {
for (const [prepaid, committed, missing] of [[3, 0, 5], [3, 2, 5], [7, 7, 1]] as const) {
const g = obligation(8, missing, prepaid, committed);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.prepaid, prepaid);
assert.equal(quote.missing, missing);
assert.equal(quote.allyPayment, committed);
assert.equal(quote.blocked, null);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.deepEqual(g.aid.emperor, { recipient: 'buyer', amount: prepaid });
}
});
void test('full prepaid price and excess escrow require no extra funding or refund', () => {
for (const prepaid of [8, 12]) {
const g = obligation(8, 0, prepaid, 2);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.prepaid, 8);
assert.equal(quote.missing, 0);
assert.ok(quote.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
assert.equal(g.aid.emperor.amount, prepaid);
assert.equal(receipt.aid.amount, prepaid);
}
});
void test('native own spice must cover the entire missing price, never eventual income', () => {
for (const [price, spice, prepaid] of [[8, 7, 0], [8, 4, 3], [100, 1, 50]] as const) {
const g = obligation(price, spice, prepaid);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.missing, price - prepaid);
assert.ok(quote.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
assert.equal(g.players.find(player => player.id === 'emperor')!.spice, spice);
}
});
void test('safe-integer edge arithmetic subtracts credit without overflowing a wallet sum', () => {
const g = obligation(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1, 1);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.missing, Number.MAX_SAFE_INTEGER - 1);
assert.equal(quote.blocked, null);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
});
void test('wrong-recipient escrow and lost committed credit cannot fund the original invoice', () => {
const g = obligation(8, 8, 3);
g.aid.emperor.recipient = 'first';
assert.equal(emperorPaymentSourceQuote(g)!.prepaid, 0);
assert.equal(emperorPaymentSourceQuote(g)!.missing, 8);
assert.ok(emperorPaymentSourceQuote(g)!.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
const lost = obligation(8, 8, 1, 2);
assert.ok(emperorPaymentSourceQuote(lost)!.blocked);
const lostReceipt = passFirst(lost, open(lost));
assert.ok(emperorPaymentUseBlock(lost, lostReceipt, 'owner'));
});
void test('public storm roster excludes native Emperor, buyer, allies and empty holders independent of faces', () => {
const g = obligation();
assert.deepEqual(requiredEmperorPaymentResponders(g), ['first', 'owner']);
const hands = g.nexusCards!.cards!.hands;
[hands.first, hands.owner] = [hands.owner, hands.first];
g.players.find(player => player.id === 'emperor')!.spice = 0;
assert.deepEqual(requiredEmperorPaymentResponders(g), ['first', 'owner']);
hands.first = null;
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner']);
g.order.reverse();
hands.first = 'emperor';
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner', 'first']);
});
void test('source clears outside the original positive normal mutual-allied obligation', () => {
const changes: ((g: Game) => void)[] = [
g => { g.phase = 4; },
g => { g.auction = null; },
g => { g.auction!.bid = 0; },
g => { g.auction!.bidder = 'emperor'; },
g => { g.players.find(player => player.id === 'emperor')!.ally = null; },
g => { g.players.find(player => player.id === 'buyer')!.ally = null; },
g => { g.currentAuctionSale = { origin: 'normal', winner: 'buyer', amount: 8, free: false, seller: null }; },
g => { g.richeseAuction = {} as NonNullable<Game['richeseAuction']>; },
];
for (const change of changes) {
const g = obligation();
change(g);
assert.equal(emperorPaymentSourceQuote(g), null);
}
});
void test('current lot cannot be already acquired or duplicate an unsold physical card', () => {
for (const zone of ['hand', 'deck', 'discard', 'unsold'] as const) {
const g = obligation();
const card = g.auction!.cards[0];
if (zone === 'hand') g.players[1].hand.push(card);
else if (zone === 'unsold') g.auction!.cards.push(card);
else g[zone].push(card);
assert.equal(emperorPaymentSourceQuote(g), null);
}
const g = obligation();
g.auction!.cards.unshift(g.auction!.cards[0]);
g.auction!.index = 1;
assert.ok(emperorPaymentSourceQuote(g));
});
void test('Use belongs only to the current exact physical holder and freezes the original event', () => {
const g = obligation();
const initial = open(g);
assert.ok(emperorPaymentUseBlock(g, initial, 'owner'));
assert.ok(emperorPaymentUseBlock(g, initial, 'first'));
const receipt = passFirst(g, initial);
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.ok(emperorPaymentUseBlock(g, initial, 'owner'));
g.nexusEmperorPayment!.current = 'another-obligation';
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
g.nexusEmperorPayment!.current = receipt.event;
g.decision = { kind: 'auctionPayment', player: 'buyer' };
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
g.decision = { kind: 'nexusEmperorPayment', player: 'owner', event: receipt.event };
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'owner', g.players);
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
});
void test('original acknowledgement membership survives an earlier holder spending their card', () => {
const g = obligation();
const receipt = passFirst(g, open(g));
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'first', g.players);
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner']);
validateEmperorPaymentReceipt(g, receipt);
assert.deepEqual(receipt.required, ['first', 'owner']);
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
});
void test('snapshot is detached and pending validation rejects changes to each original source fact', () => {
const changes: ((g: Game) => void)[] = [
g => { g.auction!.bid++; },
g => { g.auction!.cards[0] = g.deck.shift()!; },
g => { g.auction!.cards.unshift(g.deck.shift()!); g.auction!.index = 1; },
g => { g.auction!.bidder = 'first'; },
g => { g.auction!.allyPayment = 1; },
g => { g.aid.emperor.amount++; },
g => { g.aid.emperor.recipient = 'first'; },
g => { g.players.find(player => player.id === 'emperor')!.spice++; },
g => { g.players.find(player => player.id === 'buyer')!.ally = null; },
];
for (const change of changes) {
const g = obligation(8, 8, 2);
const receipt = open(g);
const snapshot = structuredClone(receipt);
change(g);
assert.deepEqual(receipt, snapshot);
assert.throws(() => validateEmperorPaymentReceipt(g, receipt));
assert.ok(emperorPaymentUseBlock(g, receipt, 'first'));
}
const g = obligation();
const required = requiredEmperorPaymentResponders(g);
const receipt = createEmperorPaymentReceipt(g, 'original', required);
required.reverse();
assert.deepEqual(receipt.required, ['first', 'owner']);
});
void test('duplicate, reordered and incomplete acknowledgements and unsigned snapshots fail closed', () => {
const g = obligation();
const original = open(g);
for (const changed of [
{ passed: ['first', 'first'] }, { passed: ['owner'] },
{ required: ['first', 'first'] }, { holders: ['first', 'first'] },
{ stage: 'passed' }, { stage: 'used', owner: 'owner' },
{ price: 0 }, { emperorSpice: -1 }, { phase: 4 },
{ aid: { recipient: null, amount: 1 } }, { extra: true },
]) {
const receipt = { ...structuredClone(original), ...changed } as EmperorPaymentReceipt;
receipt.signature = emperorPaymentReceiptSignature(receipt);
assert.throws(() => validateEmperorPaymentReceipt(g, receipt));
}
const unsigned = structuredClone(original);
unsigned.passed.push('first');
assert.throws(() => validateEmperorPaymentReceipt(g, unsigned));
const current = passFirst(g, original);
g.pendingNexusEmperorPayment!.receipt.passed.push('first');
assert.ok(emperorPaymentUseBlock(g, current, 'owner'));
});
void test('completed Use and allpass receipts validate history without requiring old wallets or custody', () => {
for (const stage of ['used', 'passed'] as const) {
const g = obligation(8, 5, 3, 2);
const original = open(g);
const receipt: EmperorPaymentReceipt = stage === 'used'
? { ...original, passed: ['first'], stage, owner: 'owner' }
: { ...original, passed: ['first', 'owner'], stage, owner: null };
receipt.signature = emperorPaymentReceiptSignature(receipt);
validateEmperorPaymentReceipt(g, receipt);
g.auction = null;
g.aid = {};
g.players.forEach(player => { player.spice = 0; player.ally = null; });
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'owner', g.players);
g.turn++;
g.phase = 1;
g.order.reverse();
validateEmperorPaymentReceipt(g, JSON.parse(JSON.stringify(receipt)) as EmperorPaymentReceipt);
if (stage === 'used') {
const unfunded = { ...receipt, emperorSpice: 4 };
unfunded.signature = emperorPaymentReceiptSignature(unfunded);
assert.throws(() => validateEmperorPaymentReceipt(g, unfunded));
}
}
});
void test('malformed price, split and escrow counts never produce an arithmetic entitlement', () => {
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
for (const field of ['price', 'split', 'spice', 'escrow'] as const) {
const g = obligation(8, 8, 3);
if (field === 'price') g.auction!.bid = invalid;
else if (field === 'split') g.auction!.allyPayment = invalid;
else if (field === 'spice') g.players[0].spice = invalid;
else g.aid.emperor.amount = invalid;
assert.equal(emperorPaymentSourceQuote(g), null);
}
}
});