Files
Dune/tests/fixture-truthtrance-early-advanced-shipment.ts
T

187 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actFutureShipment, advanceFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
type FutureShipmentStep,
} from './fixture-truthtrance-future-shipment';
export type TruthtranceEarlyAdvancedShipmentPhase = 'storm' | 'charity' | 'bidding';
export type TruthtranceEarlyAdvancedShipmentStep = FutureShipmentStep;
export const actTruthtranceEarlyAdvancedShipment = actFutureShipment;
export const nextTruthtranceEarlyAdvancedShipmentStep = nextFutureShipmentStep;
export const nextTruthtranceEarlyAdvancedShipment = nextFutureShipmentStep;
export const advanceTruthtranceEarlyAdvancedShipment = advanceFutureShipment;
export const truthtranceEarlyAdvancedShipmentPlayer = futureShipmentPlayer;
export const truthtranceEarlyAdvancedShipmentClean = futureShipmentClean;
export function truthtranceEarlyAdvancedShipmentInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original base33 Treachery Card retains one physical slot'); return ids;
}
export const truthtranceEarlyAdvancedShipmentPhysical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
ready: game.ready, movementRemaining: game.movementRemaining,
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
elites: player.elites, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved })),
});
/** Actual public Advanced classic start and original five-card deal. Only
* unopened starting/first-auction/dual-Spice positions and scalar lotteries
* are selected. Stop before Truthtrance; no played state is installed. */
export function createTruthtranceEarlyAdvancedShipmentFixture(options: {
phase?: TruthtranceEarlyAdvancedShipmentPhase;
} = {}) {
const phase = options.phase ?? 'charity', phaseNumber = phase === 'storm' ? 0 : phase === 'charity' ? 2 : 3;
const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen', guild = 'guild';
let game = createGame('TRUTHEARLYADVANCEDSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), true);
for (const faction of [target, counterHolder, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = truthtranceEarlyAdvancedShipmentInventory(game); assert.equal(originalInventory.length, 33);
const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
const baliset = game.deck.find(card => card.name === 'Baliset'), shield = game.deck.find(card => card.kind === 'shield'),
snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && karama && baliset && shield && snooper);
const deal = [truth.id, baliset.id, karama.id, shield.id, snooper.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
// A genuinely bought ordinary weapon cannot introduce a second Karama
// coupon into the respondent's later funding witness.
const ordinaryLot = game.deck.find(card => !deal.includes(card.id) && card.kind === 'projectile' && !card.effect);
assert.ok(ordinaryLot);
const auctionFrom = game.deck.findIndex(card => card.id === ordinaryLot.id); assert.ok(auctionFrom >= deal.length);
[game.deck[deal.length], game.deck[auctionFrom]] = [game.deck[auctionFrom], game.deck[deal.length]];
const auctionOrder = { position: deal.length, from: auctionFrom, card: ordinaryLot.id };
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(game);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === phaseNumber && futureShipmentClean(state) &&
(phaseNumber !== 3 || !!state.auction));
const respondent = futureShipmentPlayer(game, target);
assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20); assert.equal(respondent.tanks, 0);
assert.deepEqual(respondent.forces, {}); assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0);
assert.ok(futureShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.ok(futureShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
assert.ok(respondent.hand.some(card => card.id === baliset.id));
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined);
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), originalInventory);
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 };
const declarationStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 8, elite: 0, allyPayment: 0 };
return { game, source: structuredClone(game), phase, phaseNumber, advanced: true as const,
asker, target, counterHolder, guild, card: truth.id, truth: truth.id, karama: karama.id, baliset: baliset.id,
question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
shipment, bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
bigBid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
bigBribe: { type: 'bribe', target: asker, amount: 3 } satisfies Action,
purchaseCost: 2, shipmentCost: 8, purchaseCard: ordinaryLot.id,
initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
beforeSpiceOrder, spiceOrder, deal, dealOrder, auctionOrder, originalSpice,
originalInventory, originalTreachery: originalInventory,
physical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
originalPhysical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Actual public Advanced A/E/H/G ready/start with advancedPreview:true, original base33 and no expansion or optional module. Every private Traitor and original setup control is an actual native action.',
'Select five original unopened starting-card positions: A Truthtrance, E Baliset, H printed Karama plus Shield, G Snooper. Select one still-unopened ordinary first-auction weapon and two original ordinary Spice positions; physical inventory remains all33 originals.',
phase === 'storm'
? 'Stop at clean original first Storm before its actual secret dials. The proposed commitment uses the current board, not an assumed future Storm outcome.'
: phase === 'charity'
? 'Actually complete first Storm dials and both Advanced Spice blows, then stop at clean original Charity before Bidding.'
: 'Actually complete first Storm, both Advanced Spice blows and Charity plus native auction-peek responses, then stop at the clean first ordinary lot before any bid.',
'E retains its actual printed ten spice, twenty native reserves, no board forces and unused shipment. Truthtrance declaration and eight-force Carthag question are returned unplayed. No played hand, wallet, army, clock, promise or response is installed.',
'The optional continuation genuinely buys the original weapon for two, uses actual Guild TAKE or WAIT/allowance controls, and ships at E actual native turn. It resolves original Guild interception/income and ends only E original turn, without a fabricated early shipment.',
],
};
}
export type TruthtranceEarlyAdvancedShipmentFixture = ReturnType<typeof createTruthtranceEarlyAdvancedShipmentFixture>;
/** Native paid purchase, original Guild timing, and exact reserve delivery.
* The caller has already asked/answered; this helper creates no promise. */
export function finishTruthtranceEarlyAdvancedShipment(f: TruthtranceEarlyAdvancedShipmentFixture, source: Game,
options: { guildTake?: boolean; amount?: number } = {}) {
const amount = options.amount ?? 8, take = options.guildTake ?? false;
let game = source;
const actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
assert.equal(game.truthtrance ?? null, null);
reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
assert.equal(game.auction?.index, 0); assert.equal(game.auction?.cards[0].id, f.purchaseCard);
const purchase = transition(() => {
act(f.target, f.bid);
reach(state => state.phase === 4 && futureShipmentClean(state));
});
assert.equal(futureShipmentPlayer(purchase.before, f.target).spice, 10);
assert.equal(futureShipmentPlayer(game, f.target).spice, 8);
assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const timing: ReturnType<typeof transition>[] = [];
const guildTurn: ReturnType<typeof transition>[] = [];
const chooseTiming = (choice: boolean) => {
assert.equal(game.decision?.kind, 'guildTiming');
timing.push(transition(() => {
act(f.guild, { type: 'decision', take: choice });
reach(state => futureShipmentClean(state));
}));
};
chooseTiming(take);
if (take) {
assert.equal(game.active, f.guild);
guildTurn.push(transition(() => { act(f.guild, { type: 'endMovement' }); }));
}
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.asker);
const askerEnd = transition(() => { act(f.asker, { type: 'endMovement' }); });
if (!take) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
chooseTiming(false);
}
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
const beforeShipment = structuredClone(game), shipmentAction: Action = { ...f.shipment, amount };
const start = actions.length;
act(f.target, shipmentAction);
const declared = structuredClone(game), originalControls: Game[] = [];
for (let count = 0; !futureShipmentClean(game) || !!game.pendingShipment; count++) {
assert.ok(count < 100, 'Resolve only the original shipment permission/payment/income');
originalControls.push(structuredClone(game));
const step = nextFutureShipmentStep(game); assert.ok(step);
act(step.actor, step.action);
}
const delivered = structuredClone(game), shipment = { before: beforeShipment, action: shipmentAction,
declared, controls: originalControls, steps: structuredClone(actions.slice(start)), after: delivered };
assert.equal(futureShipmentPlayer(game, f.target).forces[f.location], amount);
assert.equal(futureShipmentPlayer(game, f.target).reserves, 20 - amount);
assert.equal(futureShipmentPlayer(game, f.target).spice, 8 - amount);
const end = transition(() => { act(f.target, { type: 'endMovement' }); });
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), f.originalInventory);
return { game, purchase, timing, guildTurn, askerEnd, beforeShipment, shipmentAction, shipment, declared,
delivered, end, actions, amount, guildTake: take };
}