import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; function fixture(advanced = false) { const g = createGame( 'MARKETAUTO', newPlayer('c', 'CHOAM', 'choam'), advanced, ['choam'], ); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'), ); g.status = 'playing'; g.phase = 3; g.turn = 2; g.order = ['c', 'e', 'b']; g.choamCharity = { turn: 2, canceled: false }; g.deck = baseDeck(); for (const p of g.players) { p.hand = []; p.spice = 20; p.forces = {}; p.traitorChoices = []; } return g; } function hold(g: Game, id: string, name: string) { const index = g.deck.findIndex((c) => c.name === name); assert.ok(index >= 0, name); const card = g.deck.splice(index, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(card); return card; } function cacheCard(g: Game, id: string, effect: string) { g.richeseCache ??= richeseCards(); const index = g.richeseCache.findIndex((c) => c.effect === effect); assert.ok(index >= 0, effect); const card = g.richeseCache.splice(index, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(card); return card; } function ready(state: Game) { if (state.phase === 3) return normalizeAutomaticGame(savedMarket(structuredClone(state))); return state.players.reduce( (g, p) => applyAction(g, p.id, { type: 'ready' }), state, ); } const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); const inventory = (g: Game) => [ ...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand), ...(g.richeseCache ?? []), ...(g.auction?.cards ?? []), ] .map((c) => c.id) .sort(); function savedMarket(g: Game) { g.choamMarket = { owner: 'c', resume: 'phase', blocked: [] }; g.decision = { kind: 'choamMarket', player: 'c' }; return g; } void test('empty CHOAM closes the Bidding market and refunds allied escrow once using public hand counts', () => { for (const advanced of [false, true]) { const g = fixture(advanced); hold(g, 'e', 'Shield'); hold(g, 'b', 'Snooper'); g.aid = { c: { recipient: 'e', amount: 3 } }; g.players[0].spice = 17; g.active = 'c'; g.auction = { cards: [g.deck.shift()!], index: 0, bid: 0, bidder: null, active: 'c', passed: [], opener: 0, }; const before = structuredClone(g), next = g.players.reduce( (state, p) => applyAction(state, p.id, { type: 'passBid' }), g, ); assert.deepEqual(g, before); assert.equal(next.phase, 4); assert.equal(next.auction, null); assert.equal(next.choamMarket, null); assert.equal(next.players[0].spice, 20); assert.deepEqual(next.aid, {}); assert.deepEqual(inventory(next), inventory(g)); assert.deepEqual(normalizeAutomaticGame(reload(next)), reload(next)); assert.throws(() => applyAction(next, 'c', { type: 'decision', done: true }), ); } }); void test('empty storm and skipped battle markets keep manual completion outside Bidding', () => { let storm = fixture(); storm.phase = 0; storm.stormPending = 0; storm = ready(storm); assert.equal(storm.phase, 0); assert.equal(storm.choamMarket?.resume, 'storm'); assert.deepEqual(normalizeAutomaticGame(reload(storm)), reload(storm)); storm = applyAction(storm, 'c', { type: 'decision', done: true }); assert.equal(storm.phase, 1); assert.equal(storm.choamMarket, null); const movement = fixture(); movement.phase = 5; movement.active = 'c'; movement.movementRemaining = ['c']; let next = applyAction(movement, 'c', { type: 'endMovement' }); assert.equal(next.phase, 5); assert.equal(next.decision?.kind, 'choamMarket'); next = applyAction(next, 'c', { type: 'decision', done: true }); assert.equal(next.phase, 6); assert.equal(next.decision?.kind, 'choamMarket'); assert.deepEqual(normalizeAutomaticGame(reload(next)), reload(next)); next = applyAction(next, 'c', { type: 'decision', done: true }); assert.equal(next.phase, 7); assert.equal(next.choamMarket, null); }); void test('all non-Bidding markets preserve identical opposing views across hidden owner and donor hand-count changes', () => { for (const phase of [0, 1, 2, 4, 5, 6, 7, 8]) for (const changed of ['c', 'e']) { const a = savedMarket(fixture()); a.phase = phase; if (phase === 0) a.choamMarket!.resume = 'storm'; if (changed === 'e') hold(a, 'e', 'Shield'); // Keep public draws fixed and vary only the tested hidden hand. In the // donor branch CHOAM must stay empty, exposing any automatic-close oracle. const hidden = a.deck.findIndex(c => c.name === 'Snooper'); assert.ok(hidden >= 0); a.discard.push(...a.deck.splice(hidden, 1)); const b = structuredClone(a); b.players.find(p => p.id === changed)!.hand.push(b.discard.pop()!); if (changed === 'e') { assert.equal(a.players[0].hand.length, 0); assert.equal(b.players[0].hand.length, 0); } assert.deepEqual(a.deck, b.deck); assert.deepEqual(inventory(a), inventory(b)); assert.deepEqual(viewGame(a, 'b'), viewGame(b, 'b')); const first = normalizeAutomaticGame(reload(a)), second = normalizeAutomaticGame(reload(b)); assert.deepEqual(first, a); assert.deepEqual(second, b); assert.deepEqual(viewGame(first, 'b'), viewGame(second, 'b')); assert.equal(first.decision?.kind, 'choamMarket'); assert.equal(first.phase, phase); } }); void test('nonempty unsaleable and saleable hands both retain a private market; other two-card hands conservatively retain empty markets', () => { for (const name of ['Shield', 'Baliset']) { const g = fixture(); hold(g, 'c', name); const next = ready(g); assert.equal(next.phase, 3); assert.equal(next.decision?.kind, 'choamMarket'); assert.deepEqual(viewGame(next, 'e').choamMarket, { owner: 'c' }); assert.equal( viewGame(next, 'e').players.find((p) => p.id === 'c')!.hand, undefined, ); } for (const incoming of [false, true]) { const g = fixture(); hold(g, 'e', 'Shield'); if (incoming) cacheCard(g, 'e', 'distrans'); else hold(g, 'e', 'Snooper'); const next = ready(g); assert.equal(next.phase, 3); assert.equal(next.decision?.kind, 'choamMarket'); assert.deepEqual(normalizeAutomaticGame(reload(next)), reload(next)); } }); void test('an empty market retains a legal incoming Distrans gift and its same-phase sale', () => { const g = fixture(), card = hold(g, 'e', 'Baliset'), distrans = cacheCard(g, 'e', 'distrans'); let next = ready(g); next = applyAction(next, 'e', { type: 'card', card: distrans.id, target: 'c', give: card.id, }); assert.equal(next.phase, 3); assert.equal(next.decision?.kind, 'choamMarket'); assert.equal(viewGame(next, 'c').choamMarket?.sales?.[0].card, card.id); next = applyAction(reload(next), 'c', { type: 'decision', mode: 'sell', card: card.id, }); assert.equal(next.phase, 4); assert.equal(next.players[0].spice, 22); assert.deepEqual(next.players[0].hand, []); assert.deepEqual(inventory(next), inventory(g)); }); void test('a reciprocal Richese ally with one card retains the gift opportunity without testing its hidden identity', () => { for (const richese of [false, true]) for (const mutual of [false, true]) { const g = fixture(); g.players[1].faction = richese ? 'richese' : 'emperor'; if (richese) g.richeseCache = richeseCards(); g.players[0].ally = 'e'; if (mutual) g.players[1].ally = 'c'; hold(g, 'e', 'Shield'); const next = ready(g); assert.equal(next.phase, richese && mutual ? 3 : 4); } const g = fixture(); g.players[1].faction = 'richese'; g.players[0].ally = 'e'; g.players[1].ally = 'c'; const card = cacheCard(g, 'e', 'distrans'); const next = applyAction(ready(g), 'e', { type: 'richeseGift', card: card.id, }); assert.equal(next.phase, 3); assert.equal(next.decision?.kind, 'choamMarket'); assert.deepEqual(next.players[0].hand, [card]); assert.equal(next.pendingRicheseGift, null); }); void test('a nonempty unsaleable Box can prepare an end-phase sale and its paid search is never auto-closed', () => { const g = fixture(), box = cacheCard(g, 'c', 'nullentropyBox'); const chosen = hold(g, 'e', 'Baliset'), other = hold(g, 'e', 'Shield'); g.players[1].hand = []; g.discard = [chosen, other]; let next = applyAction(ready(g), 'c', { type: 'card', card: box.id }); assert.equal(next.decision?.kind, 'nullentropy'); assert.deepEqual(normalizeAutomaticGame(reload(next)), reload(next)); next = applyAction(next, 'c', { type: 'decision', event: next.pendingNullentropy!.event, card: chosen.id, }); assert.equal(next.phase, 3); assert.equal(next.decision?.kind, 'choamMarket'); next = applyAction(next, 'c', { type: 'decision', mode: 'sell', card: chosen.id, }); assert.equal(next.phase, 4); assert.equal(next.players[0].spice, 20); assert.deepEqual(inventory(next), inventory(g)); }); void test('live sale, allied trade replies and Truthtrance take precedence over automatic completion', () => { const g = fixture(), card = hold(g, 'c', 'Baliset'); const index = g.deck.findIndex((c) => c.effect === 'karama'); g.players[1].hand.push(g.deck.splice(index, 1)[0]); const sale = applyAction(ready(g), 'c', { type: 'decision', mode: 'sell', card: card.id, }); assert.equal(sale.response?.kind, 'choamSale'); assert.deepEqual(normalizeAutomaticGame(reload(sale)), reload(sale)); const traded = fixture(); traded.players[0].ally = 'e'; traded.players[1].ally = 'c'; const offered = hold(traded, 'c', 'Shield'); hold(traded, 'e', 'Snooper'); const reply = applyAction(ready(traded), 'c', { type: 'decision', mode: 'trade', card: offered.id, }); assert.equal(reply.decision?.kind, 'choamTradeReply'); assert.deepEqual(normalizeAutomaticGame(reload(reply)), reload(reply)); const empty = savedMarket(fixture()), truth = hold(empty, 'e', 'Truthtrance'); const question = applyAction(empty, 'e', { type: 'card', card: truth.id }); assert.ok(question.truthtrance); assert.equal(question.phase, 3); assert.deepEqual(normalizeAutomaticGame(reload(question)), reload(question)); }); void test('all four bot profiles sell the final Bidding-phase card and need no extra empty-market action', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(); g.players[0].bot = difficulty; hold(g, 'c', 'Baliset'); const market = ready(g), action = botActions(viewGame(market, 'c'))[0]; assert.equal(action.mode, 'sell', difficulty); const next = applyAction(market, 'c', action); assert.equal(next.phase, 4, difficulty); assert.equal(next.choamMarket, null); assert.equal(next.players[0].spice, 22, difficulty); } });