import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { TERRITORIES } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { advance as advanceKullSemuta, inventory, player } from './fixture-kull-richese-semuta'; import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama'; export { inventory, player }; export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); /** Public Basic setup with native CHOAM/Richese. Original unopened starting * cards and a safe unopened Spice position are selected. Richese purchases the * physical Semuta for one spice; Emperor and BG or Atreides actually ship into * the same unoccupied stronghold. No played force, purse, phase or battle is installed. */ export function createKullBattleSemutaFixture(options: { power?: 'voice' | 'prescience'; preReady?: boolean } = {}) { const power = options.power ?? 'voice'; const owner = power === 'voice' ? 'beneGesserit' : 'atreides'; let game = createGame('KULLBATTLESEMUTA', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); for (const faction of ['richese', 'emperor', owner, 'harkonnen'] as const) joinGame(game, newPlayer(faction, faction, faction)); game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true }); for (const seat of game.players) game = act(game, seat.id, { type: 'ready' }); game = act(game, game.host, { type: 'start' }); assert.equal(game.status, 'setup'); assert.ok(game.semutaPreview && game.kullPreview); const originalCards = inventory(game), selected: Game['deck'] = []; for (const kind of ['kull', 'filler', 'karama', 'filler', 'karama'] as const) { const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : kind === 'karama' ? card.effect === 'karama' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad'); assert.ok(index >= 0, `The original unopened ${kind} must exist.`); selected.push(game.deck.splice(index, 1)[0]); } game.deck.unshift(...selected); game = advanceKullSemuta(game, state => state.status === 'playing'); const [kull, , karama, , counter] = selected.map(card => card.id); assert.ok(player(game, 'choam').hand.some(card => card.id === kull)); assert.ok(player(game, 'emperor').hand.some(card => card.id === karama)); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === counter)); const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]); game = advanceKullSemuta(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) && !state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard); assert.ok(player(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID)); let target: { territory: string; sector: number } | null = null; for (let count = 0; count < 1000 && game.phase !== 6; count++) { if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingKull && game.active) { const actor = game.active, seat = player(game, actor); if ((actor === 'emperor' || actor === owner) && !seat.shipped) { if (!target) { const territory = TERRITORIES.find(t => t.type === 'stronghold' && t.sectors.some(s => s !== game.storm) && game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`)))); assert.ok(territory, 'A safe empty printed stronghold must be available.'); target = { territory: territory.id, sector: territory.sectors.find(sector => sector !== game.storm)! }; } game = act(game, actor, { type: 'ship', territory: target.territory, sector: target.sector, amount: 1, elite: 0, allyPayment: 0 }); } else game = act(game, actor, { type: 'endMovement' }); continue; } const next = nextSemutaRicheseCacheKarama(game); assert.ok(next, `Complete the actual source control at ${game.phase}/${game.decision?.kind}.`); game = act(game, next.actor, next.action); } assert.equal(game.phase, 6); assert.ok(target); assert.equal(player(game, 'emperor').forces[`${target.territory}:${target.sector}`], 1); assert.equal(player(game, owner).forces[`${target.territory}:${target.sector}`], 1); // A native chooser owns this real battle; other seats' unspent choices are // resolved by the same production policy before making the selection. for (let count = 0; count < 30 && !game.battle; count++) { const view = game.active ? viewGame(game, game.active) : null; const targets = view?.battleChoices?.find(choice => choice.territory === target!.territory && (choice.chooser ?? choice.attacker) === game.active && [choice.attacker, choice.defender].includes(game.active!)); if (targets) { const opponent = game.active === 'emperor' ? owner : 'emperor'; game = act(game, game.active!, { type: 'chooseBattle', territory: target.territory, target: opponent }); break; } const next = nextSemutaRicheseCacheKarama(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.ok(game.battle); for (let count = 0; count < 30 && game.battle?.preparation?.kind !== power; count++) { const next = nextSemutaRicheseCacheKarama(game); assert.ok(next); game = act(game, next.actor, next.action); } assert.equal(game.battle?.preparation?.kind, power); if (options.preReady) { const pre = game.battle!.preLeader; assert.ok(pre && !pre.closed); game = act(game, 'emperor', { type: 'battlePreparationReady', event: pre.event }); assert.deepEqual(game.battle!.preLeader!.ready, ['emperor']); assert.equal(game.battle!.preLeader!.closed, false); } game = act(game, owner, power === 'voice' ? { type: 'voice', kind: 'shield', must: false } : { type: 'prescience', field: 'dial' }); assert.equal(game.response?.kind, power); assert.deepEqual(inventory(game), originalCards); return { game, originalCards, target, kull, karama, counter, semuta: SEMUTA_DRUG_ID, holder: 'richese', reactor: 'choam', owner, actor: 'emperor', counterOwner: 'harkonnen', power }; }