Files
Dune/tests/public-deals-group-travel-runtime.test.ts
T
Quintine 6c1530382d
Verify and publish checkpoint container / container (push) Successful in 10m49s
Support immediate payments in group travel deals
2026-10-29 17:41:58 +11:00

216 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovement as act, truthMovementClean as clean, truthMovementInventory as inventory,
truthMovementPlayer as player } from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-factions';
const proposer = 'atreides', traveler = 'emperor', first = 'harkonnen', second = 'fremen';
const shipment: Action = {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0};
const movement: Action = {type:'move', from:'polar_sink:0', territory:'imperial_basin', sector:9, amount:1};
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean; dueTurn?:number};
function reach(game: Game, until: (game: Game) => boolean): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before the travel opportunity.');
const step = next(game); assert.ok(step, `Supply native continuation at ${game.turn}/${game.phase}`);
game = act(game, step.actor, step.action);
}
throw Error('The native travel opportunity was not reached.');
}
function settle(game: Game): Game {
return reach(game, state => clean(state) && !state.pendingShipment);
}
/** Four actual human Basic seats. Reorder only original unopened starting
* cards and Spice cards, and use native setup and phase actions to reach the
* Emperor's unused first-turn shipment. No resource, force, promise, vote or
* pending decision is installed by the test. */
function nativeSource(): Game {
let game = createGame('GROUPTRAVEL', newPlayer(proposer, 'Atreides', 'atreides'), false, []);
for (const [id, faction] of [[traveler,'emperor'], [first,'harkonnen'], [second,'fremen']] as const)
joinGame(game, newPlayer(id, id, faction));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, proposer, {type:'start'});
assert.equal(game.status, 'setup');
const cards = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 4);
assert.equal(cards.length, 4);
for (const [position, card] of cards.entries()) {
const from = game.deck.findIndex(row => row.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
assert.equal(game.status, 'playing');
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]);
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === traveler && clean(state));
assert.equal(player(game, traveler).shipped, false);
assert.ok(player(game, traveler).spice >= 2);
assert.ok(!game.travelPromises?.length);
return game;
}
let source: Game | undefined;
const fixture = () => structuredClone(source ??= nativeSource());
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected deal and travel actions preserve the table.');
}
function offer(game: Game, targets: string[], travel: Term) {
const nextGame = act(game, proposer, {type:'dealOffer', targets, terms:'A shared current-turn journey.', travel});
const deal = nextGame.deals?.at(-1); assert.ok(deal);
return {game:nextGame, id:deal.id};
}
function respond(game: Game, actor: string, id: string, accept = true) {
return act(game, actor, {type:'dealRespond', deal:id, accept});
}
function status(game: Game, actor: string, id: string) {
return viewGame(game, actor).dealTravelStatus[id];
}
function receipt(game: Game, id: string) {
const row = game.travelPromises?.find(promise => promise.sourceDeal === id); assert.ok(row);
return row;
}
void test('a named traveler accepts last and performs the real shipment for a three-party deal', () => {
const initial = fixture(), originalCards = inventory(initial);
const offered = offer(initial, [first, traveler],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
let game = offered.game;
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, first, offered.id);
assert.equal(game.deals!.at(-1)!.status, 'offered');
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
game = respond(game, traveler, offered.id);
assert.equal(game.deals!.at(-1)!.status, 'active');
assert.equal(receipt(game, offered.id).player, traveler);
assert.equal(receipt(game, offered.id).asker, proposer);
for (const viewer of [proposer, first, traveler, second]) assert.equal(status(game, viewer, offered.id), 'pending');
reject(game, traveler, {type:'endMovement'});
const completed = settle(act(game, traveler, shipment));
assert.equal(player(completed, traveler).forces['polar_sink:0'], 1);
assert.equal(player(completed, traveler).reserves, player(initial, traveler).reserves - 1);
for (const viewer of [proposer, first, traveler]) assert.equal(status(completed, viewer, offered.id), 'fulfilled');
assert.equal(completed.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
assert.deepEqual(inventory(completed), originalCards);
});
void test('four-party consent requires all other recipients first and release requires every participant', () => {
const offered = offer(fixture(), [traveler, first, second],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
let game = offered.game;
game = respond(game, second, offered.id);
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, first, offered.id);
game = respond(game, traveler, offered.id);
assert.equal(game.deals!.at(-1)!.status, 'active');
for (const actor of [proposer, second, first]) {
game = act(game, actor, {type:'dealRelease', deal:offered.id, agree:true});
assert.equal(status(game, traveler, offered.id), 'pending');
}
reject(game, traveler, {type:'endMovement'});
game = act(game, traveler, {type:'dealRelease', deal:offered.id, agree:true});
assert.equal(status(game, traveler, offered.id), 'released');
assert.equal(receipt(game, offered.id).releasedByDeal, true);
game = settle(act(game, traveler, {type:'endMovement'}));
assert.equal(status(game, first, offered.id), 'released');
});
void test('a four-party future shipment stays scheduled until the due turn and follows the named traveler', () => {
const initial = fixture(), dueTurn = initial.turn + 1, originalCards = inventory(initial);
const offered = offer(initial, [first, traveler, second],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true, dueTurn});
let game = offered.game;
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, second, offered.id);
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
game = respond(game, first, offered.id);
game = respond(game, traveler, offered.id);
assert.equal(game.deals!.at(-1)!.status, 'active');
assert.equal(receipt(game, offered.id).player, traveler);
assert.equal(receipt(game, offered.id).turn, dueTurn);
for (const viewer of [proposer, first, second, traveler])
assert.equal(status(game, viewer, offered.id), 'scheduled');
assert.equal(viewGame(game, traveler).travelPromiseGuide ?? null, null);
game = settle(act(game, traveler, {type:'endMovement'}));
game = reach(game, state => state.turn === dueTurn && state.phase === 5 &&
state.active === traveler && clean(state) && !state.pendingShipment);
assert.equal(status(game, traveler, offered.id), 'pending');
reject(game, traveler, {type:'endMovement'});
game = settle(act(game, traveler, shipment));
for (const viewer of [proposer, first, second, traveler])
assert.equal(status(game, viewer, offered.id), 'fulfilled');
assert.equal(player(game, traveler).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(game), originalCards);
});
for (const answer of [true, false]) void test(`group movement ${answer ? 'requires' : 'forbids'} one real matching action`, () => {
const moved = settle(act(fixture(), traveler, shipment));
const offered = offer(moved, [first, traveler],
{actor:traveler, kind:'move', territory:'imperial_basin', minimum:1, answer});
let game = respond(offered.game, first, offered.id);
game = respond(game, traveler, offered.id);
if (answer) {
reject(game, traveler, {type:'endMovement'});
game = settle(act(game, traveler, movement));
assert.equal(player(game, traveler).forces['imperial_basin:9'], 1);
} else {
reject(game, traveler, movement);
game = settle(act(game, traveler, {type:'endMovement'}));
assert.equal(player(game, traveler).forces['imperial_basin:9'] ?? 0, 0);
}
assert.equal(status(game, first, offered.id), 'fulfilled');
});
void test('the traveler may decline early; malformed or uninvited actors and payment combinations reject', () => {
const initial = fixture(), term:Term = {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true};
for (const bad of [proposer, second, 'unknown'])
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:{...term, actor:bad}});
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:term,
payment:{kind:'deferredBribe', payer:proposer, recipient:traveler, amount:1, dueTurn:initial.turn}});
const offered = offer(initial, [first, traveler], term);
const declined = respond(offered.game, traveler, offered.id, false);
assert.equal(declined.deals!.at(-1)!.status, 'declined');
assert.equal(declined.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
void test('an unavailable journey rejects only the final traveler consent and leaves the group offer open', () => {
const alreadyShipped = settle(act(fixture(), traveler, shipment));
const offered = offer(alreadyShipped, [first, traveler],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
const waiting = respond(offered.game, first, offered.id);
assert.equal(waiting.deals!.at(-1)!.status, 'offered');
assert.equal(waiting.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
reject(waiting, traveler, {type:'dealRespond', deal:offered.id, accept:true});
assert.equal(viewGame(waiting, first).deals.at(-1)!.status, 'offered');
});
void test('the legacy two-person packet still binds the recipient without a named actor', () => {
const game = fixture();
let nextGame = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Two-player travel.',
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true}});
const deal = nextGame.deals!.at(-1)!;
nextGame = respond(nextGame, traveler, deal.id);
assert.equal(receipt(nextGame, deal.id).player, traveler);
assert.equal(status(nextGame, first, deal.id), 'pending');
});
void test('all minimal autopilot profiles can complete the named group travel obligation', () => {
const offered = offer(fixture(), [first, traveler],
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
const accepted = respond(respond(offered.game, first, offered.id), traveler, offered.id);
for (const difficulty of DIFFICULTIES) {
let game = act(accepted, traveler, {type:'setAutopilot', difficulty});
for (let step = 0; step < 8 && status(game, traveler, offered.id) === 'pending'; step++) {
const actions = botActions(viewGame(game, traveler));
assert.ok(actions.length, `${difficulty} needs a legal native continuation`);
game = settle(act(game, traveler, actions[0]));
}
assert.equal(status(game, traveler, offered.id), 'fulfilled', difficulty);
}
});