import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, 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'; function reject(game:Game, actor:string, action:Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); } function allowSale(source:Game):Game { let game = source; for (let count = 0; game.response?.kind === 'choamSale' && count < game.players.length; count++) { const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor); game = act(game, actor.id, {type:'passResponse'}); } assert.notEqual(game.response?.kind, 'choamSale'); if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); return game; } void test('market Ghola commits the actual return and parks its income and original sale through the Semuta offer', () => { const f = fixture(), pending = f.game.pendingTreacheryDiscard; assert.equal(f.source.response?.kind, 'choamSale'); assert.equal(pending?.reaction?.stage, 'offer'); assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard'); if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.'); assert.equal(pending.continuation.effect, 'ghola'); assert.equal(pending.continuation.player, f.owner); assert.equal(pending.continuation.card, f.ghola); assert.equal(pending.continuation.resume.response?.kind, 'revivalIncome'); assert.equal(pending.continuation.resume.response?.owner, f.incomeOwner); assert.equal(pending.continuation.resume.response?.recipient, f.owner); assert.equal(pending.continuation.resume.response?.amount, 1); assert.equal(pending.continuation.resume.response?.intent, f.game.pendingChoamMarketGhola?.event); assert.deepEqual(f.game.pendingChoamMarketGhola?.response, f.response); assert.deepEqual(f.game.pendingChoamMarketGhola?.market, f.market); assert.equal(f.game.pendingChoamMarketGhola?.stage, 'discard'); assert.equal(f.game.response, null); assert.equal(player(f.game, f.owner).reserves, player(f.source, f.owner).reserves + 1); assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.equal(player(f.game, f.incomeOwner).spice, player(f.source, f.incomeOwner).spice); assert.deepEqual(inventory(f.game), f.originalInventory); for (const seat of f.game.players) { const projected = viewGame(f.game, seat.id).semutaReaction; assert.ok(projected); assert.deepEqual(projected.candidates, []); assert.equal(projected.canCommit, seat.id === f.claimant); } reject(f.game, f.claimant, {type:'semutaCommit', event:'stale-market-ghola'}); }); for (const claims of [false, true]) for (const cancelIncome of [false, true]) void test( `market Ghola ${claims ? 'claim' : 'pass'} preserves ${cancelIncome ? 'canceled' : 'paid'} income and original sale`, () => { const f = fixture(), beforeIncome = player(f.source, f.incomeOwner).spice, beforeSale = player(f.source, f.owner).spice; let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game); assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.response?.kind, 'revivalIncome'); assert.equal(game.response?.intent, game.pendingChoamMarketGhola?.event); assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); assert.deepEqual(game.choamMarket, f.market); assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.equal(player(game, f.incomeOwner).spice, beforeIncome); assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims); assert.equal(game.discard.some(card => card.id === f.ghola), !claims); if (cancelIncome) { game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); } else game = allowIncome(game); assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome)); assert.equal(game.pendingChoamMarketGhola ?? null, null); if (game.response?.kind === 'choamSale') { assert.deepEqual(game.response, f.response); assert.deepEqual(game.choamMarket, f.market); assert.equal(player(game, f.owner).spice, beforeSale); } else { // When the counter consumed the last usable Karama, the restored sale // may immediately advance through the existing automatic response path. assert.equal(game.response ?? null, null); assert.equal(player(game, f.owner).spice, beforeSale + 2); assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); } game = allowSale(game); assert.equal(player(game, f.owner).spice, beforeSale + 2); assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); assert.equal(game.discard.filter(card => card.id === f.sale).length, 1); assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome)); assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.claimant, {type:'semutaCommit', event:f.event}); }, ); void test('without Tleilaxu, Semuta claim or pass restores the original sale without income', () => { for (const claims of [false, true]) { const f = fixture({withoutTleilaxu:true}), beforeSale = player(f.source, f.owner).spice; const pending = f.game.pendingTreacheryDiscard; assert.equal(f.game.players.some(seat => seat.faction === 'tleilaxu'), false); assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard'); if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.'); assert.equal(pending.continuation.resume.response ?? null, null); assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1); let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game); assert.equal(game.pendingChoamMarketGhola ?? null, null); assert.deepEqual(game.response, f.response); assert.deepEqual(game.choamMarket, f.market); assert.equal(player(game, f.owner).spice, beforeSale); assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims); assert.equal(game.discard.some(card => card.id === f.ghola), !claims); game = allowSale(game); assert.equal(player(game, f.owner).spice, beforeSale + 2); assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); assert.equal(game.discard.filter(card => card.id === f.sale).length, 1); assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); assert.deepEqual(inventory(game), f.originalInventory); } }); void test('a full-hand Semuta claimant exchanges its physical card and still restores market income', () => { const f = fixture({fullHand:true}); assert.equal(player(f.game, f.claimant).hand.length, 4); const game = claim(f.game, f.claimant, f.ghola); assert.equal(player(game, f.claimant).hand.length, 4); assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); assert.equal(game.response?.kind, 'revivalIncome'); assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); assert.deepEqual(inventory(game), f.originalInventory); }); void test('all minimal bot profiles can continue the preserved neutral offer', () => { const f = fixture(); for (const difficulty of DIFFICULTIES) { let game = structuredClone(f.game); const view = viewGame(game, f.claimant); view.players.find(seat => seat.id === f.claimant)!.bot = difficulty; const action = botActions(view).find(candidate => candidate.type === 'semutaPass' || candidate.type === 'semutaCommit'); assert.ok(action, `${difficulty} needs a legal Semuta response`); game = applyAction(game, f.claimant, action); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { const choice = botActions(viewGame(game, f.claimant)).find(candidate => candidate.type === 'semutaSelect'); assert.ok(choice, `${difficulty} needs a legal discard selection`); game = applyAction(game, f.claimant, choice); } if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); assert.equal(game.response?.kind, 'revivalIncome'); assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); assert.deepEqual(inventory(game), f.originalInventory); } });