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'; /** The live sold lot remains an auction reference; its physical card is in * the buyer's hand. Later 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; } /** Genuine Basic setup, the real first-turn Richese Semuta purchase, and a * paid turn-two normal lot. The existing fixture selects unopened cards and * Spice order; no live hand, wallet, payment, 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 buyerSpice = player(game, 'harkonnen').spice; const emperorSpice = player(game, 'emperor').spice; game = act(game, 'harkonnen', {type:'bid', amount:1, allyPayment:0}); game = advance(game, state => state.response?.kind === 'emperorIncome'); assert.equal(game.response?.owner, 'emperor'); assert.equal(game.currentAuctionSale?.origin, 'normal'); assert.equal(game.currentAuctionSale?.winner, 'harkonnen'); assert.equal(game.currentAuctionSale?.amount, 1); assert.equal(game.auction?.index, index); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === lot)); assert.equal(player(game, 'harkonnen').spice, buyerSpice - 1); assert.equal(player(game, 'emperor').spice, emperorSpice); assert.ok(player(game, 'choam').hand.some(card => card.id === f.kull)); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter)); assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta)); assert.deepEqual(inventory(game), f.originalCards); return {...f, game, index, lot, buyerSpice:buyerSpice - 1, emperorSpice}; } 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 < game.players.length; count++) { const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer'); const seat = game.players.find(row => !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(`paid Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => { const f = source(); let game = f.game; assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter)); game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter}); 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, 'emperor').responseControls?.cancelCards.includes(f.karama)); game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); game = passDiscard(game); } if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard); const discarded = outcome === 'success' ? f.kull : f.counter; 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.ok(player(game, 'harkonnen').hand.some(card => card.id === f.lot)); assert.equal(player(game, 'harkonnen').spice, f.buyerSpice); assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success')); assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), 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); }); /** The owner buys a real Richese cache card, so the native price goes to * Emperor. No auction, wallet, held card or pending response is staged. */ function cacheSource() { 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:'first'}); for (let count = 0; game.response?.kind === 'richeseAuction' && count < game.players.length; count++) { const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); game = act(game, seat.id, {type:'passResponse'}); } assert.equal(game.decision?.kind, 'richeseCache'); const card = game.richeseCache?.[0]; assert.ok(card); const buyerSpice = player(game, 'richese').spice; const emperorSpice = player(game, 'emperor').spice; game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event, card:card.id, method:'silent'}); const lot = game.richeseAuction; assert.ok(lot?.method === 'silent'); for (let count = 0; game.richeseAuction?.event === lot.event && !game.response && count < game.players.length; count++) { const current = game.richeseAuction!; const actor = current.eligible.find(id => !Object.hasOwn(current.sealed, id)); assert.ok(actor); game = act(game, actor, {type:'richeseBid', event:current.event, amount:actor === 'richese' ? 1 : 0, allyPayment:0}); } assert.equal(game.response?.kind, 'emperorIncome'); assert.equal(game.response?.owner, 'emperor'); assert.equal(game.currentAuctionSale?.origin, 'cache'); assert.equal(game.currentAuctionSale?.winner, 'richese'); assert.equal(game.currentAuctionSale?.amount, 1); assert.ok(player(game, 'richese').hand.some(held => held.id === card.id)); assert.equal(player(game, 'richese').spice, buyerSpice - 1); assert.equal(player(game, 'emperor').spice, emperorSpice); assert.deepEqual(inventory(game), f.originalCards); return {...f, game, purchased:card.id, buyerSpice:buyerSpice - 1, emperorSpice}; } for (const outcome of ['decline','success','counter'] as const) for (const claim of [false,true]) void test(`paid Richese cache Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => { const f = cacheSource(); let game = f.game; assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter)); game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter}); 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, 'emperor').responseControls?.cancelCards.includes(f.karama)); game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); game = passDiscard(game); } if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard); const discarded = outcome === 'success' ? f.kull : f.counter; assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded)); game = claim ? claimDiscard(game, discarded) : passDiscard(game); if (game.response?.kind === 'emperorIncome') game = advance(game, state => state.response?.kind !== 'emperorIncome' && !state.pendingTreacheryDiscard); assert.equal(game.pendingKull ?? null, null); assert.ok(player(game, 'richese').hand.some(card => card.id === f.purchased)); assert.equal(player(game, 'richese').spice, f.buyerSpice); assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success')); assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), 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); });