import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthAlliance as act, advanceTruthAlliance as advance, createTruthAllianceFixture, truthAllianceClean as clean, truthAllianceInventory as inventory, truthAlliancePlayer as player } from './fixture-truth-alliance'; import { declareTruthBribe } from './fixture-truth-bribe'; type PledgePromise = { player: string; ally: string; minimum: number; answer: boolean; turn: number; phase: number; fulfilled?: boolean; released?: boolean }; const promise = (game: Game): PledgePromise => { const rows = (game as Game & { pledgePromises?: PledgePromise[] }).pledgePromises; assert.ok(rows?.length); return rows.at(-1)!; }; function rejected(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); } /** A real Basic setup, storm, worm, ordinary Nexus, reciprocal consent and * later Bidding opening. Only the source fixture's original unopened deal and * Spice order are selected; no alliance, purse, aid or question is staged. */ function fixture(phase: 3 | 5 = 3) { const base = createTruthAllianceFixture({ consent: 'allied' }); const game = advance(base.game, state => state.phase === phase && (phase !== 3 || !!state.auction) && clean(state)); assert.equal(player(game, base.target).ally, base.partner); assert.equal(player(game, base.partner).ally, base.target); assert.deepEqual(inventory(game), base.originalCards); return { ...base, game, ally: base.partner, physical: inventory(game) }; } type Fixture = ReturnType; function ask(f: Fixture, source: Game, minimum: number) { let game = declareTruthBribe(f, source).game; const askerView = viewGame(game, f.asker); assert.ok(askerView.truthPledgeTargets.includes(f.target)); game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target, turn: game.turn, phase: game.phase, ally: f.ally, minimum } }); return game; } function answer(game: Game, actor: string, response: 'yes' | 'no') { return act(game, actor, { type: 'truthAnswer', answer: response }); } void test('Basic Yes binds a future accepted same-value ally-funding update without treating it as a payment', () => { const f = fixture(); let game = act(f.game, f.target, { type: 'pledgeAid', amount: 3 }); const priorSpice = player(game, f.target).spice; const originalAid = structuredClone(game.aid[f.target]); game = ask(f, game, 3); assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes')); assert.equal(viewGame(game, f.asker).truthPledgeAnswers, null); game = answer(game, f.target, 'yes'); assert.equal(promise(game).player, f.target); assert.equal(promise(game).ally, f.ally); assert.equal(promise(game).minimum, 3); assert.ok(!promise(game).fulfilled, 'A pre-answer pledge does not fulfill a future update'); assert.deepEqual(game.aid[f.target], originalAid); const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'pledgeAid'); assert.equal(guide.action.amount, 3); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.doesNotThrow(() => act(game, f.target, action)); } game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true); assert.deepEqual(game.aid[f.target], originalAid); assert.equal(player(game, f.target).spice, priorSpice); assert.deepEqual(inventory(game), f.physical); }); void test('Basic No rejects a qualifying pledge without changing the real wallet or escrow', () => { const f = fixture(); let game = ask(f, f.game, 3); assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('no')); game = answer(game, f.target, 'no'); assert.equal(promise(game).answer, false); game = act(game, f.target, { type: 'pledgeAid', amount: 2 }); const aid = structuredClone(game.aid[f.target]), spice = player(game, f.target).spice; rejected(game, f.target, { type: 'pledgeAid', amount: 3 }); assert.deepEqual(game.aid[f.target], aid); assert.equal(player(game, f.target).spice, spice); assert.deepEqual(inventory(game), f.physical); }); void test('an unaffordable Yes is unavailable and cannot be forced by the client', () => { const f = fixture(), available = player(f.game, f.target).spice; const game = ask(f, f.game, available + 1); assert.deepEqual(viewGame(game, f.target).truthPledgeAnswers, ['no']); rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); }); void test('an unfulfilled Yes prevents the owner voluntarily spending its remaining pledge capacity', () => { const f = fixture(); let game = ask(f, f.game, 4); game = answer(game, f.target, 'yes'); const available = player(game, f.target).spice; rejected(game, f.target, { type: 'bribe', target: f.asker, amount: available - 1 }); assert.ok(!promise(game).fulfilled && !promise(game).released); const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.physical); }); void test('a No pledge commitment stops applying when actual Bidding closes', () => { const f = fixture(); let game = ask(f, f.game, 3); game = answer(game, f.target, 'no'); game = advance(game, state => state.phase === 4 && clean(state)); assert.ok(promise(game).released || promise(game).phase !== game.phase, 'The earlier phase commitment cannot constrain a new phase'); assert.deepEqual(inventory(game), f.physical); }); void test('Basic Shipment and Movement Yes preserves existing escrow and uses native pledge funding once', () => { const f = fixture(5); let game = act(f.game, f.target, { type: 'pledgeAid', amount: 1 }); const before = structuredClone(game), recipientSpice = player(game, f.ally).spice; game = ask(f, game, 4); assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes')); game = answer(game, f.target, 'yes'); assert.equal(promise(game).phase, 5); assert.equal(game.aid[f.target].amount, 1); assert.equal(player(game, f.target).spice, player(before, f.target).spice); const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4); game = act(game, f.target, guide.action); assert.equal(game.aid[f.target].recipient, f.ally); assert.equal(game.aid[f.target].amount, 4); assert.equal(player(game, f.target).spice, player(before, f.target).spice - 3); assert.equal(player(game, f.ally).spice, recipientSpice, 'Pledged spice has not been paid to the ally'); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.physical); }); void test('Basic Shipment and Movement No rejects qualifying aid and retains the accepted lower escrow', () => { const f = fixture(5); let game = ask(f, f.game, 4); game = answer(game, f.target, 'no'); game = act(game, f.target, { type: 'pledgeAid', amount: 3 }); const before = structuredClone(game); rejected(game, f.target, { type: 'pledgeAid', amount: 4 }); assert.deepEqual(game.aid[f.target], before.aid[f.target]); assert.equal(player(game, f.target).spice, player(before, f.target).spice); game = act(game, f.target, { type: 'pledgeAid', amount: 2 }); assert.equal(game.aid[f.target].amount, 2); assert.deepEqual(inventory(game), f.physical); }); for (const response of ['yes', 'no'] as const) { void test(`a live ${response} pledge excludes a new independent structured action question`, () => { const f = fixture(); let game = ask(f, f.game, 3); game = answer(game, f.target, response); const remainingTruth = f.truths.find(id => player(game, f.asker).hand.some(card => card.id === id)); assert.ok(remainingTruth, 'The native source retains its second physical Truthtrance'); game = declareTruthBribe({ ...f, truths: [remainingTruth] }, game).game; rejected(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: game.turn, recipient: f.asker, minimum: 1 } }); assert.deepEqual(inventory(game), f.physical); }); } void test('another player’s final deal acceptance releases a now-unaffordable pledge Yes after the real payment', () => { const f = fixture(); const minimum = 4, available = player(f.game, f.target).spice; const amount = available - minimum + 1; assert.ok(amount > 0); // The donor actually offers and therefore consents to the immediate // payment before the later Truthtrance answer. No pending deal is staged. let game = act(f.game, f.target, { type: 'dealOffer', target: f.asker, terms: 'A previously agreed payment.', payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount } }); const deal = game.deals?.at(-1); assert.ok(deal); game = ask(f, game, minimum); game = answer(game, f.target, 'yes'); assert.ok(!promise(game).fulfilled && !promise(game).released); const before = structuredClone(game), oldBribes = player(game, f.asker).bribes; // The other player’s final acceptance, rather than a new voluntary action // by the payer, executes the already-consented native immediate bribe. game = act(game, f.asker, { type: 'dealRespond', deal: deal.id, accept: true }); assert.equal(player(game, f.target).spice, player(before, f.target).spice - amount); assert.equal(player(game, f.asker).bribes, oldBribes + amount); assert.deepEqual(game.aid[f.target], before.aid[f.target]); assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); assert.deepEqual(inventory(game), f.physical); }); void test('a hidden pledge amount does not reveal whether a public Yes is fulfilled', () => { const f = fixture(); let bound = ask(f, f.game, 3); bound = answer(bound, f.target, 'yes'); // Both are real updates to the same alliance after the same public answer. // Their publicly logged action and event count are identical, but only the // private escrow amount and fulfillment differ. const below = act(bound, f.target, { type: 'pledgeAid', amount: 2 }); const fulfilled = act(bound, f.target, { type: 'pledgeAid', amount: 3 }); assert.ok(!promise(below).fulfilled); assert.equal(promise(fulfilled).fulfilled, true); const left = viewGame(below, f.asker), right = viewGame(fulfilled, f.asker); assert.deepEqual(left.log, right.log); assert.deepEqual(left.truthHistory, right.truthHistory); assert.deepEqual(left.aid, right.aid); assert.deepEqual(left.pledgePromises, []); assert.deepEqual(right.pledgePromises, []); assert.deepEqual(left.truthPledgeTargets, right.truthPledgeTargets); assert.deepEqual(left.truthBribeTargets, right.truthBribeTargets); const remainingTruth = f.truths.find(id => player(below, f.asker).hand.some(card => card.id === id)); assert.ok(remainingTruth); const rejection = (source: Game) => { const waiting = declareTruthBribe({ ...f, truths: [remainingTruth] }, source).game; const before = structuredClone(waiting); let reason = ''; try { act(waiting, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: waiting.turn, recipient: f.asker, minimum: 1 } }); } catch (error) { reason = error instanceof Error ? error.message : String(error); } assert.ok(reason, 'The independent future-action question remains unavailable'); assert.deepEqual(waiting, before); return reason; }; assert.equal(rejection(below), rejection(fulfilled)); assert.deepEqual(inventory(below), f.physical); assert.deepEqual(inventory(fulfilled), f.physical); });