import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { actSemutaNativeMarkets as act, advanceSemutaNativeMarkets as advance, semutaNativeMarketsInventory as inventory, semutaNativeMarketsPlayer as player } from './fixture-semuta-native-markets'; export const actSemutaMarketGhola = act; export const semutaMarketGholaPlayer = player; export const semutaMarketGholaInventory = inventory; /** Public Basic setup and real CHOAM market declaration. The original * starting card order and Spice positions are selected before they are drawn. * After setup, the real Ghola and Semuta cards are moved from their respective * decks to their owners' hands and one CHOAM reserve is moved to Tanks. When * selected, physical Kull and a second Karama are also moved from the deck to * CHOAM and Tleilaxu respectively. Those * conserved staging choices establish the relevant holdings and casualty; no * response, market, discard receipt, wallet, revival or income is staged. */ export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; withoutTleilaxu?: boolean; withKull?: boolean} = {}) { const owner = 'choam', claimant = 'richese', incomeOwner = options.withoutTleilaxu ? 'atreides' : 'tleilaxu', counterOwner = 'emperor'; let game = createGame('SEMUTAMARKETGHOLA', newPlayer(claimant, 'Richese', 'richese'), false, ['choam', 'ix']); for (const id of [owner, incomeOwner, counterOwner] as const) joinGame(game, newPlayer(id, id, id)); if (!options.withKull) game = act(game, claimant, {type:'moduleOption', module:'kullPreview', enabled:false}); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, claimant, {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds); const originalInventory = inventory(game); const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); const sale = game.deck.find(card => card.kind === 'worthless'); const counter = game.deck.find(card => card.effect === 'karama'); assert.equal(ordinary.length, 2); assert.ok(sale); assert.ok(counter); for (const [position, card] of [ordinary[0], sale, ordinary[1], counter].entries()) { const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position); [game.deck[position], game.deck[index]] = [game.deck[index], game.deck[position]]; } game = advance(game, state => state.status === 'playing'); assert.ok(player(game, owner).hand.some(card => card.id === sale.id)); assert.ok(player(game, counterOwner).hand.some(card => card.id === counter.id)); const front = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(front >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(front, 1)[0]); const semutaIndex = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(semutaIndex >= 0); player(game, claimant).hand.push(game.richeseCache!.splice(semutaIndex, 1)[0]); const gholaIndex = game.deck.findIndex(card => card.effect === 'ghola'); assert.ok(gholaIndex >= 0); const ghola = game.deck.splice(gholaIndex, 1)[0]; player(game, owner).hand.push(ghola); let kull: string | undefined, kullCounter: string | undefined; if (options.withKull) { assert.equal(game.kullPreview, true); const index = game.deck.findIndex(card => card.id === 'ix-kull-wahad'); assert.ok(index >= 0); const card = game.deck.splice(index, 1)[0]; player(game, owner).hand.push(card); kull = card.id; const counterIndex = game.deck.findIndex(candidate => candidate.effect === 'karama'); assert.ok(counterIndex >= 0); const secondCounter = game.deck.splice(counterIndex, 1)[0]; player(game, incomeOwner).hand.push(secondCounter); kullCounter = secondCounter.id; } const choam = player(game, owner); assert.ok(choam.reserves > 0); choam.reserves--; choam.tanks++; if (options.fullHand) while (player(game, claimant).hand.length < 4) { const index = game.deck.findIndex(card => ['projectile','poison','shield','snooper','worthless'].includes(card.kind)); assert.ok(index >= 0); player(game, claimant).hand.push(game.deck.splice(index, 1)[0]); } assert.deepEqual(inventory(game), originalInventory); game = advance(game, state => state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening); assert.equal(game.decision?.player, owner); game = act(game, owner, {type:'decision', mode:'sell', card:sale.id}); assert.equal(game.response?.kind, 'choamSale'); const source = structuredClone(game), market = structuredClone(game.choamMarket), response = structuredClone(game.response); const gholaAction:Action = {type:'card', card:ghola.id, amount:1, elite:0}; game = act(game, owner, gholaAction); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); const event = game.pendingTreacheryDiscard!.batch.event; assert.deepEqual(inventory(game), originalInventory); return {game, source, owner, claimant, incomeOwner, counterOwner, ghola:ghola.id, semuta:SEMUTA_DRUG_ID, counter:counter.id, sale:sale.id, event, originalInventory, kull, kullCounter, market, response, gholaAction, fullHand:!!options.fullHand}; } /** Pass only the current discard offer, without consuming a later response. */ export function passSemutaMarketGhola(source:Game):Game { const pending = source.pendingTreacheryDiscard; assert.equal(pending?.reaction?.stage, 'offer'); const event = pending!.batch.event; let game = source; for (const seat of source.players) { if (game.pendingTreacheryDiscard?.reaction?.stage !== 'offer') break; if (!game.pendingTreacheryDiscard.reaction.passed.includes(seat.id)) game = act(game, seat.id, {type:'semutaPass', event}); } return game; } export function claimSemutaMarketGhola(source:Game, actor:string, card:string):Game { const event = source.pendingTreacheryDiscard!.batch.event; let game = act(source, actor, {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, actor, {type:'semutaSelect', event, card}); return game; } export function allowSemutaMarketGholaIncome(source:Game):Game { let game = source; for (let count = 0; game.response?.kind === 'revivalIncome' && count < game.players.length; count++) { const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat); game = act(game, seat.id, {type:'passResponse'}); } assert.notEqual(game.response?.kind, 'revivalIncome'); return game; }