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 { 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; dueTurn:number}; function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before, 'Rejected consent and travel actions leave the game unchanged.'); } function offer(game: Game, proposer: string, target: string, travel: Term) { const next = act(game, proposer, {type:'dealOffer', target, terms:'Agreed travel on a later turn.', travel}); const deal = next.deals?.at(-1); assert.ok(deal); return {game:next, id:deal.id}; } 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]; } function receipt(game: Game, id: string) { const row = game.travelPromises?.find(item => item.sourceDeal === id); assert.ok(row); return row; } function endCurrent(game: Game, target: string) { assert.equal(game.phase, 5); assert.equal(game.active, target); return settle(act(game, target, {type:'endMovement'})); } function dueShipment(game: Game, target: string, dueTurn: number): Game { return advance(game, state => state.turn === dueTurn && state.phase === 5 && state.active === target && clean(state) && !state.pendingShipment); } /** The shared Basic two-human fixture reaches the actual first-turn shipment * opportunity through setup and phase actions. All agreements, transfers, * Charity and due-turn actions below are real player actions; no funds, clocks, * forces, choices or receipts are staged. */ void test('future consent does not reserve an empty purse; actual next-turn Charity funds the promised shipment', () => { const f = fixture(), dueTurn = f.game.turn + 1, originalCards = inventory(f.game); const purse = player(f.game, f.target).spice; assert.ok(purse > 0); let game = act(f.game, f.target, {type:'bribe', target:f.asker, amount:purse}); assert.equal(player(game, f.target).spice, 0); const offered = offer(game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); game = accept(offered.game, f.target, offered.id); assert.equal(player(game, f.target).spice, 0); assert.equal(receipt(game, offered.id).turn, dueTurn); assert.equal(receipt(game, offered.id).player, f.target); assert.equal(status(game, f.asker, offered.id), 'scheduled'); assert.equal(status(game, f.target, offered.id), 'scheduled'); assert.equal(viewGame(game, f.target).travelPromiseGuide ?? null, null); game = endCurrent(game, f.target); assert.equal(status(game, f.target, offered.id), 'scheduled'); game = advance(game, state => state.turn === dueTurn && state.phase === 2 && clean(state) && !state.ready.includes(f.target)); assert.equal(status(game, f.target, offered.id), 'pending'); assert.equal(player(game, f.target).spice, 0); game = act(game, f.target, {type:'charity'}); assert.equal(player(game, f.target).spice, 2); game = dueShipment(game, f.target, dueTurn); reject(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(game, f.asker, offered.id), 'fulfilled'); assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.deepEqual(inventory(game), originalCards); }); for (const answer of [true, false]) void test(`future movement ${answer ? 'requires' : 'forbids'} the real matching action in its due turn`, () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'move', territory:f.movementTerritory, minimum:1, answer, dueTurn}); let game = accept(offered.game, f.target, offered.id); assert.equal(status(game, f.target, offered.id), 'scheduled'); game = endCurrent(game, f.target); game = dueShipment(game, f.target, dueTurn); game = settle(act(game, f.target, f.shipmentAction)); if (answer) { reject(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove)); } else { reject(game, f.target, f.firstMove); game = settle(act(game, f.target, f.currentEnd)); } assert.equal(status(game, f.target, offered.id), 'fulfilled'); assert.deepEqual(inventory(game), f.originalCards); }); void test('an unaccepted offer remains available through its due turn and expires only afterwards', () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); let game = endCurrent(offered.game, f.target); game = dueShipment(game, f.target, dueTurn); assert.equal(game.deals!.find(row => row.id === offered.id)!.status, 'offered'); const accepted = accept(game, f.target, offered.id); assert.equal(status(accepted, f.target, offered.id), 'pending'); assert.equal(receipt(accepted, offered.id).turn, dueTurn); game = endCurrent(game, f.target); game = advance(game, state => state.turn > dueTurn); reject(game, f.target, {type:'dealRespond', deal:offered.id, accept:true}); assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); }); void test('mutual release before the due turn cancels the scheduled receipt without blocking current travel', () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); let game = accept(offered.game, f.target, offered.id); game = act(game, f.asker, {type:'dealRelease', deal:offered.id, agree:true}); assert.equal(status(game, f.target, offered.id), 'scheduled'); game = act(game, f.target, {type:'dealRelease', deal:offered.id, agree:true}); assert.equal(status(game, f.target, offered.id), 'released'); assert.equal(receipt(game, offered.id).releasedByDeal, true); game = endCurrent(game, f.target); game = dueShipment(game, f.target, dueTurn); assert.equal(status(game, f.asker, offered.id), 'released'); game = settle(act(game, f.target, f.currentEnd)); assert.equal(status(game, f.target, offered.id), 'released'); }); void test('an unfunded future promise remains pending until its actual travel turn can close', () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:20, answer:true, dueTurn}); let game = accept(offered.game, f.target, offered.id); game = endCurrent(game, f.target); game = dueShipment(game, f.target, dueTurn); assert.equal(status(game, f.target, offered.id), 'pending'); assert.ok(player(game, f.target).spice < 40); game = settle(act(game, f.target, f.currentEnd)); assert.equal(status(game, f.target, offered.id), 'void'); }); void test('a witnessed due shipment cannot be voluntarily made unaffordable', () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); let game = endCurrent(accept(offered.game, f.target, offered.id), f.target); game = dueShipment(game, f.target, dueTurn); const purse = player(game, f.target).spice; assert.ok(purse > 2); reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1}); game = act(game, f.target, {type:'bribe', target:f.asker, amount:purse - 2}); game = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(game, f.target, offered.id), 'fulfilled'); }); void test('public contradictions cannot be accepted and do not disturb the earlier future agreement', () => { const f = fixture(), dueTurn = f.game.turn + 1; const term:Term = {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}; const first = offer(f.game, f.asker, f.target, term); let game = accept(first.game, f.target, first.id); const second = offer(game, f.asker, f.target, {...term, answer:false}); game = second.game; reject(game, f.target, {type:'dealRespond', deal:second.id, accept:true}); const third = offer(game, f.asker, f.target, {...term, territory:'carthag'}); game = third.game; reject(game, f.target, {type:'dealRespond', deal:third.id, accept:true}); assert.equal(status(game, f.target, first.id), 'scheduled'); assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered'); assert.equal(game.deals!.find(deal => deal.id === third.id)!.status, 'offered'); game = endCurrent(game, f.target); game = dueShipment(game, f.target, dueTurn); assert.equal(status(game, f.target, first.id), 'pending'); const purse = player(game, f.target).spice; assert.ok(purse > 2); reject(game, f.target, {type:'bribe', target:f.asker, amount:purse - 1}); game = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(game, f.target, first.id), 'fulfilled'); }); void test('all minimal autopilot profiles complete an accepted future shipment on the due turn', () => { const f = fixture(), dueTurn = f.game.turn + 1; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); let game = accept(offered.game, f.target, offered.id); game = endCurrent(game, f.target); const due = dueShipment(game, f.target, dueTurn); for (const difficulty of DIFFICULTIES) { let controlled = act(due, f.target, {type:'setAutopilot', difficulty}); for (let step = 0; step < 8 && status(controlled, f.target, offered.id) === 'pending'; step++) { const actions = botActions(viewGame(controlled, f.target)); assert.ok(actions.length, `${difficulty} needs a legal native continuation`); controlled = settle(act(controlled, f.target, actions[0])); } assert.equal(status(controlled, f.target, offered.id), 'fulfilled', difficulty); } }); void test('a game that naturally ends before the scheduled turn voids the unfulfilled term', () => { const f = fixture(), dueTurn = 11; const offered = offer(f.game, f.asker, f.target, {kind:'ship', territory:f.shipmentTerritory, minimum:1, answer:true, dueTurn}); let game = accept(offered.game, f.target, offered.id); game = endCurrent(game, f.target); game = advance(game, state => state.status === 'finished'); assert.ok(game.turn < dueTurn); assert.equal(status(game, f.asker, offered.id), 'void'); assert.equal(receipt(game, offered.id).fulfilled, false); });