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 { createSemutaMarketGholaFixture as fixture, actSemutaMarketGhola as act, semutaMarketGholaPlayer as player, semutaMarketGholaInventory as inventory, passSemutaMarketGhola as pass, claimSemutaMarketGhola as claim, allowSemutaMarketGholaIncome as allowIncome } from './fixture-semuta-market-ghola'; /** Resolve the existing public responses, but never choose a new market action. */ function finishOriginalSale(source: Game): Game { let game = source; for (let count = 0; count < 30; count++) { if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { game = pass(game); continue; } if (game.response) { const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat, 'An unresolved response needs an unpassed seat'); game = act(game, seat.id, {type:'passResponse'}); continue; } assert.equal(game.pendingKull ?? null, null); assert.equal(game.pendingChoamMarketGhola ?? null, null); return game; } throw Error('The original income and market must finish'); } for (const claims of [false, true]) for (const kullOutcome of ['decline', 'success', 'counter'] as const) void test(`default Kull: Semuta ${claims ? 'claims' : 'passes'} market Ghola; Kull ${kullOutcome} preserves income and sale`, () => { const f = fixture({withKull:true}); assert.ok(f.kull); assert.ok(f.kullCounter); const beforeIncome = player(f.source, f.incomeOwner).spice, beforeSale = player(f.source, f.owner).spice; assert.equal(f.game.kullPreview, true); let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game); assert.equal(game.response?.kind, 'revivalIncome'); assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); assert.ok(viewGame(game, f.counterOwner).responseControls?.cancelCards.includes(f.counter)); game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'}); const reaction = viewGame(game, f.owner).kullReaction; assert.ok(reaction); assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull)); game = kullOutcome === 'decline' ? act(game, f.owner, {type:'kullDecision', event:reaction.event, decline:true}) : act(game, f.owner, {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); if (kullOutcome === 'counter') { assert.ok(viewGame(game, f.incomeOwner).responseControls?.cancelCards.includes(f.kullCounter)); game = act(game, f.incomeOwner, {type:'card', card:f.kullCounter, mode:'cancel'}); } if (kullOutcome === 'success') { for (let count = 0; !game.pendingTreacheryDiscard && game.response && 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.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(game.pendingTreacheryDiscard?.continuation.kind, kullOutcome === 'decline' ? 'karamaIncomeDiscard' : kullOutcome === 'success' ? 'kullSuccessDiscard' : 'kullCounterDiscard'); game = pass(game); if (kullOutcome === 'counter') { assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaIncomeDiscard'); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); } game = finishOriginalSale(game); assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(kullOutcome === 'success')); assert.equal(player(game, f.owner).spice, beforeSale + 2); assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims); assert.equal(game.discard.filter(card => card.id === f.sale).length, 1); assert.equal(player(game, f.counterOwner).hand.some(card => card.id === f.counter), kullOutcome === 'success'); assert.equal(game.discard.filter(card => card.id === f.kull).length, Number(kullOutcome === 'success')); assert.deepEqual(inventory(game), f.originalInventory); }); for (const kullOutcome of ['decline', 'success', 'counter'] as const) void test(`resumed sale cancellation: Kull ${kullOutcome} offers its physical cost to Semuta`, () => { const f = fixture({withKull:true}); assert.ok(f.kull); assert.ok(f.kullCounter); const beforeIncome = player(f.source, f.incomeOwner).spice, beforeSale = player(f.source, f.owner).spice; let game = allowIncome(pass(f.game)); assert.equal(game.response?.kind, 'choamSale'); assert.deepEqual(game.response, f.response); assert.ok(viewGame(game, f.counterOwner).responseControls?.cancelCards.includes(f.counter)); game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'}); const reaction = viewGame(game, f.owner).kullReaction; assert.ok(reaction); game = kullOutcome === 'decline' ? act(game, f.owner, {type:'kullDecision', event:reaction.event, decline:true}) : act(game, f.owner, {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull}); if (kullOutcome === 'counter') { assert.ok(viewGame(game, f.incomeOwner).responseControls?.cancelCards.includes(f.kullCounter)); game = act(game, f.incomeOwner, {type:'card', card:f.kullCounter, mode:'cancel'}); } const cost = kullOutcome === 'decline' ? f.counter : kullOutcome === 'success' ? f.kull : f.kullCounter; for (let count = 0; !game.pendingTreacheryDiscard && game.response && 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.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === cost)); game = claim(game, f.claimant, cost); game = finishOriginalSale(game); assert.equal(player(game, f.incomeOwner).spice, beforeIncome + 1); assert.equal(player(game, f.owner).spice, beforeSale + (kullOutcome === 'success' ? 2 : 0)); assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), kullOutcome !== 'success'); assert.equal(game.discard.filter(card => card.id === f.sale).length, Number(kullOutcome === 'success')); assert.equal(player(game, f.claimant).hand.filter(card => card.id === cost).length, 1); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.deepEqual(inventory(game), f.originalInventory); }); void test('all minimal bot profiles can answer Kull against the resumed sale', () => { const f = fixture({withKull:true}); let game = allowIncome(pass(f.game)); assert.equal(game.response?.kind, 'choamSale'); game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'}); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = difficulty; const action = botActions(view).find(candidate => candidate.type === 'kullDecision'); assert.ok(action, difficulty); const continued = act(game, f.owner, action); assert.notEqual(continued.pendingKull?.stage, 'offer'); assert.deepEqual(inventory(continued), f.originalInventory); } });