import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { richeseCards } from '../game/richese-cards'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho); export const EXTORTION_SAPHO = sapho.id; export type SaphoExtortionStep = { actor: string; action: Action }; export const saphoExtortionPlayer = (game: Game, actor = 'emperor') => { const player = game.players.find(player => player.id === actor); assert.ok(player); return player; }; export const saphoExtortionClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; export const saphoExtortionPending = (game: Game) => !game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard && game.decision?.kind === 'moritaniExtortion'; export function actSaphoExtortion(game: Game, actor: string, action: Action, actions: SaphoExtortionStep[] = []): Game { const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); actions.push({ actor, action: structuredClone(action) }); return next; } /** Native optional controls are declined; the source programme owns every * Extortion reveal, placement and payment instead of inventing those choices. */ export function nextSaphoExtortionStep(game: Game): SaphoExtortionStep | null { if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { const decision = game.decision; if (decision?.kind === 'moritaniTerror' || decision?.kind === 'moritaniExtortion') return null; if (decision?.kind === 'moritaniSetup') return { actor: decision.player, action: { type: 'decision', territory: 'carthag', sector: 11 } }; if (decision?.kind === 'moritaniPlacement') return { actor: decision.player, action: { type: 'decision', decline: true } }; if (decision?.kind === 'moritaniAssassinate') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } }; if (decision?.kind === 'richeseCache') { const card = game.richeseCache!.find(card => card.id === EXTORTION_SAPHO) ?? game.richeseCache![0]; assert.ok(card); return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, card: card.id, method: 'onceAround', direction: 'clockwise' } }; } if (decision?.kind === 'richeseUnbid') return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; } const lot = game.richeseAuction; if (saphoExtortionClean(game) && lot?.cardId === EXTORTION_SAPHO && !lot.outcome && lot.active === 'emperor' && lot.bid === 0) return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } }; return nextRicheseStrongholdsNativeStep(game); } export function advanceSaphoExtortion(game: Game, until: (state: Game) => boolean, actions: SaphoExtortionStep[] = []): Game { for (let count = 0; count < 2400; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The original Extortion programme ended before its next control'); const step = nextSaphoExtortionStep(game); assert.ok(step, 'Choose the actual owned Extortion control before advancing'); game = actSaphoExtortion(game, step.actor, step.action, actions); } throw Error('The original Sapho/Extortion programme did not reach its finite boundary'); } function orderOriginalLand(game: Game) { const front: Game['spiceDeck'] = []; for (let count = 0; count < (game.advanced ? 2 : 1); count++) { const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]); } game.spiceDeck.unshift(...front); } export function saphoExtortionAnswer(game: Game, pay: boolean): SaphoExtortionStep { const decision = game.decision; assert.ok(decision?.kind === 'moritaniExtortion'); return { actor: decision.player, action: { type: 'decision', event: decision.event, pay } }; } /** Public original setup, actual paid cache1 Sapho, first native Mentat Terror * placement, paid foreign entry and reveal, then another actual placement * before bank5. The original seat/join order leaves four native payers; an * actual Atreides decline leaves the holder inside the unasked suffix. * Only unopened Spice positions and original scalar lotteries are controlled. * No dealt card, physical counter, wallet, clock, queue or receipt is installed. */ export function createSaphoExtortionFixture(options: { advanced?: boolean; priorDecline?: boolean } = {}) { const advanced = options.advanced ?? false; let game = createGame('SAPHOEXTORTION', newPlayer('richese', 'Richese', 'richese'), advanced, ['ecaz', 'choam']); for (const faction of ['atreides', 'guild', 'emperor', 'moritani'] as const) joinGame(game, newPlayer(faction, faction, faction)); const actions: SaphoExtortionStep[] = []; const act = (actor: string, action: Action) => { game = actSaphoExtortion(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceSaphoExtortion(game, until, actions); }; 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); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); orderOriginalLand(game); reach(state => saphoExtortionPlayer(state).hand.some(card => card.id === EXTORTION_SAPHO)); const acquired = structuredClone(game); reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement'); const token = game.moritaniTerror!.tokens.find(token => token.kind === 'extortion' && token.status === 'available'); assert.ok(token); const beforePlacement = structuredClone(game), placement: SaphoExtortionStep = { actor: 'moritani', action: { type: 'decision', token: token.id, territory: 'habbanya_ridge_sietch', } }; act(placement.actor, placement.action); reach(state => state.turn === 2 && state.phase === 1 && saphoExtortionClean(state)); const placed = structuredClone(game); orderOriginalLand(game); reach(state => state.phase === 5 && state.active === 'emperor' && saphoExtortionClean(state)); const beforeEntry = structuredClone(game), entry: SaphoExtortionStep = { actor: 'emperor', action: { type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 1, elite: 0, allyPayment: 0, } }; act(entry.actor, entry.action); reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror'); const offered = structuredClone(game), reveal: SaphoExtortionStep = { actor: 'moritani', action: { type: 'decision', reveal: true } }; act(reveal.actor, reveal.action); reach(saphoExtortionClean); const revealed = structuredClone(game); reach(state => state.turn === 2 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement'); const beforeMentatPlacement = structuredClone(game); const later = game.moritaniTerror!.tokens.find(token => token.kind === 'sabotage' && token.status === 'available'); assert.ok(later); const mentatPlacement: SaphoExtortionStep = { actor: 'moritani', action: { type: 'decision', token: later.id, territory: 'sietch_tabr', } }; act(mentatPlacement.actor, mentatPlacement.action); reach(saphoExtortionPending); const collected = structuredClone(game), declines: { before: Game; step: SaphoExtortionStep; after: Game }[] = []; if (options.priorDecline !== false) { const before = structuredClone(game), step = saphoExtortionAnswer(game, false); assert.equal(step.actor, 'atreides', 'The original turn-two Storm/join order supplies the completed prefix'); act(step.actor, step.action); declines.push({ before, step, after: structuredClone(game) }); } const queue = game.moritaniExtortion!; assert.ok(queue.queue.slice(queue.cursor).includes('emperor')); assert.notEqual(queue.queue[queue.cursor], 'emperor'); assert.notEqual(queue.queue.at(-1), 'emperor'); return { game, lobby, setup, afterSetup, acquired, beforePlacement, placement, placed, beforeEntry, entry, offered, reveal, revealed, beforeMentatPlacement, mentatPlacement, collected, declines, owner: 'emperor', moritani: 'moritani', card: EXTORTION_SAPHO, token: token.id, laterToken: later.id, advanced, priorDecline: options.priorDecline !== false, sourceActionCount: actions.length, actions, staging: ['Only unopened land Spice positions and original scalar lotteries are selected. Public join order Richese/Atreides/Guild/Emperor/Moritani, original setup, actual cache1, initial native Extortion placement, paid foreign1 entry, revelation and later placement-before-bank5 create the real payer queue. No postdeal custody, counters, wallets, clocks, payer order or receipt is assigned.'] }; } export type SaphoExtortionFixture = ReturnType; export function saphoExtortionAction(game: Game, mode: 'first' | 'last'): Action { const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'extortion' && option.mode === mode); assert.ok(option, `The actual pending unasked suffix needs a meaningful Sapho-${mode} option`); return { type: 'card', card: EXTORTION_SAPHO, ...option }; } /** Each answer uses the actual current native payer and current event. The * first real payment ends offers; an all-decline branch returns the real token * through the native hidden-supply rotation. */ export function completeSaphoExtortion(fixture: SaphoExtortionFixture, mode: 'first' | 'last', payOwner = false) { let game = structuredClone(fixture.game); const actions: SaphoExtortionStep[] = [], answers: { before: Game; step: SaphoExtortionStep; after: Game }[] = []; const previous = saphoExtortionAnswer(game, false), action = saphoExtortionAction(game, mode); game = actSaphoExtortion(game, fixture.owner, action, actions); // Public Basic Richese now keeps its actual Semuta discard opportunity. // Complete those native all-seat passes before answering the same payer. if (game.pendingTreacheryDiscard) game = advanceSaphoExtortion(game,saphoExtortionPending,actions); const ordered = structuredClone(game); for (let count = 0; game.moritaniExtortion && count < fixture.game.players.length; count++) { assert.ok(saphoExtortionPending(game)); const before = structuredClone(game), step = saphoExtortionAnswer(game, payOwner && game.decision!.player === fixture.owner); game = actSaphoExtortion(game, step.actor, step.action, actions); answers.push({ before, step, after: structuredClone(game) }); } assert.equal(game.moritaniExtortion, undefined, 'The real Extortion obligation settles finitely'); return { game, action, previous, ordered, answers, actions, mode, payOwner }; }