205 lines
11 KiB
TypeScript
205 lines
11 KiB
TypeScript
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);
|
|
});
|