import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, RuleError, } from '../game/engine'; import { baseDeck, treacheryDeck } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { charityAmount, charityMultiplier } from '../game/charity'; import { createTechTokens } from '../game/tech-tokens'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; function fixture(advanced = false) { const g = createGame('INFLATE2', newPlayer('c', 'CHOAM', 'choam'), advanced, [ 'choam', ]); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'), ); g.status = 'playing'; g.phase = 7; 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; const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id); for (const player of players) g = applyAction(g, player, { type: 'ready' }); while (g.decision?.kind === 'choamMarket') g = applyAction(g, g.decision.player, { type: 'decision', done: true }); return g; } function charityTurn(state: Game, player: string) { let g = state; while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) { const current = g.charityOrder.remaining[0]; assert.ok(current, 'the requested Charity opportunity must still be pending'); g = applyAction(g, current, { 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 place(g: Game, side: 'double' | 'cancel' = 'double') { return applyAction(g, 'c', { type: 'choamInflation', side }); } 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' }); } function nextCharity(state: Game) { const g = structuredClone(state); g.turn++; g.phase = 1; g.nexus = true; g.ready = []; return ready(g); } function mentat(state: Game) { const g = structuredClone(state.phase === 2 && state.charityOrder ? ready(state) : state); g.phase = 7; g.ready = []; return ready(g); } void test('clean printed Karama cancellation offers Semuta before CHOAM Inflation settles', () => { let g = fixture(); g.players[1] = newPlayer('e', 'Richese', 'richese'); g.richeseCache = richeseCards(); g.semutaPreview = true; const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID); assert.ok(semuta >= 0); g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]); contest(g); g = place(ready(g), 'double'); assert.equal(g.response?.kind, 'choamInflation'); const spent = g.players[1].hand.find(c => c.effect === 'karama')!; g = cancel(g); const event = g.pendingTreacheryDiscard!.batch.event; assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(g.response, null); assert.equal(g.inflation, undefined); assert.equal(g.inflationAttemptTurn, g.turn); assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true); assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false); let declined: Game = JSON.parse(JSON.stringify(g)); for (const id of ['c', 'e', 'b']) declined = applyAction(declined, id, { type: 'semutaPass', event }); assert.equal(declined.pendingTreacheryDiscard, null); assert.equal(declined.inflation, undefined); assert.equal(declined.inflationAttemptTurn, g.turn); const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event }); assert.equal(claimed.pendingTreacheryDiscard, null); assert.equal(claimed.inflation, undefined); assert.equal(claimed.inflationAttemptTurn, g.turn); assert.equal(claimed.players[2].hand.filter(c => c.id === spent.id).length, 1); assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1); assert.throws(() => place(claimed), /already attempted/); }); void test('Inflation placement is a CHOAM Mentat action with a response before the token enters play', () => { const initial = contest(fixture()); assert.throws(() => place(initial), /Mentat/); let g = ready(initial); assert.throws( () => applyAction(g, 'e', { type: 'choamInflation', side: 'double' }), /CHOAM/, ); assert.throws( () => applyAction(g, 'c', { type: 'choamInflation', side: 'invalid' }), /Choose/, ); g = applyAction(g, 'e', { type: 'ready' }); g = place(g); assert.equal(g.response?.kind, 'choamInflation'); assert.equal(g.inflation, undefined); assert.deepEqual(g.ready, []); g = allow(g); assert.deepEqual(g.inflation, { side: 'double', placedTurn: 1, updatedTurn: 1, flipped: false, }); assert.equal(g.inflationUsed, true); assert.equal(charityMultiplier(g), 1); assert.equal(initial.inflation, undefined); }); void test('both Inflation orders last two charity phases, flip once and are permanently removed', () => { for (const side of ['double', 'cancel'] as const) { let g = allow(place(ready(fixture()), side)); for (let i = 0; i < 2; i++) { const expected = (i === 0 ? side === 'double' : side === 'cancel') ? 2 : 0; const before = g.players[0].spice; g = allow(nextCharity(g)); assert.equal(charityMultiplier(g), expected); assert.equal(g.players[0].spice, before + 6 * expected); g = mentat(g); assert.equal(g.response, null); if (i === 0) { assert.equal(g.inflation?.flipped, true); assert.notEqual(g.inflation?.side, side); } else assert.equal(g.inflation, null); } assert.equal(g.inflationUsed, true); assert.throws(() => place(g), /already used/); const before = g.players[0].spice; g = allow(nextCharity(g)); assert.equal(g.players[0].spice, before + 6); } }); void test('Double changes ordinary payout, preserves ordinary eligibility and doubles advanced Bene Gesserit', () => { const initial = allow(nextCharity(allow(place(ready(fixture(true)))))); for (const [starting, ordinary, bg] of [ [0, 4, 4], [1, 2, 4], [2, 0, 4], [20, 0, 4], ]) { let g = structuredClone(initial); g.players[1].spice = starting; g.players[2].spice = starting; assert.equal(charityAmount(g, g.players[1]), ordinary); assert.equal(charityAmount(g, g.players[2]), bg); const before = g.players[0].spice; g = charityTurn(g, 'e'); if (ordinary) g = applyAction(g, 'e', { type: 'charity' }); else assert.throws(() => applyAction(g, 'e', { type: 'charity' }), /fewer/); g = charityTurn(g, 'b'); g = allow(applyAction(g, 'b', { type: 'charity' })); assert.equal(g.players[1].spice, starting + ordinary); assert.equal(g.players[2].spice, starting + bg); assert.equal(g.players[0].spice, before - ordinary - bg); } }); void test('Cancel suppresses all claims and CHOAM income, including wealthy advanced Bene Gesserit', () => { let g = nextCharity(allow(place(ready(fixture(true)), 'cancel'))); assert.equal(g.response, null); assert.equal(g.players[0].spice, 20); for (const id of g.order) { g = charityTurn(g, id); const p = g.players.find(player => player.id === id)!; assert.equal(charityAmount(g, p), 0); assert.throws(() => applyAction(g, id, { type: 'charity' }), /fewer/); assert.equal(viewGame(g, id).charity.incomePending, false); } g = ready(g); assert.equal(g.phase, 3); }); void test('canceling doubled CHOAM income preserves doubled charity from the bank', () => { let g = cancel(nextCharity(contest(allow(place(ready(fixture(true))))))); g.players[0].spice = 0; g.players[1].spice = 1; g = applyAction(g, 'c', { type: 'charity' }); g = charityTurn(g, 'e'); g = applyAction(g, 'e', { type: 'charity' }); g = charityTurn(g, 'b'); g = allow(applyAction(g, 'b', { type: 'charity' })); assert.deepEqual( g.players.map((p) => p.spice), [4, 3, 24], ); assert.equal(g.inflation?.side, 'double'); }); void test('canceling doubled wealthy Bene Gesserit charity does not charge CHOAM', () => { let g = allow(nextCharity(allow(place(ready(fixture(true)))))); const before = g.players[0].spice; g = charityTurn(g, 'b'); g = cancel(applyAction(contest(g), 'b', { type: 'charity' })); assert.equal(g.players[0].spice, before); assert.equal(g.players[2].spice, 20); }); void test('canceled placement preserves the token for a later Mentat but prevents another attempt this phase', () => { let g = cancel(place(ready(contest(fixture())))); assert.equal(g.inflationUsed, undefined); assert.equal(g.inflation, undefined); assert.throws(() => place(g, 'cancel'), /already attempted/); g.turn++; g = allow(place(g, 'cancel')); assert.equal(g.inflation?.side, 'cancel'); assert.equal(g.inflation?.placedTurn, 2); }); void test('mandatory flip and removal do not open a Karama window and do not repeat on phase reentry', () => { let g = allow(nextCharity(allow(place(ready(fixture()))))); g = mentat(g); assert.equal(g.inflation?.side, 'cancel'); const before = structuredClone(g.inflation); g = mentat(g); assert.deepEqual(g.inflation, before); assert.equal(g.response, null); const card = g.deck.find((c) => c.effect === 'karama')!; g.players[1].hand.push(card); assert.throws( () => applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' }), RuleError, ); }); void test('Double prohibits bribes immediately, then permits them after flipping to Cancel', () => { let g = allow(place(ready(fixture()))); g.phase = 0; assert.throws( () => applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 }), /Bribes are prohibited/, ); g = mentat(allow(nextCharity(g))); g.phase = 0; g = applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 }); assert.equal(g.players[2].bribes, 1); }); void test('existing bribes still settle at Mentat when Inflation flips onto Double', () => { let g = allow(place(ready(fixture()), 'cancel')); g = allow(nextCharity(g)); g = applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 3 }); g = mentat(g); assert.equal(g.inflation?.side, 'double'); assert.equal(g.players[2].spice, 23); assert.equal(g.players[2].bribes, 0); }); void test('Inflation leaves Spice Production payout unchanged and CHOAM income alone does not trigger it', () => { let g = fixture(); g.techTokens = createTechTokens(); g.techTokens.production.owner = 'e'; g = allow(nextCharity(allow(place(ready(g))))); assert.equal(g.techTokens!.production.spice, 0); g.players[1].spice = 0; g = applyAction(g, 'e', { type: 'charity' }); assert.equal(g.players[1].spice, 4); assert.equal(g.techTokens!.production.spice, 1); g = ready(g); assert.equal(g.players[1].spice, 5); }); void test('Amal phase opening precedes the mandatory Mentat flip', () => { let g = allow(nextCharity(allow(place(ready(fixture()))))); g.expansions.push('ix'); g.deck = treacheryDeck(['ix']); const amal = g.deck.find((c) => c.effect === 'amal')!; g.deck = g.deck.filter((c) => c.id !== amal.id); g.players[0].hand.push(amal); g = mentat(g); assert.ok(g.phaseOpening); assert.equal(g.inflation?.side, 'double'); const before = g.players[0].spice; g = applyAction(g, 'c', { type: 'card', card: amal.id }); assert.equal(g.players[0].spice, Math.floor(before / 2)); g = ready(g); assert.equal(g.inflation?.side, 'cancel'); }); void test('Inflation lifecycle persists across JSON reconnects with identical public status', () => { let g = allow(place(ready(fixture()))); g = JSON.parse(JSON.stringify(g)) as Game; assert.deepEqual(viewGame(g, 'c').inflation, viewGame(g, 'e').inflation); g = mentat(allow(nextCharity(g))); g = JSON.parse(JSON.stringify(g)) as Game; g = mentat(allow(nextCharity(g))); assert.equal(g.inflation, null); assert.equal(viewGame(g, 'b').inflationUsed, true); }); void test('all AI levels place Inflation legally, finish its response and do not claim canceled charity', () => { for (const difficulty of DIFFICULTIES) { let g = ready(fixture(true)); g.players[0].bot = difficulty; g = applyAction(g, 'c', botActions(viewGame(g, 'c'))[0]); assert.equal(g.response, null); assert.equal(g.inflationUsed, true); g = allow(g); assert.equal(botActions(viewGame(g, 'c'))[0].type, 'ready'); if (g.inflation!.side === 'double') g = mentat(allow(nextCharity(g))); g = allow(nextCharity(g)); assert.equal(charityMultiplier(g), 0); g.players.forEach((p) => { p.bot = difficulty; p.spice = 0; }); for (const id of g.order) { assert.equal(g.charityOrder?.remaining[0], id); const actions = botActions(viewGame(g, g.charityOrder!.remaining[0])); assert.equal(actions[0]?.type, 'ready'); g = applyAction(g, id, actions[0]); } } });