import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { treacheryDeck } from '../game/cards'; import type { MixedActionExpression } from '../game/mixed-action-question'; import { traitorDeck } from '../game/traitors'; import type { TruthFact, TruthQuestion } from '../game/truthtrance'; import { windowCardOpportunity } from '../game/window-card-play'; import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; import { actTruthTurnThumper, nextTruthTurnThumperNative, truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice, truthTurnThumperTraitors, type TruthTurnThumperLeaf, type TruthTurnThumperStep } from './fixture-truth-turn-thumper'; export type TruthTurnAmalLeaf = TruthTurnThumperLeaf | { kind: 'windowCardPlay'; effect: 'amal'; source: string }; export type TruthTurnAmalExpression = MixedActionExpression; export type TruthTurnAmalStep = TruthTurnThumperStep; export const actTruthTurnAmal = actTruthTurnThumper; export const truthTurnAmalPlayer = truthTurnThumperPlayer; export const truthTurnAmalInventory = truthTurnThumperInventory; export const truthTurnAmalTraitors = truthTurnThumperTraitors; export const truthTurnAmalSpice = truthTurnThumperSpice; /** Quiet original opening remains available; only temporary interactions stop. */ export const truthTurnAmalQuiet = (game: Game) => !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment; export const truthTurnAmalClean = (game: Game) => truthTurnAmalQuiet(game) && !game.phaseOpening; export const truthTurnAmalFact = (fact: TruthFact): TruthTurnAmalExpression => ({ kind: 'fact', fact }); export const truthTurnAmalPlay = (source: string): TruthTurnAmalExpression => ({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'amal', source } }); export const truthTurnAmalPayment = (recipient: string, minimum = 5): TruthTurnAmalExpression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } }); export const truthTurnAmalGroup = (kind: 'and' | 'or', ...terms: TruthTurnAmalExpression[]): TruthTurnAmalExpression => ({ kind, terms }); export const nextTruthTurnAmalNative = nextTruthTurnThumperNative; export function nextTruthTurnAmal(game: Game): TruthTurnAmalStep | null { if (game.truthtrance) return null; for (const player of game.players) { const guide = viewGame(game, player.id).turnActionGuide; if (guide) return { actor: player.id, action: guide.action }; } return nextTruthTurnAmalNative(game); } export function advanceTruthTurnAmal(game: Game, until: (state: Game) => boolean, actions: TruthTurnAmalStep[] = [], nativeOnly = false): Game { for (let count = 0; count < 1900; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The original Amal source ended before its native boundary'); const step = nativeOnly ? nextTruthTurnAmalNative(game) : nextTruthTurnAmal(game); assert.ok(step, 'Supply the actual Truthtrance question or private Battle Plan'); game = actTruthTurnAmal(game, step.actor, step.action, actions); } throw Error('The genuine Amal chronology did not reach its finite boundary'); } export function settleTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game { return advanceTruthTurnAmal(game, truthTurnAmalQuiet, actions, true); } /** Original public selected-Ix H/A/E. Only the actual unopened deal and safe * unopened Spice prefix are selected. Every requested opening is opened by * real setup, dials, passes, readiness and original movement endings. */ export function createTruthTurnAmalFixture(options: { phase?: 0 | 1 | 8; advanced?: boolean; incoming?: 0 | 4 } = {}) { const phase = options.phase ?? 0, advanced = options.advanced ?? false, incoming = options.incoming ?? 0; assert.ok(!incoming || phase === 8, 'The real incoming bribe is earned in the preceding phase7'); const asker = 'harkonnen', observer = 'atreides', target = 'emperor', recipient = asker; let game = createGame('TRUTHTURNAMAL', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']); joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(target, 'Emperor', 'emperor')); const initial = structuredClone(game), actions: TruthTurnAmalStep[] = []; const act = (actor: string, action: Action) => { game = actTruthTurnAmal(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnAmal(game, until, actions, true); }; for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0)); const originalCards = truthTurnAmalInventory(game), originalTraitors = truthTurnAmalTraitors(game), originalSpice = truthTurnAmalSpice(game); assert.deepEqual(originalCards, treacheryDeck(['ix']).map(card => card.id).sort()); assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort()); const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2); const amal = game.deck.find(card => card.effect === 'amal'), filler = game.deck.find(card => card.name === 'Baliset'); assert.ok(amal && filler); const deal = [...truths.map(card => card.id), filler.id, amal.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 }; }); const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); assert.equal(game.phase, 0); assert.ok(game.phaseOpening); let spiceOrder: ReturnType = []; let collection: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null; let incomingReceipt: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null; if (phase === 1) reach(state => state.phase === 1 && !!state.phaseOpening && truthTurnAmalQuiet(state)); if (phase === 8) { reach(beforeIxSpiceDraw); spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(advanced ? [{ territory: 'broken_land' }] : [])]); reach(state => state.phase === 7 && !!state.phaseOpening && truthTurnAmalQuiet(state)); const beforeCollection = structuredClone(game), collectAt = actions.length; reach(state => state.phase === 7 && truthTurnAmalClean(state)); collection = { before: beforeCollection, steps: structuredClone(actions.slice(collectAt)), after: structuredClone(game) }; if (incoming) { const before = structuredClone(game), from = actions.length; act(asker, { type: 'bribe', target, amount: incoming }); incomingReceipt = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming); } reach(state => state.phase === 8 && !!state.phaseOpening && truthTurnAmalQuiet(state)); } assert.equal(game.turn, 1); assert.equal(game.phase, phase); assert.ok(game.phaseOpening); assert.deepEqual(game.phaseOpening.passed, []); assert.equal(truthTurnAmalPlayer(game, target).spice, 10); assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming); assert.deepEqual(truthTurnAmalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id)); assert.deepEqual(truthTurnAmalPlayer(game, target).hand.map(card => card.id), [amal.id]); assert.deepEqual(truthTurnAmalInventory(game), originalCards); assert.deepEqual(truthTurnAmalTraitors(game), originalTraitors); assert.deepEqual(truthTurnAmalSpice(game), originalSpice); const opportunity = windowCardOpportunity(game, 'amal'); assert.ok(opportunity); const source = opportunity.source; assert.equal(source, `phase-opening:1:${phase}`); const mixed = truthTurnAmalGroup('and', truthTurnAmalPlay(source), truthTurnAmalPayment(recipient)); const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion; const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnAmalPlayer(game, target).spice }; return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, asker, target, recipient, observer, phase, advanced, incoming, collection, incomingReceipt, truths: truths.map(card => card.id), truth: truths[0].id, card: amal.id, amal: amal.id, effect: 'amal' as const, source, opportunity, mixed, question, spiceFact, nativeAction: { type: 'card', card: amal.id } satisfies Action, payment: { type: 'bribe', target: recipient, amount: 5 } satisfies Action, dealOrder, spiceOrder, originalCards, originalTraitors, originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length, sourceCounts: { selected: actions.length, originalSetup: actions.findLastIndex(step => step.action.type === 'traitor') + 1, actualCollection: collection?.steps.length ?? 0, actualIncomingPayment: incomingReceipt?.steps.length ?? 0 }, sourceDisclosure: 'Actual public Harkonnen/Atreides/Emperor selected-Ix setup: H receives both original Truthtrances, A Baliset and E original Amal through the unopened native deal. ' + `Only original unopened deal/Spice positions and scalar lotteries are selected. Real setup and original native controls open turn1 phase${phase}; other original openings are passed without Amal. ` + (phase === 8 ? advanced ? 'Original Harkonnen Carthag and Atreides Arrakeen each actually earn their Advanced two-spice city income during Collection; Emperor has no city income. ' : 'Actual Basic Collection gives no automatic city income, and no original army occupies the selected Spice blow. ' : '') + (incoming ? 'H actually pays E four spice during quiet phase7; that known receipt stays queued through the original Mentat opening until actual initialization. ' : '') + 'Early-opening funding examples use owned current resources; absolute post-halving limits use the original Mentat opening after Charity and Bidding. ' + 'Owner cash is the original ten before the requested opening. No hand, wallet, ready/pass list, phase, turn, body, income, halving or promise is installed. All counts derive from actual action arrays.', }; } export type TruthTurnAmalFixture = ReturnType; export function declareTruthTurnAmal(f: TruthTurnAmalFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) { let game = actTruthTurnAmal(source, f.asker, { type: 'card', card: f.truths[copy] }, actions); while (game.truthtrance?.stage === 'priority') { const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); game = actTruthTurnAmal(game, actor.id, { type: 'truthPass' }, actions); } assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions }; } export function askTruthTurnAmal(game: Game, asker: string, target: string, mixed: TruthTurnAmalExpression, actions: TruthTurnAmalStep[] = []): Game { return actTruthTurnAmal(game, asker, { type: 'truthAsk', question: { kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed) } }, actions); } export function answerTruthTurnAmal(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthTurnAmalStep[] = []): Game { return actTruthTurnAmal(game, target, { type: 'truthAnswer', answer }, actions); } export function bindTruthTurnAmal(f: TruthTurnAmalFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) { const declared = declareTruthTurnAmal(f, source, copy, actions), asked = askTruthTurnAmal(declared.game, f.asker, f.target, mixed, actions); return { game: answerTruthTurnAmal(asked, f.target, answer, actions), declared, asked, actions }; } export function truthTurnAmalPromise(game: Game, target = game.turnActionPromises![0].player) { const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row; } export function closeTruthTurnAmal(game: Game, f: Pick, actions: TruthTurnAmalStep[] = []): Game { return advanceTruthTurnAmal(game, state => truthTurnAmalQuiet(state) && !windowCardOpportunity(state, 'amal', f.source), actions, true); } export function finishTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game { const turn = game.turn; return advanceTruthTurnAmal(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening && truthTurnAmalQuiet(state), actions); }