import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { actTruthTravel as act, advanceTruthTravel as advance, createTruthTravelFixture as fixture, settleTruthTravel as settle, truthTravelClean as clean, truthTravelInventory as inventory, truthTravelPlayer as player } from './fixture-truth-travel'; type Term = { kind: 'ship' | 'move'; territory: string; minimum: number; answer: boolean }; function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before, 'Failed consent and action packets preserve the source table.'); } function offer(game: Game, proposer: string, target: string, travel: Term, amount: number) { const payment = { kind: 'immediateBribe', payer: proposer, recipient: target, amount }; const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Payment for agreed travel.', payment, travel }); const deal = next.deals?.at(-1); assert.ok(deal); return { game: next, id: deal.id, payment, travel }; } function accept(game: Game, target: string, id: string) { return act(game, target, { type: 'dealRespond', deal: id, accept: true }); } function status(game: Game, viewer: string, id: string) { return viewGame(game, viewer).dealTravelStatus[id]; } /** Native Basic Atreides/Emperor game at the real pre-shipment or shipped * phase-five boundary. Only original unopened deals/Spice and scalar lotteries * are selected by the shared TruthTravel fixture; all agreements and payments * below are real game actions. */ void test('final consent atomically pays the proposer bribe and creates the travel obligation', () => { const f = fixture(), initial = f.game; const beforePayer = player(initial, f.asker).spice, beforeCash = player(initial, f.target).spice, beforeEscrow = player(initial, f.target).bribes; assert.ok(beforePayer >= 3); const offered = offer(initial, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3); assert.equal(player(offered.game, f.asker).spice, beforePayer); assert.equal(player(offered.game, f.target).bribes, beforeEscrow); const game = accept(offered.game, f.target, offered.id); assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active'); assert.equal(player(game, f.asker).spice, beforePayer - 3); assert.equal(player(game, f.target).spice, beforeCash); assert.equal(player(game, f.target).bribes, beforeEscrow + 3); assert.equal(game.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1); assert.equal(status(game, f.target, offered.id), 'pending'); reject(game, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); const next = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(next, f.target, offered.id), 'fulfilled'); assert.equal(player(next, f.target).spice, beforeCash - f.shipmentCost); assert.equal(player(next, f.target).bribes, beforeEscrow + 3); assert.deepEqual(inventory(next), f.originalCards); const collection = advance(next, state => state.turn === initial.turn && state.phase === 7 && clean(state)); const cashBeforeMentat = player(collection, f.target).spice; assert.equal(player(collection, f.target).bribes, beforeEscrow + 3); const mentat = advance(collection, state => state.turn === initial.turn && state.phase === 8 && clean(state)); assert.equal(player(mentat, f.target).spice, cashBeforeMentat + beforeEscrow + 3); assert.equal(player(mentat, f.target).bribes, 0); assert.deepEqual(inventory(mentat), f.originalCards); }); void test('a payer who spends after offering cannot partly activate or pay the deal', () => { const f = fixture(), payerCash = player(f.game, f.asker).spice; assert.ok(payerCash >= 1); const offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, payerCash); const spent = act(offered.game, f.asker, { type: 'bribe', target: f.target, amount: 1 }); assert.equal(player(spent, f.asker).spice, payerCash - 1); const before = structuredClone(spent); reject(spent, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); assert.deepEqual(spent, before); assert.equal(spent.deals!.find(deal => deal.id === offered.id)!.status, 'offered'); assert.equal(spent.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); assert.deepEqual(inventory(spent), f.originalCards); }); void test('an infeasible promised shipment rolls back payment and final acceptance', () => { const f = fixture({ window: 'shipped' }), before = f.game; const offered = offer(before, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1); const payer = player(offered.game, f.asker).spice, cash = player(offered.game, f.target).spice, escrow = player(offered.game, f.target).bribes; reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered'); assert.equal(player(offered.game, f.asker).spice, payer); assert.equal(player(offered.game, f.target).spice, cash); assert.equal(player(offered.game, f.target).bribes, escrow); assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); }); void test('the recipient cannot use incoming Mentat escrow to fund the promised shipment', () => { const f = fixture(), cash = player(f.game, f.target).spice; assert.ok(cash >= 2); const spent = act(f.game, f.target, { type: 'bribe', target: f.asker, amount: cash - 1 }); assert.equal(player(spent, f.target).spice, 1); assert.ok(player(spent, f.asker).spice >= 3); const offered = offer(spent, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 3); reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); assert.equal(player(offered.game, f.target).spice, 1); assert.equal(offered.game.deals!.find(deal => deal.id === offered.id)!.status, 'offered'); assert.equal(offered.game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); }); void test('unanimous release ends the pending travel obligation without refunding payment', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }, 1); let game = accept(offered.game, f.target, offered.id); const paid = player(game, f.asker).spice, escrow = player(game, f.target).bribes; game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true }); assert.equal(status(game, f.target, offered.id), 'pending'); game = act(game, f.target, { type: 'dealRelease', deal: offered.id, agree: true }); assert.equal(status(game, f.target, offered.id), 'released'); assert.equal(player(game, f.asker).spice, paid); assert.equal(player(game, f.target).bribes, escrow); game = settle(act(game, f.target, f.currentEnd)); assert.equal(player(game, f.asker).spice, paid); assert.equal(player(game, f.target).bribes, escrow); }); void test('paid travel excludes deferred and multiparty payment variants', () => { const f = fixture(), travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const base = { type: 'dealOffer', target: f.target, terms: 'Travel.', travel } satisfies Action; reject(f.game, f.asker, { ...base, payment: { kind: 'deferredBribe', payer: f.asker, recipient: f.target, amount: 1, dueTurn: f.game.turn } }); reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel, payment: { kind: 'immediateBribe', payer: f.asker, recipient: f.target, amount: 1 } }); }); void test('the travelling recipient can pay the proposer at final consent and then complete shipment', () => { const f = fixture(), payerBefore = player(f.game, f.target).spice, recipientBefore = player(f.game, f.asker).bribes; assert.ok(payerBefore >= f.shipmentCost + 1); const payment = { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: 1 }; const travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const offered = act(f.game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Payment for agreed travel.', payment, travel }); const deal = offered.deals!.at(-1)!; assert.equal(player(offered, f.target).spice, payerBefore); const accepted = accept(offered, f.target, deal.id); assert.equal(accepted.deals!.find(row => row.id === deal.id)!.status, 'active'); assert.equal(player(accepted, f.target).spice, payerBefore - 1); assert.equal(player(accepted, f.asker).bribes, recipientBefore + 1); assert.equal(accepted.travelPromises!.filter(row => row.sourceDeal === deal.id).length, 1); assert.equal(status(accepted, f.target, deal.id), 'pending'); const shipped = settle(act(accepted, f.target, f.shipmentAction)); assert.equal(status(shipped, f.target, deal.id), 'fulfilled'); assert.equal(player(shipped, f.target).spice, payerBefore - 1 - f.shipmentCost); assert.equal(player(shipped, f.asker).bribes, recipientBefore + 1); assert.deepEqual(inventory(shipped), f.originalCards); }); void test('reverse payment that consumes the travel funds rolls back the final consent atomically', () => { const f = fixture(), cash = player(f.game, f.target).spice, recipientEscrow = player(f.game, f.asker).bribes; assert.ok(cash >= f.shipmentCost); const payment = { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount: cash }; const travel = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const offered = act(f.game, f.asker, { type: 'dealOffer', target: f.target, terms: 'Payment for agreed travel.', payment, travel }); const deal = offered.deals!.at(-1)!; reject(offered, f.target, { type: 'dealRespond', deal: deal.id, accept: true }); assert.equal(offered.deals!.find(row => row.id === deal.id)!.status, 'offered'); assert.equal(player(offered, f.target).spice, cash); assert.equal(player(offered, f.asker).bribes, recipientEscrow); assert.equal(offered.travelPromises?.some(row => row.sourceDeal === deal.id) ?? false, false); assert.deepEqual(inventory(offered), f.originalCards); });