Files
Dune/tests/truthtrance-richese-promises-engine.test.ts
T

245 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createTruthtranceRichesePromisesFixture as fixture,
actTruthtranceRichesePromises as act, askTruthtranceRichesePromises as ask,
advanceTruthtranceRichesePromises as advance, finishTruthtranceRichesePromises as finish,
truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesPlayer as player,
truthtranceRichesePromisesInventory as inventory, truthtranceRichesePromisesPhysical as physical,
type TruthtranceRichesePromisesFixture as Fixture, type TruthtranceRichesePromisesStep as Step,
} from './fixture-truthtrance-richese-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.initial.advanced, false); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.rules.steps, f.advanced ? [{ actor: f.target, action: { type: 'rules', advanced: true } }] : []);
assert.deepEqual(f.game.expansions, ['choam']); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.ok(clean(f.game));
assert.equal(f.game.auction ?? null, null); assert.equal(f.game.richeseAuction ?? null, null);
assert.equal(f.game.richeseBidding ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 3).map(card => card.id), f.deal);
assert.equal(f.beforeStartingDeal.deck[3].id, f.ordinaryLot);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.equal(player(f.game, f.asker).hand[0].id, f.truthtrance);
assert.equal(player(f.game).spice, 5); assert.equal(player(f.game).reserves, 20); assert.equal(player(f.game).tanks, 0);
assert.deepEqual(player(f.game).forces, {}); assert.equal(player(f.game).shipped, false); assert.equal(player(f.game).moved, 0);
assert.deepEqual(player(f.game).noField, f.originalNoField); assert.equal(player(f.game).noField!.deployed, null);
assert.equal(player(f.game).hand.some(card => ['karama', 'hajr'].includes(card.effect ?? '')), false);
assert.equal(f.originalMainDeck.length, 35); assert.equal(f.originalCache.length, 10);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.action.type === 'stormDial'));
}
function bound(f: Fixture, game = structuredClone(f.game), question: TruthQuestion = f.question) {
const before = physical(game); game = ask(game, f, question);
const view = viewGame(game, f.target);
assert.ok((question.kind === 'shipment' ? view.truthShipmentAnswers : view.truthMovementAnswers)?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = advance(game, clean);
assert.deepEqual(physical(game), before); assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
return game;
}
function reachTurn(game: Game, actions: Step[] = []) {
return advance(game, state => state.phase === 5 && state.active === 'richese' && clean(state), actions);
}
function ship(game: Game, f: Fixture, amount: number, actions: Step[] = []) {
game = reachTurn(game, actions); const before = structuredClone(game);
const action: Action = { type: 'ship', territory: 'carthag', sector: 11, amount, elite: 0, allyPayment: 0 };
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
assert.equal(player(before).spice - player(game).spice, amount);
assert.equal(player(before).reserves - player(game).reserves, amount);
assert.equal(player(game).forces['carthag:11'], amount); assert.equal(player(game).shipped, true);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.shipmentPromises![0].released, undefined);
assert.deepEqual(player(game).noField, f.originalNoField); assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.target, action); return game;
}
function nativeBidding(actions: Step[], advanced: boolean) {
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.position === 'first'));
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === 'richese-stone-burner'));
assert.ok(actions.some(step => step.action.type === 'richeseBid'));
assert.ok(actions.some(step => step.action.type === 'passBid'));
if (advanced) assert.ok(actions.some(step => step.action.type === 'decision' && step.action.decline === true));
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`native ${band} Richese early shipment pays five current spice after actual cache and normal auctions`, () => {
const f = fixture({ advanced }); source(f); const actions: Step[] = [];
let game = bound(f);
assert.ok(viewGame(f.game, f.asker).truthShipmentTargets.includes(f.target));
assert.equal(game.shipmentPromises![0].fulfilled, undefined); reject(game, f.target, f.shipmentAction);
game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(player(game).spice, 5); assert.deepEqual(player(game).forces, {});
assert.deepEqual(player(game).hand.map(card => card.id), [f.targetCard]);
assert.ok(game.richeseRemoved?.some(card => card.id === f.cacheCard));
const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
assert.ok(completion.actions.some(action => action.type === 'ship' && action.amount === 5 && action.territory === 'carthag'));
reject(game, f.target, { type: 'endMovement' }); reject(game, f.target, { ...f.shipmentAction, amount: 4 });
game = ship(game, f, 5, actions); assert.equal(player(game).spice, 0); assert.equal(player(game).reserves, 15);
game = finish(game, actions); assert.equal(game.phase, 7); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
});
void test(`native ${band} Richese early No forbids five but permits the actual four-counter city shipment`, () => {
const f = fixture({ advanced }); let game = ask(structuredClone(f.game), f);
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
reject(game, f.target, f.shipmentAction); game = ship(game, f, 4);
assert.equal(player(game).spice, 1); assert.equal(game.shipmentPromises![0].answer, false);
});
void test(`native ${band} Richese current cash and physical stock cannot promise six while original Emperor can`, () => {
const f = fixture({ advanced }); source(f);
let game = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); assert.deepEqual(inventory(game), f.originalInventory);
const other = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.observer, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(other, f.observer).truthShipmentAnswers, ['yes', 'no']);
assert.equal(player(other, f.observer).spice, 10); assert.equal(player(other).spice, 5);
});
void test(`native ${band} Richese Silent own invoice reserves one before a truthful four-counter shipment`, () => {
const f = fixture({ advanced }), auction = { method: 'silent' as const }, actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, actions, auction);
const event = game.richeseAuction!.event, card = game.richeseAuction!.cardId;
game = act(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 }, actions);
assert.equal(player(game).spice, 5); assert.equal(game.richeseFunding?.[f.target].amount, 1);
const unpaid = structuredClone(game);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
reject(impossible, f.target, { type: 'truthAnswer', answer: 'yes' });
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions, auction);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, player(unpaid, f.observer).spice + 1);
assert.ok(player(game).hand.some(candidate => candidate.id === card)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
reject(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length, 1);
});
void test(`native ${band} Richese ordinary standing invoice pays once and leaves four for the accepted city shipment`, () => {
const f = fixture({ advanced }), actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) && !!state.auction &&
state.auction.active === f.target && state.auction.bid === 0, actions);
const lot = game.auction!.cards[game.auction!.index].id; assert.equal(lot, f.ordinaryLot);
game = act(game, f.target, f.normalBid, actions);
assert.equal(player(game).spice, 5); assert.equal(game.auction!.bidder, f.target); assert.equal(game.auction!.bid, 1);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, 11);
assert.ok(player(game).hand.some(card => card.id === lot)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
game = finish(game, actions); assert.equal(game.phase, 7); assert.equal(player(game, f.observer).spice, 11);
});
void test(`native ${band} Richese empty-board early movement earns a paid landing then the actual ground event`, () => {
const f = fixture({ advanced, kind: 'movement' }); source(f); const actions: Step[] = [];
let game = bound(f); assert.ok(viewGame(f.game, f.asker).truthMovementTargets.includes(f.target));
assert.deepEqual(player(game).forces, {}); assert.equal(player(game).reserves, 20);
reject(game, f.target, f.moveAction); game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(game.movementPromises![0].fulfilled, undefined); reject(game, f.target, { type: 'endMovement' });
const offered = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(offered);
assert.equal(offered.type, 'ship');
// The native witness may prefer an original No-Field. This case takes the
// independent ordinary physical route; profiles below keep their own guide.
const shipment = f.shipmentAction;
assert.equal(shipment.noField, undefined);
const before = structuredClone(game);
game = act(game, f.target, shipment, actions); game = advance(game, clean, actions);
assert.equal(player(game).shipped, true); assert.equal(player(game).moved, 0);
assert.ok(player(before).spice > player(game).spice); assert.ok(player(game).spice >= 0);
assert.equal(player(before).reserves - player(game).reserves, shipment.amount);
assert.equal(game.movementPromises![0].fulfilled, undefined, 'A reserve shipment is not the promised ground event');
const movement = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(movement);
assert.equal(movement.type, 'move'); assert.equal(movement.territory, 'carthag');
const moved = movement.forces && typeof movement.forces === 'object'
? Object.values(movement.forces as Record<string, number>).reduce((total, amount) => total + amount, 0)
: Number(movement.amount);
assert.ok(moved >= 1);
game = act(game, f.target, movement, actions); game = advance(game, clean, actions);
assert.equal(game.movementPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= 1); assert.deepEqual(player(game).noField, f.originalNoField);
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, movement);
game = finish(game, actions); assert.equal(game.phase, 7);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
});
}
void test('native Richese early No on movement permits skipping and rejects the actual later matching ground route', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, kind: 'movement' }); let game = ask(f.game, f);
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
reject(game, f.target, f.moveAction);
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('native Richese early questions retain actor, pending-answer and stale-action boundaries', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), game = ask(f.game, f);
reject(game, f.observer, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.action); reject(game, f.asker, f.askStep.action);
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' }); reject(done, f.asker, f.declarationStep.action);
assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
}
});
void test('all four minimal profiles use the original Richese early answer, owned auction controls and actual completion', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), asked = ask(f.game, f), promised = bound(f);
// Once Around skips its owner after every other seat passes. A separate
// actual Silent lot supplies R's event-bound zero submission; it creates no
// acquired cache card and is not the completion branch's auction prefix.
const silent = advance(promised, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, [], { method: 'silent' });
const expectedPass: Action = { type: 'richeseBid', event: silent.richeseAuction!.event, amount: 0, allyPayment: 0 };
const prefix: Step[] = [], turn = reachTurn(promised, prefix);
nativeBidding(prefix, advanced);
assert.equal(player(turn).spice, 5); assert.deepEqual(player(turn).hand.map(card => card.id), [f.targetCard]);
assert.ok(turn.richeseRemoved?.some(card => card.id === f.cacheCard));
assert.ok(prefix.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.keep === false));
for (const difficulty of DIFFICULTIES) {
const answering = viewGame(asked, f.target); answering.players.find(p => p.id === f.target)!.bot = difficulty;
const answer = botActions(answering)[0]; assert.ok(answer); assert.equal(answer.type, 'truthAnswer');
assert.doesNotThrow(() => act(asked, f.target, answer));
const bidding = viewGame(silent, f.target);
assert.deepEqual(kind === 'shipment' ? bidding.shipmentPromisePolicy : bidding.movementPromisePolicy, [expectedPass]);
bidding.players.find(p => p.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bidding), [expectedPass]);
const submitted = act(silent, f.target, expectedPass);
assert.equal(submitted.richeseAuction!.sealed[f.target], 0); assert.equal(player(submitted).spice, 5);
assert.deepEqual(physical(submitted), physical(silent)); assert.deepEqual(inventory(submitted), f.originalInventory);
let game = structuredClone(turn), count = 0;
const actions: Step[] = [];
while (!(kind === 'shipment' ? game.shipmentPromises![0].fulfilled : game.movementPromises![0].fulfilled) && count++ < 12) {
assert.equal(game.active, f.target); assert.ok(clean(game));
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action, `The ${difficulty} profile needs the actual ${kind} completion`);
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
}
assert.ok(count < 12); assert.equal(kind === 'shipment' ? game.shipmentPromises![0].released : game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= f.minimum);
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
if (kind === 'movement') assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
}
}
});