Connect Emperor Nexus compulsory funding to native auction settlement

This commit is contained in:
Quintine committed 2026-10-15 06:38:14 +11:00
1 parent 25996ec8a7
commit 022040972c
18 files changed
+1291 -16

No files matched your search

+213
View File
@@ -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 } };
}
+2
View File
@@ -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')
+195
View File
@@ -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);
});
+306
View File
@@ -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);
}
}
});