216 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|