import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, RuleError, type Game, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { saleOptions } from '../game/choam-market'; import { createTechTokens } from '../game/tech-tokens'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { richeseCards } from '../game/richese-cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; function fixture() { const g = createGame('MARKET22', newPlayer('c', 'CHOAM', 'choam'), false, [ 'choam', ]); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'), ); g.status = 'playing'; g.phase = 2; g.choamCharity = { turn: 1, canceled: false }; g.order = ['c', 'e', 'b']; g.deck = baseDeck(); g.players.forEach((p) => { p.spice = 20; p.traitorChoices = []; }); return g; } function ready(state: Game) { let g = state; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); return g; } function allow(state: Game) { let g = state; while (g.response) { const p = g.players.find( (p) => !viewGame(g, p.id).responseControls?.hasPassed && !!viewGame(g, p.id).responseControls?.cancelCards.length, )!; g = applyAction(g, p.id, { type: 'passResponse' }); } return g; } function hold(g: Game, id: string, name: string) { const i = g.deck.findIndex((c) => c.name === name); assert.ok(i >= 0, name); const card = g.deck.splice(i, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(card); return card; } function contest(g: Game, count = 1, id = 'e') { for (let n = 0; n < count; n++) { const index = g.deck.findIndex((card) => card.effect === 'karama'); assert.ok(index >= 0); g.players.find((p) => p.id === id)!.hand.push(g.deck.splice(index, 1)[0]); } return g; } function cancel(g: Game) { const card = g.players .find((p) => p.id === 'e')! .hand.find((card) => card.effect === 'karama'); assert.ok(card, 'canceller must hold Karama before the declaration'); return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' }); } const done = (g: Game) => applyAction(g, 'c', { type: 'decision', done: true }); const sell = (g: Game, card: string, witness?: string) => applyAction(g, 'c', { type: 'decision', mode: 'sell', card, witness }); function allied(g: Game) { g.players[0].ally = 'e'; g.players[1].ally = 'c'; return g; } function offer(g: Game, card: string) { return applyAction(g, 'c', { type: 'decision', mode: 'trade', card }); } const live = (g: Game) => [ ...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand), ...(g.auction?.cards.slice(g.auction.index) ?? []), ] .map((c) => c.id) .sort(); void test('outside Bidding the market preserves private empty and nonempty hand opportunities', () => { for (const cards of [false, true]) { const initial = fixture(); if (cards) hold(initial, 'c', 'Baliset'); let g = ready(initial); assert.equal(g.phase, 2); assert.equal(g.decision?.kind, 'choamMarket'); assert.equal(g.auction, null); assert.deepEqual(viewGame(g, 'e').choamMarket, { owner: 'c' }); assert.throws( () => applyAction(g, 'e', { type: 'decision', done: true }), /pending decision/, ); assert.throws( () => applyAction(g, 'c', { type: 'charity' }), /pending decision/, ); g = done(g); assert.equal(g.phase, 3); assert.ok(g.auction); assert.equal(g.choamMarket, null); } }); void test('Worthless sale reveals its card but only discards and pays after the response', () => { const initial = contest(fixture()); const card = hold(initial, 'c', 'Baliset'); let g = sell(ready(initial), card.id); assert.equal(g.response?.kind, 'choamSale'); assert.equal(g.players[0].hand.length, 1); assert.equal(g.players[0].spice, 20); g = allow(g); assert.equal(g.players[0].spice, 22); assert.equal(g.players[0].hand.length, 0); assert.equal(g.discard.at(-1)?.id, card.id); assert.equal(g.decision?.kind, 'choamMarket'); assert.equal(g.phase, 2); assert.deepEqual(live(g), live(initial)); }); void test('clean CHOAM sale offers Semuta after committed income before the market resumes', () => { const g = fixture(); g.players[1] = newPlayer('e', 'Richese', 'richese'); g.players[1].spice = 20; g.richeseCache = richeseCards(); g.semutaPreview = true; const index = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(index >= 0); const semuta = g.richeseCache.splice(index, 1)[0]; g.players[1].hand.push(semuta); contest(g, 1, 'b'); const sold = hold(g, 'c', 'Baliset'); const original = live(g); const absent = structuredClone(g); absent.players[1].hand = [absent.richeseCache!.splice(0, 1)[0]]; absent.richeseCache!.push(semuta); const offered = (state: Game) => allow(sell(ready(state), sold.id)); const pending = offered(g), neutral = offered(absent); const event = pending.pendingTreacheryDiscard!.batch.event; assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamSaleDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(pending.choamMarket?.sale, undefined); assert.equal(pending.decision, null); assert.equal(pending.players[0].spice, 22); assert.equal(pending.players[0].hand.some(card => card.id === sold.id), false); assert.equal(pending.discard.filter(card => card.id === sold.id).length, 1); assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c')); assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b')); assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); for (const change of [ (state: Game) => { const next = state.pendingTreacheryDiscard!.continuation; if (next.kind === 'choamSaleDiscard') next.price = 3; }, (state: Game) => { state.players[0].spice += 2; }, (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, ]) { const corrupt: Game = JSON.parse(JSON.stringify(pending)); change(corrupt); const before = JSON.stringify(corrupt); assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event })); assert.equal(JSON.stringify(corrupt), before); } let declined: Game = JSON.parse(JSON.stringify(pending)); for (const id of ['c', 'b', 'e']) declined = applyAction(declined, id, { type: 'semutaPass', event }); const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e', botActions(viewGame(pending, 'e'))[0]!); for (const state of [declined, claimed]) { assert.equal(state.pendingTreacheryDiscard, null); assert.equal(state.decision?.kind, 'choamMarket'); assert.equal(state.players[0].spice, 22); assert.deepEqual(live(state), original); assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event })); } assert.equal(declined.discard.filter(card => card.id === sold.id).length, 1); assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1); assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); const resumed = done(claimed); assert.equal(resumed.choamMarket, null); assert.equal(resumed.phase, 3); }); void test('CHOAM duplicate sale retains its witness and three spice through Semuta', () => { const g = fixture(); g.players[1] = newPlayer('e', 'Richese', 'richese'); g.players[1].spice = 20; g.richeseCache = richeseCards(); g.semutaPreview = true; const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); const semuta = g.richeseCache.splice(semutaIndex, 1)[0]; g.players[1].hand.push(semuta); contest(g, 1, 'b'); const sold = hold(g, 'c', 'Snooper'), witness = hold(g, 'c', 'Snooper'); const pending = allow(sell(ready(g), sold.id, witness.id)); const continuation = pending.pendingTreacheryDiscard?.continuation; assert.equal(continuation?.kind, 'choamSaleDiscard'); if (continuation?.kind === 'choamSaleDiscard') { assert.equal(continuation.witness, witness.id); assert.equal(continuation.price, 3); } assert.equal(pending.players[0].spice, 23); assert.deepEqual(pending.players[0].hand.map(card => card.id), [witness.id]); const event = pending.pendingTreacheryDiscard!.batch.event; const claimed = applyAction(pending, 'e', { type: 'semutaCommit', event }); assert.equal(claimed.players[0].spice, 23); assert.deepEqual(claimed.players[0].hand.map(card => card.id), [witness.id]); assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1); assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); assert.equal(claimed.decision?.kind, 'choamMarket'); }); void test('canceled CHOAM sale creates no Semuta event or bank income', () => { const g = fixture(); g.players[1] = newPlayer('e', 'Richese', 'richese'); g.richeseCache = richeseCards(); g.semutaPreview = true; const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); g.players[1].hand.push(g.richeseCache.splice(semutaIndex, 1)[0]); contest(g, 1, 'b'); const sold = hold(g, 'c', 'Baliset'); const declaration = sell(ready(g), sold.id); const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!; const canceled = applyAction(declaration, 'b', { type: 'card', card: karama.id, mode: 'cancel' }); assert.ok(!canceled.pendingTreacheryDiscard); assert.equal(canceled.players[0].spice, 20); assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1); assert.equal(canceled.choamMarket?.sale, undefined); assert.equal(canceled.decision?.kind, 'choamMarket'); }); void test('surplus sales keep one exact duplicate and permit selling several surplus copies', () => { let g = fixture(); const cards = [ hold(g, 'c', 'Snooper'), hold(g, 'c', 'Snooper'), hold(g, 'c', 'Snooper'), ]; const inventory = live(g); g = ready(g); g = allow(sell(g, cards[0].id, cards[2].id)); g = allow(sell(g, cards[1].id, cards[2].id)); assert.equal(g.players[0].spice, 26); assert.deepEqual( g.players[0].hand.map((c) => c.id), [cards[2].id], ); assert.throws(() => sell(g, cards[2].id, cards[2].id), /exact duplicate/); assert.deepEqual(live(g), inventory); }); void test('same-role cards do not count as identical duplicates and forged witnesses fail atomically', () => { let g = fixture(); const snooper = hold(g, 'c', 'Snooper'); const shield = hold(g, 'c', 'Shield'); assert.equal( saleOptions([ snooper, { ...shield, name: 'Shield Snooper', kind: 'shieldSnooper' }, ]).length, 0, ); g = ready(g); const before = structuredClone(g); for (const witness of [shield.id, snooper.id, 'missing']) assert.throws(() => sell(g, snooper.id, witness), /exact duplicate/); assert.deepEqual(g, before); }); void test('Karama preserves the sale card and price, prevents reselling that card this phase and allows other sales', () => { let g = fixture(); const first = hold(g, 'c', 'Baliset'); const second = hold(g, 'c', 'Kulon'); g = cancel(sell(ready(contest(g)), first.id)); assert.equal(g.players[0].spice, 20); assert.equal(g.players[0].hand.length, 2); assert.throws(() => sell(g, first.id), /available/); g = allow(sell(g, second.id)); assert.equal(g.players[0].spice, 22); assert.ok(g.players[0].hand.some((c) => c.id === first.id)); }); void test('an allied trade requires two chosen cards and CHOAM confirmation before either moves', () => { let g = allied(fixture()); const a = hold(g, 'c', 'Shield'); const b = hold(g, 'e', 'Snooper'); const inventory = live(g); g = offer(ready(g), a.id); assert.equal(g.decision?.kind, 'choamTradeReply'); assert.equal(viewGame(g, 'e').choamMarket?.offered?.id, a.id); assert.deepEqual(viewGame(g, 'b').choamMarket, { owner: 'c' }); assert.throws( () => applyAction(g, 'e', { type: 'decision', card: a.id }), /your cards/, ); g = applyAction(g, 'e', { type: 'decision', card: b.id }); assert.equal(g.players[0].hand[0].id, a.id); assert.equal(g.players[1].hand[0].id, b.id); assert.equal(viewGame(g, 'c').choamMarket?.returned?.id, b.id); assert.equal(viewGame(g, 'b').choamMarket?.returned, undefined); g = applyAction(g, 'c', { type: 'decision', accept: true }); assert.equal(g.players[0].hand[0].id, b.id); assert.equal(g.players[1].hand[0].id, a.id); assert.equal(g.choamTradeTurn, 1); assert.throws(() => offer(g, b.id), /once per turn/); assert.deepEqual(live(g), inventory); }); void test('either ally may decline; no cards move and the once-per-turn exchange remains available', () => { for (const confirm of [false, true]) { let g = allied(fixture()); const a = hold(g, 'c', 'Shield'); const b = hold(g, 'e', 'Snooper'); g = offer(ready(g), a.id); if (confirm) g = applyAction(g, 'e', { type: 'decision', card: b.id }); g = applyAction(g, confirm ? 'c' : 'e', { type: 'decision', decline: true, }); assert.equal(g.choamTradeTurn, undefined); assert.equal(g.players[0].hand[0].id, a.id); assert.equal(g.players[1].hand[0].id, b.id); assert.equal(g.decision?.kind, 'choamMarket'); } }); void test('an empty ally hand can decline and a player without an ally cannot offer a one-way gift', () => { let g = fixture(); const a = hold(g, 'c', 'Shield'); g = ready(g); assert.throws(() => offer(g, a.id), /need an ally/); g = offer(allied(g), a.id); assert.throws( () => applyAction(g, 'e', { type: 'decision', accept: true }), /cards/, ); g = applyAction(g, 'e', { type: 'decision', decline: true }); assert.equal(g.players[0].hand[0].id, a.id); }); void test('trade confirmation checks current card custody and does not create or lose cards after an interruption', () => { let g = allied(fixture()); const a = hold(g, 'c', 'Shield'); const b = hold(g, 'e', 'Snooper'); g = offer(ready(g), a.id); g = applyAction(g, 'e', { type: 'decision', card: b.id }); // Represents a completed intervening card effect before confirmation. g.players[1].hand = []; g.discard.push(b); const inventory = live(g); g = applyAction(g, 'c', { type: 'decision', accept: true }); assert.equal(g.players[0].hand[0].id, a.id); assert.equal(g.choamTradeTurn, undefined); assert.deepEqual(live(g), inventory); }); void test('end-of-phase tech income settles once, after market completion', () => { let g = fixture(); hold(g, 'c', 'Shield'); g.techTokens = createTechTokens(); g.techTokens.production.owner = 'c'; g.techTokens.production.spice = 2; const card = hold(g, 'c', 'Baliset'); g = allow(sell(ready(g), card.id)); assert.equal(g.players[0].spice, 22); g = done(g); assert.equal(g.players[0].spice, 24); assert.equal(g.techTokens!.production.spice, 0); }); void test('allied auction escrow is returned only once after the bidding closing window', () => { let g = fixture(); hold(g, 'c', 'Shield'); g.phase = 3; g.active = 'c'; const card = g.deck.shift()!; g.auction = { cards: [card], index: 0, bid: 0, bidder: null, active: 'c', passed: [], opener: 0, }; g.aid = { c: { recipient: 'e', amount: 3 } }; g.players[0].spice = 17; for (const p of g.players) g = applyAction(g, p.id, { type: 'passBid' }); assert.equal(g.players[0].spice, 17); g = done(g); assert.equal(g.players[0].spice, 20); assert.deepEqual(g.aid, {}); }); void test('storm closing resumes forecast and the next phase after the market, rather than skipping the window', () => { let g = fixture(); hold(g, 'c', 'Baliset'); g.phase = 0; g.stormPending = 0; g = ready(g); assert.equal(g.phase, 0); assert.equal(g.choamMarket?.resume, 'storm'); g = done(g); assert.equal(g.phase, 1); assert.equal(g.choamMarket, null); }); void test('automatically skipped battle phase still offers its own closing window', () => { let g = fixture(); hold(g, 'c', 'Baliset'); g.phase = 5; g.active = 'c'; g.movementRemaining = ['c']; g = applyAction(g, 'c', { type: 'endMovement' }); assert.equal(g.phase, 5); assert.equal(g.decision?.kind, 'choamMarket'); g = done(g); assert.equal(g.phase, 6); assert.equal(g.decision?.kind, 'choamMarket'); g = done(g); assert.equal(g.phase, 7); }); void test('closing an empty auction opens the market before revival reset and next-phase Amal', () => { let g = fixture(); hold(g, 'c', 'Baliset'); g.phase = 3; g.active = 'c'; const card = g.deck.shift()!; g.auction = { cards: [card], index: 0, bid: 0, bidder: null, active: 'c', passed: [], opener: 0, }; g.players[0].revived = 2; g.expansions.push('ix'); for (const p of g.players) g = applyAction(g, p.id, { type: 'passBid' }); assert.equal(g.phase, 3); assert.equal(g.players[0].revived, 2); g = done(g); assert.equal(g.phase, 4); assert.ok(g.phaseOpening); assert.equal(g.players[0].revived, 2); g = ready(g); assert.equal(g.players[0].revived, 0); }); void test('a pending sale survives JSON persistence and remains server-owned', () => { let g = contest(fixture()); const card = hold(g, 'c', 'Baliset'); g = sell(ready(g), card.id); g = JSON.parse(JSON.stringify(g)) as Game; assert.throws(() => done(g), RuleError); g = allow(g); assert.equal(g.players[0].spice, 22); g = done(g); assert.equal(g.phase, 3); }); void test('all AI levels sell legally and complete empty or declined market choices', () => { for (const difficulty of DIFFICULTIES) { let g = allied(fixture()); hold(g, 'c', 'Baliset'); hold(g, 'c', 'Shield'); g.players.forEach((p) => { p.bot = difficulty; }); g = ready(g); for (let i = 0; i < 20 && g.phase === 2; i++) { const id = g.response ? g.players.find( (p) => !viewGame(g, p.id).responseControls?.hasPassed && !!viewGame(g, p.id).responseControls?.cancelCards.length, )!.id : g.decision!.player; const actions = botActions(viewGame(g, id)); assert.ok(actions.length, difficulty); g = applyAction(g, id, actions[0]); } assert.equal(g.phase, 3); assert.equal(g.players[0].spice, 22); assert.equal(g.players[0].hand[0].name, 'Shield'); } }); void test('Truthtrance can interrupt a pending sale without selling a card it consumed', () => { let g = contest(fixture()); const first = hold(g, 'c', 'Truthtrance'); const second = hold(g, 'c', 'Truthtrance'); const inventory = live(g); g = sell(ready(g), first.id, second.id); g = applyAction(g, 'c', { type: 'card', card: first.id }); while (g.truthtrance?.stage === 'priority') { const p = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!; g = applyAction(g, p.id, { type: 'truthPass' }); } g = applyAction(g, 'c', { type: 'truthAsk', question: { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' }, }, }); g = applyAction(g, 'e', { type: 'truthAnswer', answer: 'no' }); assert.equal(g.response?.kind, 'choamSale'); g = allow(g); assert.equal(g.players[0].spice, 20); assert.deepEqual( g.players[0].hand.map((c) => c.id), [second.id], ); assert.deepEqual(live(g), inventory); assert.equal(g.decision?.kind, 'choamMarket'); }); void test('the one-trade limit spans phase boundaries and renews when the turn advances', () => { let g = allied(fixture()); const a = hold(g, 'c', 'Shield'); const b = hold(g, 'e', 'Snooper'); g = offer(ready(g), a.id); g = applyAction(g, 'e', { type: 'decision', card: b.id }); g = applyAction(g, 'c', { type: 'decision', accept: true }); g = done(g); g.phase = 4; g.auction = null; g.ready = []; g = ready(g); assert.throws(() => offer(g, b.id), /once per turn/); g.phase = 8; g = done(g); g = applyAction(g, 'c', { type: 'decision', done: true }); assert.equal(g.turn, 2); g.phase = 4; g.ready = []; g.response = null; g = ready(g); g = offer(g, b.id); assert.equal(g.decision?.kind, 'choamTradeReply'); });