import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { viewGame, type Action, type Game } from '../game/engine'; import { truthFactAnswer } from '../game/truthtrance'; import { windowCardOpportunity } from '../game/window-card-play'; import { actTruthTurnKarama as act, advanceTruthTurnKarama as advance, answerTruthTurnKarama as answer, askTruthTurnKarama as ask, bindTruthTurnKarama as bind, closeTruthTurnKaramaLot as close, createTruthTurnKaramaFixture as fixture, declareTruthTurnKarama as declare, finishTruthTurnKarama as finish, nextTruthTurnKaramaNative as nativeStep, reachTruthTurnKaramaInvoice as reachInvoice, settleTruthTurnKarama as settle, truthTurnKaramaBid as bid, truthTurnKaramaClean as clean, truthTurnKaramaFact as fact, truthTurnKaramaGroup as group, truthTurnKaramaInventory as inventory, truthTurnKaramaPayment as payment, truthTurnKaramaPlay as play, truthTurnKaramaPlayer as player, truthTurnKaramaPromise as promise, truthTurnKaramaTraitors as traitors, winTruthTurnKaramaBid as invoice, type TruthTurnKaramaFixture, type TruthTurnKaramaStep, } from './fixture-truth-turn-karama'; function rejected(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); } function physical(game: Game, f: TruthTurnKaramaFixture, copies: 1 | 2 = 1) { for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1); assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors); } function receipt(game: Game, index = 0) { const stage = game.turnActionPromises![index].windowCards?.karamaAuction; assert.ok(stage); return stage; } void test('native Karama sources preserve actual opening two Truths, original printed card and recorded bidder controls', () => { for (const advanced of [false, true]) for (const window of ['active', 'waiting'] as const) { const f = fixture({ advanced, window }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0)); assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.karama]); assert.equal(player(f.game, f.target).spice, 10); assert.equal(f.game.advanced, advanced); assert.equal(f.source, 'ordinary-auction:1:1'); assert.deepEqual(windowCardOpportunity(f.game, 'karamaAuction'), f.opportunity); assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.inherited, f.base.sourceActions.length); assert.equal(f.sourceCounts.nativeContinuation, 0); assert.equal(f.sourceCounts.selected, f.base.sourceActionCount); assert.equal(f.sourceActions.filter(step => step.action.type === 'passBid').length, window === 'active' ? 2 : 0); // The held Karama creates three genuine Atreides-inspection response passes. assert.equal(f.sourceActionCount, (advanced ? 26 : 23) + (window === 'active' ? 2 : 0)); assert.equal(f.game.auction?.active, window === 'active' ? f.target : f.asker); assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors); } const f = fixture({ liveOpponent: true }); assert.equal(f.sourceCounts.inherited, 23); assert.equal(f.sourceCounts.nativeContinuation, 2); assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, 25); assert.equal(f.game.auction?.bidder, f.asker); assert.equal(f.game.auction?.bid, 1); assert.equal(f.game.auction?.active, f.target); assert.equal(f.game.auction?.passed.includes(f.asker), false); }); void test('a printed Karama purchase Yes grants the exact original lot for free and records one future physical cost', () => { for (const window of ['active', 'waiting'] as const) { const f = fixture({ window }); let game = bind(f, 'yes', play(f.source)).game; assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false }); const purse = player(game, f.target).spice; game = settle(act(game, f.target, f.purchase)); assert.equal(player(game, f.target).spice, purse); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true); assert.notEqual(windowCardOpportunity(game, 'karamaAuction')?.source, f.source); physical(game, f); } }); void test('a real winning invoice can spend the original printed Karama without spice even for a native overbid', () => { const f = fixture(); let game = bind(f, 'yes', play(f.source)).game; game = invoice(game, f, 11); assert.equal(game.auction?.bid, 11); assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(player(game, f.target).spice, 10); assert.equal(receipt(game).played, false); const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'decision'); assert.equal(guide.action.karama, true); assert.equal(guide.action.card, f.karama); game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); physical(game, f); }); void test('No to this printed auction cost blocks immediate and invoice Karama while preserving the native spice payment', () => { const f = fixture(); let game = bind(f, 'no', play(f.source)).game; rejected(game, f.target, f.purchase); game = invoice(game, f); rejected(game, f.target, f.karamaPayment); game = settle(act(game, f.target, f.spicePayment)); assert.equal(player(game, f.target).spice, 7); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); assert.ok(player(game, f.target).hand.some(card => card.id === f.karama)); assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true }); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); }); void test('whole printed-auction OR payment Yes preserves either actual free purchase or ordinary paid spice route', () => { const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient)); const bound = bind(f, 'yes', mixed).game; const bought = settle(act(bound, f.target, f.purchase)); assert.equal(promise(bought, f.target).fulfilled, true); assert.equal(player(bought, f.recipient).bribes, 0); let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true); paid = invoice(paid, f); paid = settle(act(paid, f.target, f.spicePayment)); assert.equal(player(paid, f.target).spice, 5); assert.ok(player(paid, f.target).hand.some(card => card.id === f.karama)); assert.equal(receipt(paid).played, false); physical(bought, f); physical(paid, f); }); void test('whole printed-auction AND payment No caps only completion and survives a free purchase until actual turn end', () => { const f = fixture(); let paid = bind(f, 'no').game; paid = act(paid, f.target, f.payment); rejected(paid, f.target, f.purchase); paid = invoice(paid, f); rejected(paid, f.target, f.karamaPayment); paid = settle(act(paid, f.target, f.spicePayment)); assert.deepEqual(receipt(paid), { source: f.source, played: false, closed: true }); assert.ok(!promise(paid, f.target).released); assert.ok(!promise(paid, f.target).fulfilled); assert.equal(paid.turnActionPromises![0].paymentClosed, false); paid = finish(paid); assert.equal(promise(paid, f.target).fulfilled, true); physical(paid, f); let bought = settle(act(bind(f, 'no').game, f.target, f.purchase)); assert.equal(receipt(bought).played, true); rejected(bought, f.target, f.payment); bought = act(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); rejected(bought, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.ok(!promise(bought, f.target).fulfilled); bought = finish(bought); assert.equal(promise(bought, f.target).fulfilled, true); physical(bought, f); }); void test('whole printed-auction OR payment No keeps its cumulative payment cap after the original lot closes', () => { const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient)); let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.purchase); rejected(game, f.target, f.payment); game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f); assert.equal(receipt(game).closed, true); assert.equal(receipt(game).played, false); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); }); void test('one funded current purse can pay all ten and still fulfill printed Karama with a free original purchase', () => { const f = fixture(), mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient, 10)); let game = bind(f, 'yes', mixed).game; game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 10 }); assert.equal(player(game, f.target).spice, 0); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase'); game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 10); assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); physical(game, f); }); void test('a required future bid precedes printed purchase and still permits either immediate or winning-invoice native cost', () => { const f = fixture({ liveOpponent: true }), mixed = group('and', bid(f.lot), play(f.source), payment(f.recipient)); for (const route of ['purchase', 'invoice'] as const) { const actions: TruthTurnKaramaStep[] = []; let game = bind(f, 'yes', mixed, f.game, 0, actions).game; rejected(game, f.target, f.purchase); game = act(game, f.target, f.payment, actions); const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 3); game = act(game, f.target, guide.action, actions); assert.equal(game.turnActionPromises![0].preparation?.bid?.maximum, 3); assert.equal(game.auction?.active, f.asker); assert.equal(receipt(game).played, false); if (route === 'invoice') { game = act(game, f.asker, { type: 'passBid' }, actions); assert.equal(game.auction?.active, f.observer); game = reachInvoice(game, f, actions); assert.equal(game.auction?.bid, 3); assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision?.player, f.target); const paying = viewGame(game, f.target).turnActionGuide; assert.ok(paying); assert.equal(paying.action.type, 'decision'); assert.equal(paying.action.karama, true); assert.equal(paying.action.card, f.karama); game = act(game, f.target, paying.action, actions); } else game = act(game, f.target, f.purchase, actions); game = settle(game, actions); assert.equal(player(game, f.target).spice, 8); assert.equal(promise(game, f.target).fulfilled, true); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); const bidAt = actions.findIndex(step => step.actor === f.target && step.action.type === 'bid'); const costAt = actions.findIndex(step => step.actor === f.target && (step.action.type === 'card' && step.action.card === f.karama || step.action.type === 'decision' && step.action.karama === true)); assert.ok(bidAt >= 0 && costAt > bidAt); physical(game, f); } }); void test('two actual Truths reject bid ten plus pay ten when the earlier No forbids the only free Karama invoice', () => { const f = fixture(); let game = bind(f, 'no', play(f.source)).game; const earlier = structuredClone(game.turnActionPromises![0]), history = structuredClone(game.truthHistory![0]); const second = group('and', bid(f.lot, 10), payment(f.recipient, 10)); const declared = declare(f, game, 1), asked = ask(declared.game, f.asker, f.target, second); assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(asked, f.target, 'no'); assert.deepEqual(game.turnActionPromises![0], earlier); assert.deepEqual(game.truthHistory![0], history); assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.karama)); physical(game, f, 2); }); void test('an original first-lot No permits the actual printed Karama in a later lot without crediting the earlier receipt', () => { const f = fixture({ window: 'waiting' }); const actions: TruthTurnKaramaStep[] = []; let game = bind(f, 'no', play(f.source), f.game, 0, actions).game; const bidderPurse = player(game, f.asker).spice, emperorPurse = player(game, f.target).spice; game = act(game, f.asker, { type: 'bid', amount: 1, allyPayment: 0 }, actions); game = act(game, f.observer, { type: 'passBid' }, actions); game = act(game, f.target, { type: 'passBid' }, actions); // H holds no Karama: its native one-spice purchase is automatic. The named // boundary helper really completes Emperor income, H bonus and next-lot peek. game = close(game, f, actions); assert.equal(game.auction?.index, 1); assert.equal(player(game, f.asker).spice, bidderPurse - 1); assert.equal(player(game, f.target).spice, emperorPurse + 1); assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard)); assert.equal(promise(game, f.target).fulfilled, true); const evidence = structuredClone(receipt(game)); assert.equal(evidence.played, false); assert.equal(evidence.closed, true); const next = windowCardOpportunity(game, 'karamaAuction'); assert.ok(next); assert.notEqual(next.source, f.source); const nextCard = game.auction!.cards[game.auction!.index].id, purse = player(game, f.target).spice; game = settle(act(game, f.target, f.purchase, actions), actions); assert.ok(player(game, f.target).hand.some(card => card.id === nextCard)); assert.equal(player(game, f.target).spice, purse); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(receipt(game), evidence); assert.ok(actions.some(step => step.actor === f.asker && step.action.type === 'bid')); physical(game, f); }); void test('actual later Karama shipment is a different role and cannot fulfill an expired auction alternative', () => { const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient)); let game = close(bind(f, 'yes', mixed).game, f); assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true }); game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true); game = act(game, f.target, { type: 'card', card: f.karama, mode: 'shipment', target: f.target }); assert.ok(game.karamaShipping); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(receipt(game).played, false); assert.ok(!promise(game, f.target).fulfilled); const purse = player(game, f.target).spice; game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 })); assert.equal(player(game, f.target).spice, purse - 1, 'Native Karama halves the original two-spice desert tariff'); assert.equal(player(game, f.target).forces['polar_sink:0'], 1); game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game).played, false); physical(game, f); }); void test('Karama guide packets retain original lot and physical card identity and reject stale sources or owners', () => { const f = fixture(), declared = declare(f); rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed: play(`${f.source}:stale`) } }); rejected(declared.game, f.target, { type: 'truthAsk', question: f.question }); let game = bind(f).game; game = act(game, f.target, f.payment); const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.karama); assert.equal(guide.action.mode, 'purchase'); assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event); assert.equal(guide.action.turnActionPromisePhase, 3); assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key))); assert.deepEqual(viewGame(game, f.observer).turnActionPromises, []); assert.equal(viewGame(game, f.observer).turnActionGuide, null); rejected(game, f.observer, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-event' }); rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 }); const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet)); rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); const second = declare(f, game, 1); rejected(second.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(f.source) } }); physical(game, f); }); void test('all four minimal profiles execute funded native printed Karama guides including a required first bid', () => { for (const joint of [false, true]) { const f = fixture({ liveOpponent: joint }), mixed = joint ? group('and', bid(f.lot), play(f.source), payment(f.recipient)) : f.mixed; const asked = ask(declare(f).game, f.asker, f.target, mixed); for (const difficulty of DIFFICULTIES) { let game = structuredClone(asked); const actions: TruthTurnKaramaStep[] = []; const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty; const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty); assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions); for (let count = 0; count < 26 && !promise(game, f.target).fulfilled; count++) { const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty; const guide = view.turnActionGuide; if (guide) { const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event); assert.ok(action, `${joint}/${difficulty}`); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions); } else { const next = nativeStep(game); assert.ok(next, `${joint}/${difficulty}`); game = act(game, next.actor, next.action, actions); } } assert.equal(promise(game, f.target).fulfilled, true, `${joint}/${difficulty}`); assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe')); assert.ok(actions.some(step => step.actor === f.target && (step.action.type === 'card' && step.action.card === f.karama || step.action.type === 'decision' && step.action.karama === true))); if (joint) assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bid')); game = settle(game); physical(game, f); } } });