import assert from 'node:assert/strict'; import test from 'node:test'; import { handLimit, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { act, finishRevival, inventory, player } from './fixture-kull-revival-semuta'; import { createSemutaRevivalBenefitsForcesFixture } from './fixture-semuta-revival-benefits-forces'; /** The native source purchases Semuta, ships four forces and loses them to * Storm before this real Revival opportunity. As in the existing Kull * Semuta fixture, only original physical cards are reassigned among zones; * no phase, wallet, force, promise, response or pending revival is installed. */ let nativeSource: ReturnType | undefined; function fixture(answer: 'yes' | 'no' = 'yes') { const source = nativeSource ??= createSemutaRevivalBenefitsForcesFixture({kind:'choamRevival', advanced:false}); let game = structuredClone(source.beforeRevival); assert.ok(game.kullPreview && game.semutaPreview); const originalCards = inventory(game); const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)]; const move = (id:string, recipient:string) => { const zone = zones.find(cards=>cards.some(card=>card.id === id)); assert.ok(zone, id); const [card] = zone.splice(zone.findIndex(row=>row.id === id),1); const seat = player(game,recipient); assert.ok(seat.hand.length < handLimit(seat)); seat.hand.push(card); }; move(SEMUTA_DRUG_ID, 'richese'); move('ix-kull-wahad', 'choam'); const truth = zones.flat().find(card=>card.effect === 'truthtrance'); assert.ok(truth); move(truth.id, 'atreides'); const counter = zones.flat().find(card=>card.effect === 'karama' && card.id !== source.cost && !card.id.startsWith('richese-')); assert.ok(counter); move(counter.id, 'tleilaxu'); assert.deepEqual(inventory(game),originalCards); assert.equal(player(game,'choam').tanks,4); game = act(game,'atreides',{type:'card',card:truth.id}); while (game.truthtrance?.stage === 'priority') { const next = game.players.find(seat=>!game.truthtrance!.passed.includes(seat.id)); assert.ok(next); game = act(game,next.id,{type:'truthPass'}); } game = act(game,'atreides',{type:'truthAsk',question:{kind:'revival',target:'choam', turn:game.turn,counter:'total',minimum:3}}); assert.ok(viewGame(game,'choam').truthRevivalAnswers?.includes(answer)); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action,difficulty); assert.equal(action.type,'truthAnswer',difficulty); assert.ok(view.truthRevivalAnswers?.includes(action.answer as 'yes'|'no'),difficulty); assert.doesNotThrow(()=>act(game,'choam',action),difficulty); } game = act(game,'choam',{type:'truthAnswer',answer}); const promise = game.revivalPromises!.find(row=>row.player === 'choam'); assert.ok(promise); // Pass the physical Truthtrance's Semuta reaction, retaining Semuta for the // subsequent Kull or counter discard. if (game.pendingTreacheryDiscard) game = semuta(game,truth.id,false); assert.ok(game.revivalPromises?.some(row=>row.event === promise.event && !row.released)); return {game,source,originalCards,event:promise.event,kull:'ix-kull-wahad', karama:source.cost,counter:counter.id}; } function semuta(game:Game, card:string, claim:boolean):Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.reaction?.stage,'offer'); assert.equal(pending.batch.entries[0].card.id,card); const event = pending.batch.event; if (claim) { assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); game = act(game,'richese',{type:'semutaCommit',event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game,'richese',{type:'semutaSelect',event,card}); } else { for (let count=0;game.pendingTreacheryDiscard?.batch.event === event && count!reaction.passed.includes(seat.id)); assert.ok(next); game = act(game,next.id,{type:'semutaPass',event}); } } assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); return game; } function pending(f:ReturnType) { let game = f.game; const guide = viewGame(game,'choam').revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.type,'revive'); assert.equal(guide.action.amount,3); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'choam'); view.players.find(seat=>seat.id === 'choam')!.bot = difficulty; const action = botActions(view).find(row=>row.revivalPromiseEvent === guide.action.revivalPromiseEvent); assert.ok(action,difficulty); assert.doesNotThrow(()=>act(game,'choam',action),difficulty); } game = act(game,'choam',guide.action); for (let count=0;count<20 && game.response?.kind !== 'choamRevival';count++) { if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival') game = act(game,game.decision.player,{type:'decision',decline:true}); else throw Error('The original revival did not reach its native benefit response.'); } assert.equal(game.response?.kind,'choamRevival'); assert.ok(game.pendingRevival); game = act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); assert.equal(game.pendingKull?.stage,'offer'); const reaction = viewGame(game,'choam').kullReaction; assert.ok(reaction); game = act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); assert.equal(game.pendingKull?.stage,'counter'); return game; } for (const claim of [false,true]) void test(`Revival Yes survives Kull success and Semuta ${claim?'claim':'pass'}`,()=>{ const f = fixture(); let game = pending(f); const promise = structuredClone(game.revivalPromises!.find(row=>row.event === f.event)); assert.ok(promise); for (let count=0;count!game.response!.passed.includes(seat.id)); assert.ok(next); game = act(game,next.id,{type:'passResponse'}); } assert.deepEqual(game.revivalPromises!.find(row=>row.event === f.event),promise); game = semuta(game,f.kull,claim); game = finishRevival(game); assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true); assert.equal(player(game,'choam').tanks,1); assert.equal(player(game,'richese').hand.some(card=>card.id === f.kull),claim); assert.deepEqual(inventory(game),f.originalCards); }); for (const claim of [false,true]) void test(`Revival Yes survives Kull counter and Semuta ${claim?'claim':'pass'}`,()=>{ const f = fixture(); let game = pending(f); assert.ok(viewGame(game,'tleilaxu').responseControls?.cancelCards.includes(f.counter)); game = act(game,'tleilaxu',{type:'card',mode:'cancel',card:f.counter}); assert.ok(!game.revivalPromises!.find(row=>row.event === f.event)?.released); game = semuta(game,f.counter,claim); if (game.pendingTreacheryDiscard) game = semuta(game,f.karama,false); game = finishRevival(game); assert.equal(game.revivalPromises!.find(row=>row.event === f.event)?.fulfilled,true); assert.equal(player(game,'choam').tanks,1); assert.equal(player(game,'richese').hand.some(card=>card.id === f.counter),claim); assert.deepEqual(inventory(game),f.originalCards); }); void test('Revival No retains its cap in the Kull and Richese profile',()=>{ const f = fixture('no'); const before = structuredClone(f.game); assert.throws(()=>act(f.game,'choam',{type:'revive',amount:3,elite:0})); assert.deepEqual(f.game,before); assert.ok(!f.game.revivalPromises!.find(row=>row.event === f.event)?.released); });