import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; /** Uses the public Basic Kull/Richese setup and an actual paid first-turn * cache acquisition of Semuta. The fixture selects only unopened initial * Treachery/Spice positions; all subsequent phases and the next cache * declaration use native actions. */ function source(position: 'first' | 'last') { const f = createKullRicheseSemutaFixture(); let game = advance(f.game, state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration'); const declaration = game.richeseBidding; assert.ok(declaration); game = act(game, 'richese', {type:'decision', event:declaration.event, position}); assert.equal(game.response?.kind, 'richeseAuction'); assert.equal(game.response?.owner, 'richese'); assert.equal(game.richeseBidding?.position, position); assert.equal(game.richeseBidding?.cacheCanceled, false); assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta)); 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, 'choam').hand.some(card => card.id === f.kull)); assert.deepEqual(inventory(game), f.originalCards); return {...f, game, cache:game.richeseCache!.map(card => card.id), normalBefore:game.richeseBidding!.normalCountBeforeCache!, normalCount:game.richeseBidding!.normalCount!}; } 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 position of ['first', 'last'] as const) for (const outcome of ['decline', 'success', 'counter'] as const) for (const claim of [false, true]) void test(`Basic ${position} cache cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = source(position); 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.ok(!game.pendingKull); if (outcome === 'success') { assert.equal(game.response?.kind, 'richeseAuction'); assert.equal(game.richeseBidding?.cacheCanceled, false); assert.equal(game.richeseBidding?.normalCount, f.normalCount); assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); } else { game = advance(game, state => state.phase === 3 && state.richeseBidding?.stage === 'normal' && !!state.auction && clean(state)); assert.equal(game.richeseBidding?.cacheCanceled, true); assert.equal(game.richeseBidding?.normalCount, f.normalBefore); assert.equal(game.auction?.cards.length, f.normalBefore); assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama)); } 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(game.richeseCache?.map(card => card.id), f.cache); assert.deepEqual(inventory(game), f.originalCards); }); /** Atreides inspection of a concealed Richese lot belongs to Advanced Black * Market, not Basic's face-up cache. This Basic parent instead reaches the * native Harkonnen bonus response after a real paid cache purchase. */ function bonusSource() { const f = source('first'); let game = f.game; 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); game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event, card:card.id, method:'silent'}); const lot = game.richeseAuction; assert.ok(lot && 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 === 'harkonnen' ? 1 : 0, allyPayment:0}); } assert.equal(game.response?.kind, 'harkonnenBonus'); assert.equal(game.response?.owner, 'harkonnen'); assert.ok(game.richeseAuction); assert.equal(game.currentAuctionSale?.origin, 'cache'); assert.equal(game.currentAuctionSale?.winner, 'harkonnen'); assert.ok(player(game, 'harkonnen').hand.some(held => held.id === card.id)); assert.deepEqual(inventory(game), f.originalCards); return {...f, game, purchased:card.id}; } for (const outcome of ['decline', 'success', 'counter'] as const) for (const claim of [false, true]) void test(`Basic paid cache Harkonnen bonus cancellation: Kull ${outcome}, Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = bonusSource(); let game = f.game; const harkonnenCards = player(game, 'harkonnen').hand.length; 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.ok(!game.pendingKull); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.purchased)); if (outcome === 'success') { // With no other eligible cancellation remaining, the restored bonus // resolves automatically and the winner draws its real extra card. assert.ok(!game.currentAuctionSale); assert.equal(player(game, 'harkonnen').hand.length, harkonnenCards + 1); assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama)); } else { assert.ok(!game.currentAuctionSale); assert.ok(!player(game, 'emperor').hand.some(card => card.id === f.karama)); } 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); }); void test('the paid cache sale and source remain bound while Kull and Semuta are pending', () => { const f = bonusSource(); let game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); const staleSale = structuredClone(game); staleSale.currentAuctionSale!.amount++; assert.throws(() => act(staleSale, 'choam', {type:'kullDecision', event:reaction.event, decline:true})); const staleCache = structuredClone(game); staleCache.richeseCache!.reverse(); assert.throws(() => act(staleCache, 'choam', {type:'kullDecision', event:reaction.event, decline:true})); game = act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); game = advance(game, state => !!state.pendingTreacheryDiscard); const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); const changed = structuredClone(game); changed.currentAuctionSale!.amount++; assert.throws(() => act(changed, 'richese', {type:'semutaPass', event:pending.batch.event})); game = passDiscard(game); assert.deepEqual(inventory(game), f.originalCards); }); void test('all existing bot profiles can answer the paid cache bonus Kull offer legally', () => { const f = bonusSource(); const game = act(f.game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); 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, 'kullDecision', difficulty); assert.doesNotThrow(() => act(game, 'choam', action), difficulty); } });