import assert from 'node:assert/strict'; import test from 'node:test'; import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthTravel as act, createTruthTravelFixture as fixture, advanceTruthTravel as advance, settleTruthTravel as settle, truthTravelInventory as inventory, truthTravelPlayer as player } from './fixture-truth-travel'; import { bindTruthBribe, createTruthBribeFixture } from './fixture-truth-bribe'; import { createTruthMixedBribeFixture, declareTruthMixedBribe } from './fixture-truth-mixed-bribe'; 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, 'Rejected actions do not modify the table.'); } function offer(game: Game, proposer: string, target: string, travel: Term) { const next = act(game, proposer, { type: 'dealOffer', target, terms: 'Agreed travel this 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 found = game.travelPromises?.find(row => row.sourceDeal === id); assert.ok(found, 'Final consent creates a source-linked native travel receipt.'); return found; } /** Original Basic base33 setup and real first-turn phase-five prefix from the * native TruthTravel fixture. Only its unopened deal/Spice order is selected; * these tests offer and accept the agreement through real player actions. */ void test('accepted shipment Yes preserves the real shipment, forbids divergence and settles once', () => { const f = fixture(), before = f.game; const term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const offered = offer(before, f.asker, f.target, term); assert.equal(offered.game.travelPromises?.length ?? 0, 0, 'An offer alone creates no commitment.'); const game = accept(offered.game, f.target, offered.id); assert.equal(receipt(game, offered.id).player, f.target); assert.equal(receipt(game, offered.id).asker, f.asker); for (const viewer of [f.asker, f.target]) assert.equal(status(game, viewer, offered.id), 'pending'); reject(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 }); reject(game, f.target, f.currentEnd); const next = settle(act(game, f.target, f.shipmentAction)); assert.equal(player(next, f.target).forces['polar_sink:0'], 1); assert.equal(player(next, f.target).reserves, player(before, f.target).reserves - 1); assert.equal(status(next, f.asker, offered.id), 'fulfilled'); assert.equal(status(next, f.target, offered.id), 'fulfilled'); assert.equal(next.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1); assert.deepEqual(inventory(next), f.originalCards); }); void test('a negative shipment term forbids its matching physical shipment and settles after closing', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: false }); let game = accept(offered.game, f.target, offered.id); assert.equal(status(game, f.target, offered.id), 'pending'); reject(game, f.target, f.shipmentAction); game = settle(act(game, f.target, f.currentEnd)); assert.equal(status(game, f.target, offered.id), 'fulfilled'); assert.equal(player(game, f.target).forces['polar_sink:0'] ?? 0, 0); assert.deepEqual(inventory(game), f.originalCards); }); for (const answer of [true, false]) void test(`accepted movement ${answer ? 'Yes' : 'No'} uses the original physical ground action`, () => { const f = fixture({ window: 'shipped' }); const offered = offer(f.game, f.asker, f.target, { kind: 'move', territory: f.movementTerritory, minimum: 1, answer }); let game = accept(offered.game, f.target, offered.id); assert.equal(receipt(game, offered.id).player, f.target); assert.equal(status(game, f.target, offered.id), 'pending'); if (answer) { reject(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove)); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1); } else { reject(game, f.target, f.firstMove); game = settle(act(game, f.target, f.currentEnd)); assert.equal(player(game, f.target).forces['imperial_basin:9'] ?? 0, 0); } assert.equal(status(game, f.asker, offered.id), 'fulfilled'); assert.deepEqual(inventory(game), f.originalCards); }); void test('both parties must release a pending obligation and only then may its target close', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); 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), 'pending'); reject(game, f.target, f.currentEnd); 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).released, true); game = settle(act(game, f.target, f.currentEnd)); assert.equal(status(game, f.asker, offered.id), 'released'); assert.deepEqual(inventory(game), f.originalCards); }); void test('a released deal cannot retain an unfulfilled live travel receipt', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); let game = accept(offered.game, f.target, offered.id); game = act(game, f.asker, { type: 'dealRelease', deal: offered.id, agree: true }); 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).fulfilled, false); assert.equal(receipt(game, offered.id).released, true); const corrupted = structuredClone(game); receipt(corrupted, offered.id).released = false; const before = structuredClone(corrupted); assert.throws(() => viewGame(corrupted, f.target)); assert.deepEqual(corrupted, before); reject(corrupted, f.target, f.currentEnd); }); void test('travel terms reject payment combinations, multiparty packets and malformed claims', () => { const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const basic = { type: 'dealOffer', target: f.target, terms: 'Agreed travel.', travel: term } satisfies Action; reject(f.game, f.asker, { ...basic, payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 1, dueTurn: f.game.turn } }); reject(f.game, f.asker, { type: 'dealOffer', targets: [f.target, f.asker], terms: 'Travel.', travel: term }); reject(f.game, f.asker, { ...basic, travel: { ...term, minimum: 0 } }); reject(f.game, f.asker, { ...basic, travel: { ...term, answer: 'yes' } }); reject(f.game, f.asker, { ...basic, travel: { ...term, territory: 'not_a_territory' } }); }); void test('two compatible accepted agreements share one real qualifying shipment', () => { const f = fixture(), term: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; 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); game = accept(second.game, f.target, second.id); assert.notEqual(receipt(game, first.id).event, receipt(game, second.id).event); assert.equal(status(game, f.target, first.id), 'pending'); assert.equal(status(game, f.target, second.id), 'pending'); const next = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(next, f.target, first.id), 'fulfilled'); assert.equal(status(next, f.target, second.id), 'fulfilled'); assert.equal(player(next, f.target).reserves, player(f.game, f.target).reserves - 1); assert.deepEqual(inventory(next), f.originalCards); }); void test('final consent to a conflicting obligation rolls back without disturbing the original', () => { const f = fixture(), yes: Term = { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }; const first = offer(f.game, f.asker, f.target, yes); let game = accept(first.game, f.target, first.id); const second = offer(game, f.asker, f.target, { ...yes, answer: false }); game = second.game; const before = structuredClone(game); reject(game, f.target, { type: 'dealRespond', deal: second.id, accept: true }); assert.deepEqual(game, before); assert.equal(game.deals!.find(deal => deal.id === second.id)!.status, 'offered'); assert.equal(game.travelPromises!.filter(row => row.sourceDeal === second.id).length, 0); assert.equal(status(game, f.target, first.id), 'pending'); const completed = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(completed, f.target, first.id), 'fulfilled'); }); void test('a pending travel term prevents final consent to a separate current-turn deferred bribe', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); let game = accept(offered.game, f.target, offered.id); game = act(game, f.asker, { type: 'dealOffer', target: f.target, terms: 'A separate payment this turn.', payment: { kind: 'deferredBribe', payer: f.target, recipient: f.asker, amount: 1, dueTurn: game.turn } }); const payment = game.deals!.at(-1)!; assert.notEqual(payment.id, offered.id); assert.equal(payment.status, 'offered'); const before = structuredClone(game); reject(game, f.target, { type: 'dealRespond', deal: payment.id, accept: true }); assert.deepEqual(game, before); assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active'); assert.equal(game.deals!.find(deal => deal.id === payment.id)!.status, 'offered'); assert.equal(status(game, f.target, offered.id), 'pending'); assert.equal(receipt(game, offered.id).released, false); const completed = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(completed, f.target, offered.id), 'fulfilled'); }); void test('final travel consent rejects a target with a live native Truthtrance bribe promise', () => { const f = createTruthBribeFixture(); const term: Term = { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true }; // Control: the same original timing supports the travel deal by itself. const control = offer(f.game, f.asker, f.target, term); assert.equal(status(accept(control.game, f.target, control.id), f.target, control.id), 'pending'); const bound = bindTruthBribe(f, 'yes').game; assert.ok(bound.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released)); const offered = offer(bound, f.asker, f.target, term); const before = structuredClone(offered.game); reject(offered.game, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); assert.deepEqual(offered.game, before); 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); assert.ok(offered.game.bribePromises?.some(row => row.player === f.target && !row.fulfilled && !row.released)); }); void test('a pending public travel deal leaves simple and mixed bribe questions askable with only Unknown', () => { const f = createTruthMixedBribeFixture(); const offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: 'polar_sink', minimum: 1, answer: true }); const accepted = accept(offered.game, f.target, offered.id); const asking = declareTruthMixedBribe(f, accepted).game; const simple: Action = { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: asking.turn, recipient: f.asker, minimum: 3 } }; for (const [question, kind] of [[simple, 'simple'], [f.questionAction, 'mixed']] as const) { const asked = act(asking, f.asker, question), target = viewGame(asked, f.target); assert.deepEqual(kind === 'simple' ? target.truthBribeAnswers : target.truthMixedBribeAnswers, ['unknown']); reject(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); reject(asked, f.target, { type: 'truthAnswer', answer: 'no' }); const answered = act(asked, f.target, { type: 'truthAnswer', answer: 'unknown' }); assert.equal(answered.truthHistory!.at(-1)!.answer, 'unknown'); assert.equal(answered.bribePromises?.length ?? 0, 0); assert.equal(answered.bribeExpressionPromises?.length ?? 0, 0); assert.equal(answered.mixedBribeQuestions?.length ?? 0, 0); assert.equal(status(answered, f.target, offered.id), 'pending'); assert.equal(receipt(answered, offered.id).released, false); } }); void test('an unanswered travel offer cannot acquire an obligation after its original turn', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); const nextTurn = advance(offered.game, game => game.turn > f.game.turn); assert.ok(nextTurn.turn > f.game.turn); reject(nextTurn, f.target, { type: 'dealRespond', deal: offered.id, accept: true }); assert.equal(nextTurn.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false); assert.equal(nextTurn.deals!.find(deal => deal.id === offered.id)!.status, 'offered'); assert.deepEqual(inventory(nextTurn), f.originalCards); }); void test('voluntary spice spending and premature ground movement cannot eliminate promised completion', () => { const f = fixture({ hajr: false }), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); let game = accept(offered.game, f.target, offered.id); reject(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 }); reject(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.shipmentAction)); assert.equal(status(game, f.target, offered.id), 'fulfilled'); assert.deepEqual(inventory(game), f.originalCards); const moved = fixture({ window: 'shipped', hajr: false }); const ground = offer(moved.game, moved.asker, moved.target, { kind: 'move', territory: moved.movementTerritory, minimum: 1, answer: true }); let awaiting = accept(ground.game, moved.target, ground.id); reject(awaiting, moved.target, moved.secondMove); reject(awaiting, moved.target, moved.currentEnd); awaiting = settle(act(awaiting, moved.target, moved.firstMove)); assert.equal(status(awaiting, moved.target, ground.id), 'fulfilled'); }); void test('a staged external loss makes an impossible agreement void without retroactive consent', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); const accepted = accept(offered.game, f.target, offered.id); // Isolate the aftermath of a foreign resource loss; this is not claimed as // an actual opponent action in the two-player native chronology. const externallyChanged = structuredClone(accepted); player(externallyChanged, f.target).spice = 0; const reconciled = normalizeAutomaticGame(externallyChanged); assert.equal(status(reconciled, f.target, offered.id), 'void'); assert.equal(receipt(reconciled, offered.id).released, true); assert.equal(reconciled.deals!.find(deal => deal.id === offered.id)!.status, 'active'); assert.deepEqual(inventory(reconciled), f.originalCards); let released = act(reconciled, f.asker, { type: 'dealRelease', deal: offered.id, agree: true }); assert.equal(status(released, f.target, offered.id), 'void'); released = act(released, f.target, { type: 'dealRelease', deal: offered.id, agree: true }); assert.equal(released.deals!.find(deal => deal.id === offered.id)!.status, 'released'); assert.equal(status(released, f.asker, offered.id), 'void'); assert.equal(status(released, f.target, offered.id), 'void'); assert.notEqual(receipt(released, offered.id).releasedByDeal, true); }); void test('all four autopilot profiles follow an accepted shipment commitment through legal actions', () => { const f = fixture(), offered = offer(f.game, f.asker, f.target, { kind: 'ship', territory: f.shipmentTerritory, minimum: 1, answer: true }); const accepted = accept(offered.game, f.target, offered.id); for (const difficulty of DIFFICULTIES) { let game = act(accepted, f.target, { type: 'setAutopilot', difficulty }); for (let step = 0; step < 8 && status(game, f.target, offered.id) === 'pending'; step++) { const actions = botActions(viewGame(game, f.target)); assert.ok(actions.length, `${difficulty} needs a legal continuation`); game = settle(act(game, f.target, actions[0])); } assert.equal(status(game, f.target, offered.id), 'fulfilled', difficulty); assert.deepEqual(inventory(game), f.originalCards); } });