Connect Emperor Nexus retention to original Richese seller lots

This commit is contained in:
Quintine committed 2026-10-15 10:20:33 +11:00
1 parent 22948ff6e4
commit d3e1c2f242
21 files changed
+1314 -67

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializePairedNexusGameForAudit, initializeRicheseBetrayalGameForAudit, joinGame, newPlayer,
type Action, type Game, type Player,
} from '../game/engine';
import type { Card } from '../game/cards';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
export type NexusEmperorSellerStep = { actor: string; action: Action };
export type NexusEmperorSellerTransition = { before: Game; step: NexusEmperorSellerStep; after: Game };
export type NexusEmperorSellerFixture = {
game: Game; event: string; winner: string; seller: string; source: 'cache' | 'blackMarket';
card: Card; price: number; round: string; lot: string;
wallets: Record<string, number>; originalCards: string[]; actions: NexusEmperorSellerStep[];
chronology: {
initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorSellerTransition;
worm: Game; alliance: Game; draw: NexusEmperorSellerTransition; afterClosingDraw: Game;
beforeOffer: Game; beforeWinnerBid: Game; beforeFinalBid: Game;
};
};
export function nexusEmperorSellerPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original seller-lot seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
export function stepNexusEmperorSeller(game: Game, step: NexusEmperorSellerStep,
actions?: NexusEmperorSellerStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
/** Consume only original owner-labelled choices; never manufacture a private plan. */
export function nextNexusEmperorSellerStep(game: Game): NexusEmperorSellerStep {
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 === 'nexusEmperorSellerPurchase')
return { actor: game.decision.player,
action: { type: 'nexusEmperorSellerPurchase', event: game.decision.event, use: false } };
const next = nextPairedChoamStep(game);
assert.ok(next, 'The original seller chronology requires a real human battle plan');
return next;
}
export function advanceNexusEmperorSeller(game: Game, until: (game: Game) => boolean,
actions?: NexusEmperorSellerStep[]): Game {
for (let n = 0; n < 2000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game), actions);
}
throw Error('The original seller chronology did not reach its requested boundary');
}
/** Original physical zones, excluding historical ordinary-auction aliases. */
export function nexusEmperorSellerCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale &&
game.currentAuctionSale.origin === 'normal')) ?? [])].map(card => card.id).sort();
}
export function nexusEmperorSellerInventory(game: Game): void {
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual(nexusEmperorSellerCards(game),
[...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 3);
}
}
/** Native Basic/Advanced paired Richese/CHOAM with classic seats. Starting cards,
* first turn, later worm/alliance/closing Nexus draw and winning bid are actual
* source actions. Only undealt singleton order/scoped scalar entropy is selected;
* hands, balances, counters, alliances, clocks and receipts are never assigned. */
export function createNexusEmperorSellerFixture(options: {
advanced?: boolean; source?: 'cache' | 'blackMarket'; winnerFaction?: 'harkonnen' | 'richese';
nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean;
betrayal?: 'pass' | 'bank' | 'before';
} = {}): NexusEmperorSellerFixture {
const source = options.source ?? 'cache', advanced = options.advanced ?? source === 'blackMarket';
const winner = options.winnerFaction ?? 'harkonnen', seller = 'richese', price = 2;
assert.ok(source !== 'blackMarket' || advanced, 'Original Black Market is Advanced only');
assert.ok(source !== 'blackMarket' || winner !== seller, 'Original Black Market excludes seller self-purchase');
const actions: NexusEmperorSellerStep[] = [];
let game = createGame('NEXUSEMPERORSELLER', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['choam', 'harkonnen', 'fremen'] as const)
joinGame(game, newPlayer(faction, faction, faction));
if (options.betrayal) joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game.nexusCards = { cards: null, phase: null };
const initial = structuredClone(game);
for (const player of game.players)
game = stepNexusEmperorSeller(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = withClassicDiscoveryNexusLottery(() => options.betrayal
? initializeRicheseBetrayalGameForAudit(game) : initializePairedNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const prefix: Card[] = [];
// A real setup-dealt Karama retains native source/bonus cancellation windows;
// the quiet driver passes them rather than fabricating a response frame.
for (const effect of ['worthless', options.truthtrance ? 'truthtrance' : 'worthless', 'worthless', 'worthless', 'karama']) {
const index = game.deck.findIndex(card => effect === 'worthless' ? card.kind === effect : card.effect === effect);
assert.ok(index >= 0);
prefix.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...prefix);
const undealt = structuredClone(game);
game = advanceNexusEmperorSeller(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.equal(nexusEmperorSellerPlayer(game, 'harkonnen').hand.length, 2);
assert.equal(nexusEmperorSellerPlayer(game, seller).hand.length, 1);
game = advanceNexusEmperorSeller(game, state => state.phase === 1 && clean(state), actions);
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0);
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1);
let firstMentat: NexusEmperorSellerTransition | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextNexusEmperorSellerStep(game);
game = stepNexusEmperorSeller(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
assert.equal(firstMentat.before.phase, 8);
game = advanceNexusEmperorSeller(game, state => state.turn === 2 && state.phase === 1 && clean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0);
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1);
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2);
game = advanceNexusEmperorSeller(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions);
const worm = structuredClone(game);
for (const [actor, target] of [['choam', 'fremen'], ['fremen', 'choam']])
game = stepNexusEmperorSeller(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = structuredClone(game);
game = advanceNexusEmperorSeller(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
assert.ok(game.nexusCards!.phase!.eligible.includes(winner));
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(options.nexusCard ?? 'emperor');
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const drawBefore = structuredClone(game), drawStep: NexusEmperorSellerStep = { actor: winner,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[winner], choice: 'draw', ownRedraws: 0 } };
game = stepNexusEmperorSeller(game, drawStep, actions);
const draw = { before: drawBefore, step: drawStep, after: structuredClone(game) };
if (options.betrayal) {
const remaining = game.nexusCards!.cards!, richese = remaining.deck.indexOf('richese');
assert.ok(richese >= 0);
remaining.deck.unshift(remaining.deck.splice(richese, 1)[0]);
game = stepNexusEmperorSeller(game, { actor: 'guild', action: { type: 'nexusCardChoice',
turn: game.turn, card: remaining.hands.guild, choice: 'draw', ownRedraws: 0 } }, actions);
}
game = advanceNexusEmperorSeller(game, state => state.phase === 2 && clean(state), actions);
const afterClosingDraw = structuredClone(game);
assert.equal(nexusEmperorSellerPlayer(game, winner).ally, null);
assert.ok(!game.players.some(player => player.faction === 'emperor'));
game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response &&
state.decision?.kind === (source === 'cache' ? 'richeseCache' : 'richeseBlackMarket'), actions);
const beforeOffer = structuredClone(game);
const card = structuredClone(source === 'cache' ? game.richeseCache![0] : nexusEmperorSellerPlayer(game, seller).hand[0]);
const round = game.richeseBidding!.event;
game = stepNexusEmperorSeller(game, { actor: seller,
action: { type: 'decision', event: round, card: card.id, method: 'silent' } }, actions);
game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response && !state.decision, actions);
const lot = game.richeseAuction!.event;
let beforeFinalBid = structuredClone(game);
let beforeWinnerBid: Game | undefined;
while (!game.richeseAuction!.outcome) {
const auction = game.richeseAuction!;
const actor = auction.order.find(id => auction.eligible.includes(id) && !Object.hasOwn(auction.sealed, id));
assert.ok(actor);
beforeFinalBid = structuredClone(game);
if (actor === winner) beforeWinnerBid = structuredClone(game);
game = stepNexusEmperorSeller(game, { actor,
action: { type: 'richeseBid', event: lot, amount: actor === winner ? price : 0 } }, actions);
}
assert.ok(beforeWinnerBid);
if (options.betrayal && options.betrayal !== 'before') {
while (game.pendingRicheseBetrayal) {
const receipt = game.pendingRicheseBetrayal.receipt;
const actor = receipt.required.find(id => !receipt.passed.includes(id));
assert.ok(actor);
game = stepNexusEmperorSeller(game, { actor, action: {
type: actor === 'guild' && options.betrayal === 'bank' ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
event: receipt.event,
} }, actions);
}
}
if (options.betrayal === 'before') assert.ok(game.pendingRicheseBetrayal);
else {
assert.equal(game.decision?.kind, 'nexusEmperorSellerPurchase');
assert.equal(game.decision.player, winner);
}
assert.equal(game.richeseAuction!.cardId, card.id);
assert.deepEqual(game.richeseAuction!.outcome, { kind: 'sold', winner, amount: price });
assert.ok(nexusEmperorSellerPlayer(game, winner).spice >= price);
assert.equal(nexusEmperorSellerPlayer(game, winner).hand.some(held => held.id === card.id), false);
const wallets = Object.fromEntries(game.players.map(player => [player.id, player.spice]));
const originalCards = nexusEmperorSellerCards(game);
nexusEmperorSellerInventory(game);
return { game, event: game.decision?.kind === 'nexusEmperorSellerPurchase' ? game.decision.event :
game.pendingRicheseBetrayal!.receipt.event, winner, seller, source, card, price, round, lot,
wallets, originalCards, actions, chronology: { initial, undealt, afterSetup, firstMentat,
worm, alliance, draw, afterClosingDraw, beforeOffer, beforeWinnerBid, beforeFinalBid } };
}
+2
View File
@@ -95,6 +95,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
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 === 'nexusEmperorSellerPurchase')
return { actor, action: { type: 'nexusEmperorSellerPurchase', 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')
+3 -1
View File
@@ -80,7 +80,9 @@ void test('a prior normal purchase cannot authorize spending Emperor Nexus on a
reject(g, owner, first, /current Emperor Nexus/i);
const second = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
const done = applyAction(g, owner, second);
assert.equal(done.nexusEmperorPurchaseHistory?.[0].auctionIndex, 1);
const receipt = done.nexusEmperorPurchaseHistory?.[0];
assert.ok(receipt && 'auctionIndex' in receipt);
assert.equal(receipt.auctionIndex, 1);
});
void test('invalid normal-lot custody and full winning hand cannot spend the physical Nexus card', () => {
+230
View File
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer, handLimit, type Game } from '../game/engine';
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
import { richeseCards } from '../game/richese-cards';
import { quoteRicheseSettlement } from '../game/richese-settlement';
import {
EmperorNexusPurchaseError, createEmperorNexusSellerPurchase,
emperorNexusSellerPurchaseEvent, emperorNexusPurchaseEvent, quoteEmperorNexusSellerPurchase,
signEmperorNexusPurchase, validateEmperorNexusPurchase,
type EmperorNexusBankPurchase, type EmperorNexusSellerPurchaseInput, type EmperorNexusSellerPurchase,
} from '../game/nexus-emperor-purchase';
import {
auctionEmperorIncomeAmount, quoteAuctionContinuation, AuctionContinuationError,
type AuctionContinuationInput,
} from '../game/auction-continuation-quote';
function original(source: 'cache' | 'blackMarket' = 'cache', self = false) {
const g = createGame('SELLERQUOTE', newPlayer('r', 'Richese', 'richese'), source === 'blackMarket', ['choam']);
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('c', 'CHOAM', 'choam'));
g.order = g.players.map(p => p.id);
g.status = 'playing';
g.phase = 3;
g.turn = 2;
for (const p of g.players) { p.hand = []; p.spice = 10; }
g.deck = [];
g.discard = [];
g.auction = null;
const card = source === 'cache' ? richeseCards()[0] : baseDeck()[0];
g.richeseCache = source === 'cache' ? [card] : [];
if (source === 'blackMarket') g.players[0].hand.push(card);
const winner = self ? 'r' : 'h';
let lot = createRicheseAuction({
event: 'original-lot', cardId: card.id, source, owner: 'r', method: 'silent',
eligible: g.order, order: g.order, tieOrder: g.order,
});
for (const actor of g.order)
lot = submitRicheseBid(lot, { event: lot.event, actor, amount: actor === winner ? 6 : 0 }, 10);
g.richeseAuction = lot;
g.richeseBidding = {
owner: 'r', event: 'original-round', turn: g.turn, stage: 'lot',
position: source === 'cache' ? 'first' : null, normalCount: 3,
blackMarketSold: false, cacheCanceled: false,
};
g.richeseFunding = { [winner]: { amount: 6, allyPayment: 0, donor: null } };
const cards = createNexusCards(g.players, () => 0);
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'emperor')];
g.nexusCards = { cards: drawNexusCard(cards, winner, g.players, () => 0), phase: null };
const buyer = g.players.find(p => p.id === winner)!;
const quote = quoteRicheseSettlement({
lot, owner: { id: 'r', handCount: g.players[0].hand.length, handLimit: 4 },
winner: { id: winner, handCount: buyer.hand.length, handLimit: handLimit(buyer), spice: buyer.spice },
card, funding: g.richeseFunding[winner],
});
assert.equal(quote.kind, 'sold');
if (quote.kind !== 'sold') throw new Error('Expected native original sale.');
const input: EmperorNexusSellerPurchaseInput = { round: g.richeseBidding.event, lot, quote, bank: false };
return { g, input, winner };
}
function continuation(g: Game, input: EmperorNexusSellerPurchaseInput, free = true): AuctionContinuationInput {
return {
status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced,
players: g.players.map(p => ({ ...p, handLimit: handLimit(p) })), order: g.order,
physicalCards: [...g.players.flatMap(p => p.hand), ...(g.richeseCache ?? [])],
auction: null, richeseAuction: input.lot, richeseBidding: g.richeseBidding,
richeseCacheCount: g.richeseCache?.length,
sale: { winner: input.quote.winner, amount: input.quote.amount, free,
origin: input.lot.source, seller: input.lot.owner, ...(input.bank ? { recipient: 'bank' as const } : {}) },
};
}
void test('original cache and Black Market price proofs sign unchanged personally held spice without mutating the source', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { g, input, winner } = original(source), before = structuredClone(g);
const offer = quoteEmperorNexusSellerPurchase(g, input, winner);
assert.equal(offer.blocked, null);
assert.equal(offer.price, 6);
assert.equal(offer.source, source);
const receipt = createEmperorNexusSellerPurchase(g, input);
assert.equal(receipt.version, 2);
assert.equal(receipt.owner, winner);
assert.equal(receipt.seller, 'r');
assert.equal(receipt.beforeSpice, 10);
assert.equal(receipt.afterSpice, 10);
assert.equal(receipt.event, emperorNexusSellerPurchaseEvent(2, winner, 0, 'original-round', 'original-lot', source, 6));
assert.equal(Object.hasOwn(receipt, 'auctionIndex'), false);
assert.deepEqual(g, before);
// Historical evidence deliberately ignores future wallets and card transfers.
g.turn = 4;
g.players.find(p => p.id === winner)!.spice = 0;
g.richeseAuction = null;
g.richeseCache = [];
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
}
});
void test('native Richese self cache purchase can retain its personally affordable price', () => {
const { g, input } = original('cache', true);
assert.equal(createEmperorNexusSellerPurchase(g, input).owner, 'r');
const q = quoteAuctionContinuation(continuation(g, input), { kind: 'sale', free: true });
assert.deepEqual(q.steps, []);
assert.deepEqual(q.next, { kind: 'richeseEnd', source: 'cache', after: 'normalPool' });
});
void test('personal-price, exact source, full hand and physical singleton corruption fail closed', () => {
const changes: ((g: Game, input: EmperorNexusSellerPurchaseInput) => void)[] = [
g => { g.players[1].spice = 5; },
(_g, input) => { input.quote.amount = 5; },
(_g, input) => { input.quote.ownPayment = 5; },
(_g, input) => { input.quote.allyPayment = 1; },
(_g, input) => { input.round = 'other-round'; },
(_g, input) => { input.lot = { ...input.lot, event: 'other-lot' }; },
(_g, input) => { input.lot = { ...input.lot, bid: 5 }; },
g => { g.richeseFunding!.h.donor = 'c'; },
g => { g.richeseFunding!.h.amount = 5; },
g => { g.deck.push(g.richeseCache![0]); },
g => { g.richeseCache = []; },
g => { g.players[1].hand = baseDeck().slice(0, 8); },
g => { g.nexusCards!.cards!.deck.push('emperor'); },
(_g, input) => { input.quote.card = { ...input.quote.card, name: 'substituted face' }; },
(_g, input) => { Object.assign(input, { extra: true }); },
(_g, input) => { Object.assign(input.quote, { extra: true }); },
];
for (const change of changes) {
const { g, input } = original();
change(g, input);
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
}
const { g, input } = original('blackMarket');
g.advanced = false;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
assert.throws(() => original('blackMarket', true), /Black Market self-purchase/);
});
void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
const { g, input, winner } = original();
const cards = g.nexusCards!.cards!;
cards.hands[winner] = 'guild';
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'guild')];
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /real Emperor/);
cards.deck = ['guild', ...cards.deck.filter(c => c !== 'emperor')];
cards.hands[winner] = 'emperor';
g.players[1].ally = 'c';
g.players[2].ally = winner;
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /unallied/);
input.quote.allyPayment = 2;
input.quote.ownPayment = 4;
g.richeseFunding!.h = { amount: 6, allyPayment: 2, donor: 'c' };
g.aid.c = { recipient: winner, amount: 2 };
g.players[1].spice = 4;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), /unallied/);
});
void test('free original seller continuation produces no seller credit or income while preserving native bonus and end boundaries', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { g, input } = original(source);
for (const bank of [false, true]) {
input.bank = bank;
const ctx = continuation(g, input), before = structuredClone(ctx);
const q = quoteAuctionContinuation(ctx, { kind: 'sale', free: true });
assert.equal(q.sale.amount, 6);
assert.equal(auctionEmperorIncomeAmount(q.sale), 0);
assert.deepEqual(q.steps, []);
assert.deepEqual(q.next, { kind: 'response', response: { kind: 'harkonnenBonus', owner: 'h', passed: [] } });
const end = quoteAuctionContinuation(ctx, { kind: 'next' });
assert.deepEqual(end.next, source === 'cache'
? { kind: 'richeseEnd', source: 'cache', after: 'normalPool' }
: { kind: 'richeseEnd', source: 'blackMarket', after: 'declaration', blackMarketSold: true });
assert.deepEqual(ctx, before);
const paid = quoteAuctionContinuation(continuation(g, input, false), { kind: 'sale', free: false });
assert.deepEqual(paid.steps, bank ? [] : [{ kind: 'sellerCredit', player: 'r', amount: 6, balance: 16 }]);
}
}
});
void test('a free seller lot cannot fabricate a donor invoice or payer-derived income', () => {
for (const patch of [
{ richeseContribution: 1 }, { richeseContribution: 0 },
{ occupiedContributions: [{ payer: 'h', amount: 6 }] },
{ bureaucratPaymentEvent: 'fake-paid-source' },
{ spiceBankerIncomePayment: {} }, { bureaucratPayment: {} },
]) {
const { g, input } = original();
const ctx = continuation(g, input);
Object.assign(ctx.sale!, patch);
assert.throws(() => quoteAuctionContinuation(ctx, { kind: 'sale', free: true }), AuctionContinuationError);
}
});
void test('strict seller receipt source, price and retained balance corruption rejects even after re-signing', () => {
const { g, input } = original();
const receipt = createEmperorNexusSellerPurchase(g, input);
const invalid = (patch: Partial<EmperorNexusSellerPurchase>, resign = false) => {
const changed = { ...receipt, ...patch };
if (resign) changed.signature = signEmperorNexusPurchase(changed);
assert.throws(() => validateEmperorNexusPurchase(g, changed), EmperorNexusPurchaseError);
};
for (const patch of [
{ origin: 'blackMarket' as const }, { seller: 'c' }, { round: 'other-round' },
{ lot: 'other-lot' }, { price: 7 }, { card: 'other-card' }, { owner: 'c' }, { sequence: 1 },
]) invalid(patch);
for (const patch of [
{ version: 1 as never }, { price: 0 }, { price: 11 }, { price: 1.5 },
{ beforeSpice: 5, afterSpice: 5 }, { afterSpice: 9 }, { afterSpice: 16 },
{ seller: 'c' }, { phase: 4 as never }, { turn: 0 }, { turn: 3 },
{ lot: ' ' }, { round: ' ' }, { auctionIndex: 0 } as never,
{ ownPayment: 6 } as never, { bankSubsidy: 6 } as never,
]) invalid(patch, true);
});
void test('normal bank receipts retain their exact original shape, event and typed signature', () => {
const { g } = original();
const receipt: EmperorNexusBankPurchase = {
kind: 'purchase', event: emperorNexusPurchaseEvent(1, 'h', 0, 2, 6),
owner: 'h', turn: 1, phase: 3, sequence: 0, faction: 'harkonnen', advanced: false,
price: 6, auctionIndex: 2, card: 'historical-bank-card', beforeSpice: 10, afterSpice: 10, signature: '',
};
const expected = JSON.stringify([
'emperorNexusPurchase', 'purchase', receipt.event, 'h', 1, 3, 0, 'harkonnen',
false, 6, 2, 'historical-bank-card', 10, 10,
]);
receipt.signature = signEmperorNexusPurchase(receipt);
assert.equal(receipt.signature, expected);
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
const hybrid = { ...receipt, version: 2, origin: 'cache', seller: 'r', round: 'round', lot: 'lot' } as never;
assert.throws(() => validateEmperorNexusPurchase(g, hybrid), EmperorNexusPurchaseError);
});
+272
View File
@@ -0,0 +1,272 @@
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 {
advanceNexusEmperorSeller, createNexusEmperorSellerFixture, nexusEmperorSellerCards,
nexusEmperorSellerInventory, nexusEmperorSellerPlayer, nextNexusEmperorSellerStep,
stepNexusEmperorSeller, type NexusEmperorSellerFixture,
} from './fixture-nexus-emperor-seller';
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const original = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, original, 'Refusal must precede spending or native lot mutation');
}
function finishOriginalLot(f: NexusEmperorSellerFixture, game: Game): { game: Game; bonuses: number } {
let bonuses = 0;
let inBonus = false;
for (let n = 0; n < 100; n++) {
if (!game.richeseAuction && !game.currentAuctionSale) return { game, bonuses };
const bonus = game.response?.kind === 'harkonnenBonus';
if (bonus && !inBonus) bonuses++;
inBonus = bonus;
assert.ok(!game.decision, 'The original paid/free acquisition cannot reopen buyer confirmation');
game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game));
}
throw Error(`Original ${f.source} acquisition did not reach its source successor`);
}
const scenarios = [
{ advanced: false, source: 'cache' as const },
{ advanced: true, source: 'cache' as const },
{ advanced: true, source: 'blackMarket' as const },
];
for (const scenario of scenarios) for (const use of [false, true]) void test(
`${scenario.advanced ? 'Advanced' : 'Basic'} original ${scenario.source}: ${use ? 'retain spice without seller income' : 'ordinary paid seller income'} and native Harkonnen bonus once`, () => {
const f = createNexusEmperorSellerFixture(scenario), original = structuredClone(f.game);
const offer = viewGame(f.game, f.winner).nexusEmperorSellerPurchase!;
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
for (const rival of f.game.players.filter(player => player.id !== f.winner))
assert.equal(viewGame(f.game, rival.id).nexusEmperorSellerPurchase, null);
const action = { type: 'nexusEmperorSellerPurchase', event: f.event, use };
let game = stepNexusEmperorSeller(f.game, { actor: f.winner, action });
assert.deepEqual(f.game, original);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (use ? 0 : f.price));
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
assert.equal(game.response?.kind, 'harkonnenBonus', 'The existing native bonus cancellation boundary must remain');
assert.equal(game.currentAuctionSale!.origin, f.source);
assert.equal(game.currentAuctionSale!.free, use);
if (use) {
assert.equal(game.currentAuctionSale!.richeseContribution, undefined);
assert.equal(game.currentAuctionSale!.spiceBankerIncomePayment, undefined);
assert.equal(game.currentAuctionSale!.bureaucratPayment, undefined);
assert.equal(game.currentAuctionSale!.bureaucratPaymentEvent, undefined);
}
assert.equal(game.currentAuctionSale!.seller, f.seller);
assert.equal(game.richeseAuction!.event, f.lot);
assert.equal(game.richeseAuction!.cardId, f.card.id);
assert.deepEqual(game.richeseAuction!.outcome, original.richeseAuction!.outcome);
assert.equal(game.pendingNexusEmperorSellerPurchase, null);
assert.equal(game.nexusCards!.cards!.hands[f.winner], use ? null : 'emperor');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, use ? 1 : 0);
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (use ? 0 : f.price));
for (const rival of game.players.filter(player => player.id !== f.winner && player.id !== f.seller))
assert.equal(rival.spice, f.wallets[rival.id], 'There is no bank-funded substitute income');
const bonusCard = game.deck[0];
assert.ok(bonusCard);
const settled = finishOriginalLot(f, game);
game = settled.game;
assert.equal(settled.bonuses, 1);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length,
nexusEmperorSellerPlayer(original, f.winner).hand.length + 2);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === bonusCard.id).length, 1);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
if (f.source === 'cache') {
assert.equal(game.richeseCache!.length, original.richeseCache!.length - 1);
assert.ok(!game.richeseCache!.some(card => card.id === f.card.id));
assert.equal(game.richeseBidding!.stage, 'normal');
assert.ok(game.auction, 'The original reduced ordinary pool follows the first cache lot');
} else {
assert.equal(nexusEmperorSellerPlayer(game, f.seller).hand.length,
nexusEmperorSellerPlayer(original, f.seller).hand.length - 1);
assert.deepEqual(game.richeseCache, original.richeseCache);
assert.equal(game.richeseBidding!.blackMarketSold, true);
assert.equal(game.decision!.kind, 'richeseDeclaration');
}
assert.deepEqual(nexusEmperorSellerCards(game), f.originalCards);
nexusEmperorSellerInventory(game);
if (use) {
const receipt = game.nexusEmperorPurchaseHistory!.at(-1)!;
assert.ok('version' in receipt && receipt.version === 2);
assert.equal(receipt.afterSpice, receipt.beforeSpice);
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
} else {
assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], original.nexusEmperorPurchaseHistory ?? []);
assert.deepEqual(game.nexusEmperorPurchaseEvents ?? [], original.nexusEmperorPurchaseEvents ?? []);
}
rejectsUnchanged(game, f.winner, action);
},
);
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} genuine Richese self-cache purchase retains the original bank destination without bank money`, () => {
const f = createNexusEmperorSellerFixture({ advanced, winnerFaction: 'richese' });
const beforeHand = nexusEmperorSellerPlayer(f.game, f.winner).hand.length;
const original = structuredClone(f.game);
const game = stepNexusEmperorSeller(f.game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
assert.deepEqual(f.game, original);
for (const player of game.players) assert.equal(player.spice, f.wallets[player.id]);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length, beforeHand + 1);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
assert.equal(game.richeseCache!.length, f.game.richeseCache!.length - 1);
assert.equal(game.richeseBidding!.stage, 'normal');
assert.equal(game.response?.kind === 'emperorIncome', false);
assert.equal(game.response?.kind === 'harkonnenBonus', false);
nexusEmperorSellerInventory(game);
},
);
void test('original buyer rejects stale, foreign, malformed and dual-cost actions without spending or source movement', () => {
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket' });
for (const rival of f.game.players.filter(player => player.id !== f.winner))
rejectsUnchanged(f.game, rival.id, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
for (const action of [
{ type: 'nexusEmperorSellerPurchase', event: 'stale', use: true },
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, price: 0 },
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, allyPayment: 1 },
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, card: f.card.id },
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, cost: f.card.id },
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: 'true' },
{ type: 'nexusEmperorSellerPurchase', event: f.event },
{ type: 'decision', event: f.event, use: true },
{ type: 'nexusEmperorPurchase', event: f.event },
]) rejectsUnchanged(f.game, f.winner, action);
assert.equal(f.game.nexusCards!.cards!.hands[f.winner], 'emperor');
nexusEmperorSellerInventory(f.game);
});
void test('a real Emperor holder cannot commit a personally unaffordable native seller bid', () => {
for (const scenario of scenarios) {
const f = createNexusEmperorSellerFixture(scenario);
const before = f.chronology.beforeWinnerBid;
const ownSpice = nexusEmperorSellerPlayer(before, f.winner).spice;
assert.equal(before.nexusCards!.cards!.hands[f.winner], 'emperor');
rejectsUnchanged(before, f.winner, { type: 'richeseBid', event: f.lot, amount: ownSpice + 1 });
assert.equal(nexusEmperorSellerPlayer(before, f.winner).spice, ownSpice);
assert.equal(nexusEmperorSellerPlayer(before, f.winner).hand.some(card => card.id === f.card.id), false);
nexusEmperorSellerInventory(before);
}
});
void test('real irrelevant closing draw has the same public buyer Pay boundary, never an Emperor spend entitlement', () => {
const f = createNexusEmperorSellerFixture({ nexusCard: 'guild' });
assert.equal(f.game.decision!.kind, 'nexusEmperorSellerPurchase');
assert.equal(f.game.decision!.player, f.winner);
assert.equal(!!viewGame(f.game, f.winner).nexusEmperorSellerPurchase?.canUse, false);
rejectsUnchanged(f.game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
const settled = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } })).game;
assert.equal(nexusEmperorSellerPlayer(settled, f.winner).spice, f.wallets[f.winner] - f.price);
assert.equal(nexusEmperorSellerPlayer(settled, f.seller).spice, f.wallets[f.seller] + f.price);
assert.equal(settled.nexusCards!.cards!.hands[f.winner], 'guild');
nexusEmperorSellerInventory(settled);
});
void test('naturally setup-dealt Truthtrance suspends the original seller purchase and restores its exact event before Use', () => {
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', truthtrance: true });
const card = nexusEmperorSellerPlayer(f.game, 'choam').hand.find(held => held.effect === 'truthtrance');
assert.ok(card);
assert.ok(nexusEmperorSellerPlayer(f.chronology.afterSetup, 'choam').hand.some(held => held.id === card.id));
let game = stepNexusEmperorSeller(f.game, { actor: 'choam', action: { type: 'card', card: card.id } });
assert.equal(game.truthtrance!.stage, 'priority');
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
rejectsUnchanged(game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
game = stepNexusEmperorSeller(game, { actor, action: { type: 'truthPass' } });
}
const name = nexusEmperorSellerPlayer(game, f.winner).hand[0].name;
game = stepNexusEmperorSeller(game, { actor: 'choam', action: { type: 'truthAsk',
question: { kind: 'fact', target: f.winner, fact: { kind: 'hand', name } } } });
assert.equal(viewGame(game, f.winner).truthAnswer, 'yes');
game = stepNexusEmperorSeller(game, { actor: f.winner, action: { type: 'truthAnswer', answer: 'yes' } });
assert.equal(game.truthtrance, null);
assert.equal(game.decision!.kind, 'nexusEmperorSellerPurchase');
assert.equal(game.decision!.event, f.event);
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
assert.equal(game.discard.filter(held => held.id === card.id).length, 1);
game = finishOriginalLot(f, stepNexusEmperorSeller(game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller]);
nexusEmperorSellerInventory(game);
});
void test('minimal original Easy bot can legally resolve both genuine buyer entitlements', () => {
for (const nexusCard of ['emperor', 'guild'] as const) {
const f = createNexusEmperorSellerFixture({ nexusCard });
const view = viewGame(f.game, f.winner);
view.players.find(player => player.id === f.winner)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action, 'The actual paused buyer needs a legal bot continuation');
const game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner, action })).game;
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (nexusCard === 'emperor' ? 0 : f.price));
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (nexusCard === 'emperor' ? 0 : f.price));
nexusEmperorSellerInventory(game);
}
});
void test('a retained original receipt remains historical through the next genuine turn and closing phases', () => {
const f = createNexusEmperorSellerFixture();
let game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
const receipt = structuredClone(game.nexusEmperorPurchaseHistory!.at(-1));
game = advanceNexusEmperorSeller(game, state => state.turn === 3 && state.phase === 2 &&
!state.phaseOpening && !state.response && !state.decision);
assert.deepEqual(game.nexusEmperorPurchaseHistory!.at(-1), receipt);
for (const player of game.players) assert.equal(viewGame(game, player.id).turn, 3);
nexusEmperorSellerInventory(game);
});
for (const betrayal of ['pass', 'bank'] as const) {
void test(`original Richese reaction ${betrayal} precedes price retention without inventing a later payment`, () => {
const f = createNexusEmperorSellerFixture({ advanced: true, betrayal });
const before = structuredClone(f.game.players);
assert.equal(f.game.richeseBetrayal?.completed.at(-1)?.stage, betrayal === 'bank' ? 'used' : 'passed');
const free = stepNexusEmperorSeller(f.game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
for (const p of free.players) assert.equal(p.spice, before.find(old => old.id === p.id)!.spice);
const settled = finishOriginalLot(f, free);
assert.equal(settled.bonuses, 1);
assert.equal(nexusEmperorSellerPlayer(settled.game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length,
betrayal === 'bank' ? 1 : 0);
nexusEmperorSellerInventory(settled.game);
});
}
void test('Pay after original Richese bank diversion debits the buyer but pays no seller', () => {
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', betrayal: 'bank' });
const paid = stepNexusEmperorSeller(f.game, { actor: f.winner,
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } });
assert.equal(nexusEmperorSellerPlayer(paid, f.winner).spice, f.wallets[f.winner] - f.price);
assert.equal(nexusEmperorSellerPlayer(paid, f.seller).spice, f.wallets[f.seller]);
assert.equal(paid.nexusCards!.cards!.hands[f.winner], 'emperor');
const settled = finishOriginalLot(f, paid);
assert.equal(settled.bonuses, 1);
nexusEmperorSellerInventory(settled.game);
});
void test('original Richese self-purchase discard occurs before any Emperor Nexus disposal or acquisition', () => {
const f = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese', betrayal: 'before' });
let game = f.game;
while (game.pendingRicheseBetrayal) {
const receipt = game.pendingRicheseBetrayal.receipt;
const actor = receipt.required.find(id => !receipt.passed.includes(id))!;
game = stepNexusEmperorSeller(game, { actor, action: {
type: actor === 'guild' ? 'richeseBetrayalUse' : 'richeseBetrayalPass', event: receipt.event,
} });
}
assert.equal(game.pendingNexusEmperorSellerPurchase ?? null, null);
assert.equal(game.nexusCards!.cards!.hands[f.winner], 'emperor');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.some(card => card.id === f.card.id), false);
assert.equal(game.discard.filter(card => card.id === f.card.id).length, 1);
for (const p of game.players) assert.equal(p.spice, f.wallets[p.id]);
nexusEmperorSellerInventory(game);
});
+9 -4
View File
@@ -14,11 +14,16 @@ function reject(g: Game,id: string,action: Action) {
assert.throws(() => applyAction(g,id,action));
assert.equal(JSON.stringify(g),before);
}
function payOriginalSeller(g: Game): Game {
const frame = g.pendingNexusEmperorSellerPurchase;
return frame ? applyAction(g, frame.winner,
{ type: 'nexusEmperorSellerPurchase', event: frame.event, use: false }) : g;
}
function passes(g: Game): Game {
const receipt = g.pendingRicheseBetrayal!.receipt;
for (const id of receipt.required)
g = applyAction(g,id,{type:'richeseBetrayalPass',event:receipt.event});
return g;
return payOriginalSeller(g);
}
function census(g: Game): string[] {
return [...g.deck,...g.discard,...(g.richeseCache ?? []),...(g.richeseRemoved ?? []),
@@ -71,7 +76,7 @@ void test('cache and concealed Black Market diversion preserve original ally spl
const before = reload(f.game), bonus = before.deck[0].id;
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.ownPayment,4);
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.donor,f.ally);
const done = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event});
const done = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4);
assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice);
assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2);
@@ -195,7 +200,7 @@ void test('initializer admits only explicit fresh CHOAM/Richese Nexus compositio
void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => {
const f = createRicheseBetrayalFixture('sale',{bonusCounter:true});
const before = reload(f.game);
const paid = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event});
const paid = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
assert.equal(paid.response?.kind,'harkonnenBonus');
assert.equal(paid.currentAuctionSale!.recipient,'bank');
assert.equal(player(paid,f.target).spice,player(before,f.target).spice);
@@ -229,7 +234,7 @@ void test('Richese can fund its buyer from escrow with no unpledged spice, with
const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2,allyIsSeller:true,allySpice:2});
assert.equal(player(f.game,f.target).spice,0);
assert.equal(f.game.aid[f.target].amount,2);
const done = use ? applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event}) : passes(reload(f.game));
const done = use ? payOriginalSeller(applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event})) : passes(reload(f.game));
assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4);
// The authorized Advanced source sends Richese's own contribution to the
// Emperor or, with no Emperor seated, to the Bank: the seller receives only