Connect Emperor Nexus compulsory funding to native auction settlement
This commit is contained in:
1 parent
25996ec8a7
commit
022040972c
18 files changed
+1291
-16
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type NexusEmperorPaymentStep = { actor: string; action: Action };
|
||||
export type NexusEmperorPaymentTransition = { before: Game; step: NexusEmperorPaymentStep; after: Game };
|
||||
export type NexusEmperorPaymentFixture = {
|
||||
game: Game;
|
||||
owner: string;
|
||||
winner: string;
|
||||
emperor: string;
|
||||
neutral: string;
|
||||
event: string;
|
||||
price: number;
|
||||
acquired: string;
|
||||
karama: string;
|
||||
originalCards: string[];
|
||||
actions: NexusEmperorPaymentStep[];
|
||||
staging: string[];
|
||||
chronology: {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
firstMentat: NexusEmperorPaymentTransition;
|
||||
worm: Game;
|
||||
alliance: Game;
|
||||
draw: NexusEmperorPaymentTransition;
|
||||
afterClosingDraw: Game;
|
||||
beforeFunding: Game;
|
||||
beforeBid: Game;
|
||||
};
|
||||
wallets: { winner: number; emperorBeforeAid: number; emperor: number; matchingAid: number };
|
||||
};
|
||||
|
||||
export function nexusEmperorPaymentPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing original Emperor-payment seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Fix only scalar native randomness. UUIDs and all other native entropy remain real. */
|
||||
function scalarEntropy<T>(operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function stepNexusEmperorPayment(game: Game, step: NexusEmperorPaymentStep,
|
||||
actions?: NexusEmperorPaymentStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return scalarEntropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function nextNexusEmperorPaymentStep(game: Game): NexusEmperorPaymentStep {
|
||||
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'nexusEmperorPayment')
|
||||
return { actor: game.decision.player,
|
||||
action: { type: 'nexusEmperorPayment', event: game.decision.event, use: false } };
|
||||
const next = nextStrongholdFactionsNativeStep(game);
|
||||
assert.ok(next, 'The genuine Emperor-payment chronology must not require a manufactured battle plan');
|
||||
return next;
|
||||
}
|
||||
export function advanceNexusEmperorPayment(game: Game, until: (game: Game) => boolean,
|
||||
actions?: NexusEmperorPaymentStep[]): Game {
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = stepNexusEmperorPayment(game, nextNexusEmperorPaymentStep(game), actions);
|
||||
}
|
||||
throw new Error('The original Emperor-payment chronology did not reach its requested boundary');
|
||||
}
|
||||
|
||||
/** Physical ownership zones only: paid auction prefixes are references, not duplicates. */
|
||||
export function nexusEmperorPaymentCards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
}
|
||||
export function nexusEmperorPaymentInventory(game: Game): void {
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.deepEqual(nexusEmperorPaymentCards(game), treacheryDeck().map(card => card.id).sort());
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), 5);
|
||||
}
|
||||
}
|
||||
function frontOriginalSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
const prefix = kinds.map(kind => {
|
||||
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
|
||||
assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
});
|
||||
game.spiceDeck.unshift(...prefix);
|
||||
}
|
||||
|
||||
/** Genuine four-seat Basic/Advanced native history. Only still-undealt physical
|
||||
* singleton order is controlled; no hand, wallet, army, phase, alliance or receipt
|
||||
* is assigned. The original first turn ends before the later real worm/alliance. */
|
||||
export function createNexusEmperorPaymentFixture(options: {
|
||||
advanced?: boolean; prepaidAid?: number; neutralHolder?: boolean; truthtrance?: boolean;
|
||||
} = {}): NexusEmperorPaymentFixture {
|
||||
const owner = 'atreides', emperor = 'emperor', winner = 'guild', neutral = 'harkonnen';
|
||||
const price = 2, matchingAid = options.prepaidAid ?? 0;
|
||||
assert.ok(Number.isInteger(matchingAid) && matchingAid >= 0 && matchingAid <= price);
|
||||
const actions: NexusEmperorPaymentStep[] = [], staging: string[] = [];
|
||||
let game = createGame('NEXUSEMPERORPAYMENT', newPlayer(owner, 'Atreides', 'atreides'), options.advanced ?? false);
|
||||
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
joinGame(game, newPlayer(winner, 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer(neutral, 'Harkonnen', 'harkonnen'));
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
for (const player of game.players) game = stepNexusEmperorPayment(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
game = scalarEntropy(() => initializeNexusGameForAudit(game));
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
// Both printed Karama cards are actually dealt by the original starting-card
|
||||
// routine: one keeps Guild's ordinary payment choice, one can cancel income.
|
||||
const prefix = [];
|
||||
for (const kind of ['worthless', 'worthless', 'karama', 'karama', options.truthtrance ? 'truthtrance' : 'worthless']) {
|
||||
const index = game.deck.findIndex(card => kind === 'worthless' ? card.kind === kind : card.effect === kind);
|
||||
assert.ok(index >= 0);
|
||||
prefix.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
game.deck.unshift(...prefix);
|
||||
staging.push('Ordered five original undealt starting Treachery singletons before their native setup deal.');
|
||||
const initial = structuredClone(game);
|
||||
game = advanceNexusEmperorPayment(game, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
const karama = nexusEmperorPaymentPlayer(game, neutral).hand.find(card => card.effect === 'karama')!.id;
|
||||
assert.ok(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.effect === 'karama'));
|
||||
game = advanceNexusEmperorPayment(game, state => state.phase === 1 && clean(state), actions);
|
||||
frontOriginalSpice(game, ['land', 'land']);
|
||||
staging.push('Ordered original undealt non-worm first-turn Spice Cards; no first-turn Nexus manufactured.');
|
||||
let firstMentat: NexusEmperorPaymentTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), step = nextNexusEmperorPaymentStep(game);
|
||||
game = stepNexusEmperorPayment(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
game = advanceNexusEmperorPayment(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
|
||||
frontOriginalSpice(game, ['worm', 'land', 'land']);
|
||||
staging.push('Ordered original still-undealt turn-two worm and subsequent land singletons.');
|
||||
game = advanceNexusEmperorPayment(game, state => !!state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && clean(state), actions);
|
||||
const worm = structuredClone(game);
|
||||
for (const [actor, target] of [[emperor, winner], [winner, emperor]])
|
||||
game = stepNexusEmperorPayment(game, { actor, action: { type: 'alliance', target } }, actions);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, emperor).ally, winner);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, winner).ally, emperor);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceNexusEmperorPayment(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('emperor'); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
staging.push('Ordered original undealt Emperor Nexus singleton immediately before its real closing draw.');
|
||||
const beforeDraw = structuredClone(game);
|
||||
const drawStep: NexusEmperorPaymentStep = { actor: owner, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
|
||||
game = stepNexusEmperorPayment(game, drawStep, actions);
|
||||
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
|
||||
const draw = { before: beforeDraw, step: drawStep, after: structuredClone(game) };
|
||||
if (options.neutralHolder ?? true) {
|
||||
const irrelevant = game.nexusCards!.cards!.deck.indexOf('fremen'); assert.ok(irrelevant >= 0);
|
||||
game.nexusCards!.cards!.deck.unshift(game.nexusCards!.cards!.deck.splice(irrelevant, 1)[0]);
|
||||
staging.push('Ordered original undealt irrelevant Fremen Nexus singleton for the other unallied closing draw.');
|
||||
game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
} else game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 } }, actions);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
game = advanceNexusEmperorPayment(game, state => state.phase === 3 && !!state.auction && clean(state), actions);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.ok(game.order.indexOf(neutral) < game.order.indexOf(owner), 'The real later storm order provides a neutral responder before Use');
|
||||
const beforeFunding = structuredClone(game);
|
||||
const emperorBeforeAid = nexusEmperorPaymentPlayer(game, emperor).spice;
|
||||
if (matchingAid) game = stepNexusEmperorPayment(game, { actor: emperor,
|
||||
action: { type: 'pledgeAid', amount: matchingAid } }, actions);
|
||||
while (game.auction!.active !== winner)
|
||||
game = stepNexusEmperorPayment(game, { actor: game.auction!.active, action: { type: 'passBid' } }, actions);
|
||||
const beforeBid = structuredClone(game), acquired = game.auction!.cards[game.auction!.index].id;
|
||||
game = stepNexusEmperorPayment(game, { actor: winner, action: { type: 'bid', amount: price, allyPayment: matchingAid } }, actions);
|
||||
game = advanceNexusEmperorPayment(game, state => state.decision?.kind === 'nexusEmperorPayment', actions);
|
||||
assert.equal(game.auction!.bid, price); assert.equal(game.auction!.bidder, winner);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.id === acquired), false);
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'nexusEmperorPayment');
|
||||
nexusEmperorPaymentInventory(game);
|
||||
return { game, owner, emperor, winner, neutral, event: decision.event, price, acquired, karama,
|
||||
originalCards: nexusEmperorPaymentCards(game), actions, staging,
|
||||
chronology: { initial, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeFunding, beforeBid },
|
||||
wallets: { winner: nexusEmperorPaymentPlayer(game, winner).spice, emperorBeforeAid,
|
||||
emperor: nexusEmperorPaymentPlayer(game, emperor).spice, matchingAid } };
|
||||
}
|
||||
@@ -93,6 +93,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
const actor = decision.player;
|
||||
if (decision.kind === 'nexusFremenProtection')
|
||||
return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } };
|
||||
if (decision.kind === 'nexusEmperorPayment')
|
||||
return { actor, action: { type: 'nexusEmperorPayment', event: decision.event, use: false } };
|
||||
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
|
||||
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
|
||||
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
createNexusEmperorPaymentFixture, nexusEmperorPaymentCards, nexusEmperorPaymentInventory,
|
||||
nexusEmperorPaymentPlayer, stepNexusEmperorPayment,
|
||||
} from './fixture-nexus-emperor-payment';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, original, 'A rejected activation must not mutate its original source');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const prepaidAid of [0, 1]) for (const cancelIncome of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${cancelIncome ? 'cancels' : 'allows'} original Emperor income`, () => {
|
||||
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
|
||||
assert.equal(f.game.decision!.player, f.neutral);
|
||||
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.neutral, f.owner]);
|
||||
for (const actor of [f.neutral, f.winner, f.emperor, f.owner])
|
||||
assert.equal(viewGame(f.game, actor).nexusEmperorPayment, null,
|
||||
'No private Emperor offer appears before its holder is current');
|
||||
rejectsUnchanged(f.game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
rejectsUnchanged(f.game, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
const beforeNeutral = structuredClone(f.game);
|
||||
let game = stepNexusEmperorPayment(f.game, { actor: f.neutral,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
|
||||
assert.equal(game.decision!.kind, 'nexusEmperorPayment');
|
||||
assert.equal(game.decision!.player, f.owner);
|
||||
assert.deepEqual(game.players, beforeNeutral.players, 'Neutral Continue changes neither wallet nor card custody');
|
||||
assert.deepEqual(game.auction, beforeNeutral.auction, 'Neutral Continue cannot create a new bid');
|
||||
const offer = viewGame(game, f.owner).nexusEmperorPayment!;
|
||||
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
|
||||
for (const actor of [f.neutral, f.winner, f.emperor])
|
||||
assert.equal(viewGame(game, actor).nexusEmperorPayment, null);
|
||||
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: `${f.event}:stale`, use: true });
|
||||
game = stepNexusEmperorPayment(game, { actor: f.owner,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
|
||||
assert.equal(game.pendingNexusEmperorPayment, null);
|
||||
assert.equal(game.nexusEmperorPayment!.current, null);
|
||||
const receipt = game.nexusEmperorPayment!.completed.at(-1)!;
|
||||
assert.equal(receipt.event, f.event); assert.equal(receipt.stage, 'used');
|
||||
assert.equal(receipt.owner, f.owner); assert.deepEqual(receipt.passed, [f.neutral]);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.neutral], 'fremen');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner,
|
||||
'Full forced funding leaves the buyer’s original balance unchanged');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor - (f.price - prepaidAid),
|
||||
'Prepaid credit is already deducted; only the missing spice is newly escrowed');
|
||||
assert.deepEqual(game.aid[f.emperor], { recipient: f.winner, amount: 0 });
|
||||
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.bidder, f.winner);
|
||||
assert.equal(game.auction!.allyPayment, f.price);
|
||||
assert.equal(game.auction!.index, f.game.auction!.index);
|
||||
assert.equal(game.currentAuctionSale!.origin, 'normal');
|
||||
assert.equal(game.currentAuctionSale!.winner, f.winner);
|
||||
assert.equal(game.currentAuctionSale!.amount, f.price); assert.equal(game.currentAuctionSale!.free, false);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(game.response!.kind, 'emperorIncome', 'Only the original native income cancellation window opens');
|
||||
assert.equal(game.response!.owner, f.emperor);
|
||||
assert.ok(!game.pendingKarama);
|
||||
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
if (cancelIncome) {
|
||||
game = stepNexusEmperorPayment(game, { actor: f.neutral,
|
||||
action: { type: 'card', mode: 'cancel', card: f.karama } });
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
} else {
|
||||
while (game.response?.kind === 'emperorIncome') {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
|
||||
game = stepNexusEmperorPayment(game, { actor, action: { type: 'passResponse' } });
|
||||
}
|
||||
}
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice,
|
||||
f.wallets.emperorBeforeAid - (cancelIncome ? f.price : 0),
|
||||
'Original income is credited once or canceled without refunding the original forced payment');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(game.auction!.index, f.game.auction!.index + 1, 'Original successor lot advances exactly once');
|
||||
assert.equal(game.response!.kind, 'atreidesAuction', 'Original next-lot inspection is restored');
|
||||
assert.equal(game.nexusEmperorPayment!.completed.length, 1);
|
||||
assert.deepEqual(nexusEmperorPaymentCards(game), f.originalCards);
|
||||
nexusEmperorPaymentInventory(game);
|
||||
},
|
||||
);
|
||||
|
||||
for (const prepaidAid of [0, 1, 2]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} every real acknowledgement passes and preserves ordinary payment with ${prepaidAid} pledged spice`, () => {
|
||||
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
|
||||
let game = f.game;
|
||||
while (game.decision?.kind === 'nexusEmperorPayment') {
|
||||
const actor = game.decision.player;
|
||||
if (actor === f.owner && prepaidAid === f.price) {
|
||||
assert.equal(viewGame(game, actor).nexusEmperorPayment!.canUse, false);
|
||||
rejectsUnchanged(game, actor, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
}
|
||||
game = stepNexusEmperorPayment(game, { actor,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
|
||||
}
|
||||
assert.equal(game.decision!.kind, 'auctionPayment'); assert.equal(game.decision!.player, f.winner);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.equal(game.nexusEmperorPayment!.completed.at(-1)!.stage, 'passed');
|
||||
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.allyPayment, prepaidAid);
|
||||
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'decision', karama: false } });
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner - f.price + prepaidAid);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
|
||||
assert.equal(game.response!.kind, 'emperorIncome');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
|
||||
assert.equal(game.nexusEmperorPayment!.completed.length, 1, 'Ordinary settlement does not reopen the same obligation');
|
||||
nexusEmperorPaymentInventory(game);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void test('real neutral response rejects stale events, wrong actors, extra keys and coercion atomically', () => {
|
||||
const f = createNexusEmperorPaymentFixture();
|
||||
for (const actor of [f.owner, f.emperor, f.winner])
|
||||
rejectsUnchanged(f.game, actor, { type: 'nexusEmperorPayment', event: f.event, use: false });
|
||||
for (const action of [
|
||||
{ type: 'nexusEmperorPayment', event: 'not-the-original-event', use: false },
|
||||
{ type: 'nexusEmperorPayment', event: f.event, use: false, amount: 2 },
|
||||
{ type: 'nexusEmperorPayment', event: f.event, use: false, policy: 'full' },
|
||||
{ type: 'nexusEmperorPayment', event: f.event, use: 'false' },
|
||||
{ type: 'nexusEmperorPayment', event: f.event },
|
||||
{ type: 'decision', event: f.event, use: false },
|
||||
]) rejectsUnchanged(f.game, f.neutral, action);
|
||||
const ownerTurn = stepNexusEmperorPayment(f.game, { actor: f.neutral,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
|
||||
rejectsUnchanged(ownerTurn, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: false });
|
||||
rejectsUnchanged(ownerTurn, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true, amount: 2 });
|
||||
});
|
||||
|
||||
void test('a real closing draw without the irrelevant holder gives only the actual remaining public responder', () => {
|
||||
const f = createNexusEmperorPaymentFixture({ neutralHolder: false });
|
||||
assert.equal(f.game.decision!.player, f.owner);
|
||||
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.owner]);
|
||||
assert.equal(viewGame(f.game, f.owner).nexusEmperorPayment!.canUse, true);
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.neutral], null);
|
||||
const game = stepNexusEmperorPayment(f.game, { actor: f.owner,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
|
||||
assert.equal(game.response!.kind, 'emperorIncome');
|
||||
nexusEmperorPaymentInventory(game);
|
||||
});
|
||||
|
||||
void test('naturally setup-dealt Truthtrance has priority over the original acknowledgement and preserves its event', () => {
|
||||
const f = createNexusEmperorPaymentFixture({ truthtrance: true });
|
||||
const card = nexusEmperorPaymentPlayer(f.game, f.neutral).hand.find(held => held.effect === 'truthtrance')!;
|
||||
assert.ok(card);
|
||||
let game = stepNexusEmperorPayment(f.game, { actor: f.neutral, action: { type: 'card', card: card.id } });
|
||||
assert.equal(game.truthtrance!.stage, 'priority');
|
||||
assert.equal(game.pendingNexusEmperorPayment!.receipt.event, f.event);
|
||||
rejectsUnchanged(game, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: false });
|
||||
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
|
||||
game = stepNexusEmperorPayment(game, { actor, action: { type: 'truthPass' } });
|
||||
}
|
||||
game = stepNexusEmperorPayment(game, { actor: f.neutral, action: { type: 'truthAsk',
|
||||
question: { kind: 'fact', target: f.winner, fact: { kind: 'hand', name: 'Karama' } } } });
|
||||
assert.equal(viewGame(game, f.winner).truthAnswer, 'yes');
|
||||
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'truthAnswer', answer: 'yes' } });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.equal(game.decision!.kind, 'nexusEmperorPayment');
|
||||
assert.equal(game.decision!.player, f.neutral);
|
||||
assert.equal(game.pendingNexusEmperorPayment!.receipt.event, f.event);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
|
||||
assert.equal(game.discard.filter(held => held.id === card.id).length, 1);
|
||||
game = stepNexusEmperorPayment(game, { actor: f.neutral,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
|
||||
game = stepNexusEmperorPayment(game, { actor: f.owner,
|
||||
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
|
||||
assert.equal(game.response!.kind, 'emperorIncome');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(held => held.id === f.acquired).length, 1);
|
||||
nexusEmperorPaymentInventory(game);
|
||||
});
|
||||
|
||||
void test('original Easy bot policy passes for an irrelevant real holder then uses its own eligible Emperor Nexus', () => {
|
||||
const f = createNexusEmperorPaymentFixture();
|
||||
let game = f.game;
|
||||
for (const actor of [f.neutral, f.owner]) {
|
||||
const view = viewGame(game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
game = stepNexusEmperorPayment(game, { actor, action });
|
||||
}
|
||||
assert.equal(game.response!.kind, 'emperorIncome');
|
||||
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
|
||||
assert.equal(game.nexusEmperorPayment!.completed.at(-1)!.stage, 'used');
|
||||
nexusEmperorPaymentInventory(game);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user