import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Game } from '../game/engine'; import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta'; /** Account for the current sold card as a historical auction reference, not a * second physical card. Unopened lots remain in the original auction custody. */ function inventory(game: Game): string[] { const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(seat => seat.hand), ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])] .map(card => card.id).sort(); assert.equal(new Set(cards).size, cards.length); return cards; } /** Public Basic four-seat setup and actual first-turn paid Semuta acquisition. * In turn two Richese announces its compulsory auction last, then Harkonnen * purchases an actual normal lot with its own spice; no live purse, hand, * auction, response or promise is staged. */ function source() { const f = createKullRicheseSemutaFixture(); let game = advance(f.game, state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration'); const round = game.richeseBidding; assert.ok(round); game = act(game, 'richese', {type:'decision', event:round.event, position:'last'}); game = advance(game, state => state.phase === 3 && !!state.auction && state.auction.active === 'harkonnen' && state.auction.bid === 0 && clean(state)); const index = game.auction!.index, lot = game.auction!.cards[index].id; const beforeSpice = player(game,'harkonnen').spice; game = act(game,'harkonnen',{type:'bid',amount:1,allyPayment:0}); game = advance(game,state => state.response?.kind === 'harkonnenBonus'); assert.equal(game.currentAuctionSale?.origin,'normal'); assert.equal(game.currentAuctionSale?.winner,'harkonnen'); assert.equal(game.auction?.index,index); assert.equal(game.auction?.cards[index].id,lot); assert.ok(player(game,'harkonnen').hand.some(card => card.id === lot)); assert.equal(player(game,'harkonnen').spice,beforeSpice-1); assert.ok(player(game,'choam').hand.some(card => card.id === f.kull)); assert.ok(player(game,'emperor').hand.some(card => card.id === f.karama)); assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.counter)); assert.ok(player(game,'richese').hand.some(card => card.id === f.semuta)); assert.deepEqual(inventory(game),f.originalCards); return {...f,game,index,lot,paidSpice:player(game,'harkonnen').spice, buyerCount:player(game,'harkonnen').hand.length}; } function passDiscard(game: Game): Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); const event = pending.batch.event; for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count !reaction.passed.includes(row.id)); assert.ok(seat); game = act(game,seat.id,{type:'semutaPass',event}); } assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); return game; } function claimDiscard(game: Game, card: string): Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); assert.ok(pending.batch.entries.some(entry => entry.card.id === card)); assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); const event=pending.batch.event; game=act(game,'richese',{type:'semutaCommit',event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game=act(game,'richese',{type:'semutaSelect',event,card}); assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); return game; } for (const outcome of ['decline','success','counter'] as const) for (const claim of [false,true]) void test(`normal Harkonnen paid bonus: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`,()=>{ const f=source(); let game=f.game; assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull)); game=outcome==='decline' ? act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}) : act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); if (outcome==='counter') { assert.ok(viewGame(game,'harkonnen').responseControls?.cancelCards.includes(f.counter)); game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); game=passDiscard(game); } if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard); const discarded=outcome==='success'?f.kull:f.karama; assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===discarded)); game=claim?claimDiscard(game,discarded):passDiscard(game); assert.equal(game.pendingKull ?? null,null); assert.equal(game.currentAuctionSale ?? null,null); assert.notEqual(game.response?.kind,'harkonnenBonus'); assert.equal(game.auction?.index,f.index+1); assert.equal(player(game,'harkonnen').spice,f.paidSpice); assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot)); assert.equal(player(game,'harkonnen').hand.length, f.buyerCount+Number(outcome==='success')-Number(outcome==='counter')); assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),outcome==='success'); assert.equal(player(game,'richese').hand.some(card=>card.id===discarded),claim); assert.equal(player(game,'richese').hand.some(card=>card.id===f.semuta),!claim); assert.deepEqual(inventory(game),f.originalCards); });