293 lines
17 KiB
TypeScript
293 lines
17 KiB
TypeScript
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);
|
|
}
|
|
});
|