Files
Dune/tests/fixture-nexus-emperor-seller.ts
T

210 lines
13 KiB
TypeScript

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 } };
}