Files
Dune/tests/truth-transfer-order-play-runtime.test.ts

309 lines
24 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthTransferOrderPlay as act, advanceTruthTransferOrderPlay as advance,
answerTruthTransferOrderPlay as answer, askTruthTransferOrderPlay as ask, bindTruthTransferOrderPlay as bind,
createTruthTransferOrderPlayFixture as fixture, createTruthSemutaPrebidFixture as semutaFixture,
createTruthEarlySaphoFixture as earlySaphoFixture, declareTruthTransferOrderPlay as declare,
finishTruthTransferOrderPlay as finish, nextTruthTransferOrderPlayNative as nativeStep,
settleTruthTransferOrderPlay as settle, truthTransferOrderDistransAction as transfer,
truthTransferOrderSaphoAction as order, truthTransferOrderPlayClean as clean,
truthTransferOrderPlayFact as fact, truthTransferOrderPlayGroup as group,
truthTransferOrderPlayInventory as inventory, truthTransferOrderPlayPayment as payment,
truthTransferOrderPlayPlayer as player, truthTransferOrderPlayPromise as promise,
truthTransferOrderPlayShip as ship, truthTransferOrderPlaySpice as spice,
truthTransferOrderPlayTraitors as traitors, truthTransferOrderPlayUse as use,
type TruthTransferOrderExpression, type TruthTransferOrderPlayFixture, type TruthTransferOrderStep,
} from './fixture-truth-transfer-order-play';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTransferOrderPlayFixture) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthTransferOrderPlayFixture, mixed: TruthTransferOrderExpression): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed });
const pay = (game: Game, f: TruthTransferOrderPlayFixture, amount: number, actions: TruthTransferOrderStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
const stage = (game: Game, f: TruthTransferOrderPlayFixture, index = 0) => game.turnActionPromises![index].cardPlays?.[f.effect];
const nativeCard = (game: Game, f: TruthTransferOrderPlayFixture): Action => f.effect === 'distrans'
? transfer(game, f) : order(game, f, 'first');
void test('transfer and order sources retain actual cache purchases, unopened Truth deals and all original native inventories', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length); assert.equal(f.sourceCounts.nativeCacheBids, 1);
assert.equal(!!f.game.semutaPreview, !advanced); assert.equal(player(f.game, f.target).spice, source === 'distrans' ? 4 : 9);
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card)); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
if (f.sapho) {
assert.equal(f.game.active, f.sapho.current); assert.equal(player(f.game, f.sapho.current).shipped, true);
assert.equal(player(f.game, f.sapho.current).moved, 0); assert.equal(player(f.game, f.target).shipped, false);
assert.ok(player(f.game, f.sapho.canceler).hand.some(card => card.id === f.sapho!.counter));
}
physical(f.game, f);
}
});
void test('Basic Distrans AND frozen fact and payment spends no transfer fee and preserves the original untouched ordinary lot', () => {
const f = fixture(), actions: TruthTransferOrderStep[] = [];
let game = bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 4))), f.game, 0, actions).game;
game = settle(game, actions); assert.ok(actions.some(step => step.action.type === 'semutaPass'));
const lot = structuredClone(game.auction), donorForces = structuredClone(player(game, f.target).forces);
game = pay(game, f, 4, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const action = transfer(game, f); game = settle(act(game, f.target, action, actions), actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 4);
assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.give || card.id === f.card));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(game.auction, lot);
assert.deepEqual(player(game, f.target).forces, donorForces); assert.equal(stage(game, f)?.played, true);
assert.equal(promise(game).fulfilled, true); assert.equal(game.semutaPreview, true); physical(game, f);
});
void test('Distrans printed use names no recipient and can choose another public-capacity route when the original target is genuinely full', () => {
const f = fixture({ capacity: 'recipientFull' }); assert.ok(f.distrans);
const fullRecipient = f.distrans.recipient, fullHandCount = player(f.game, fullRecipient).hand.length;
const copy = f.truthHolders.findIndex(holder => holder !== fullRecipient); assert.ok(copy === 0 || copy === 1);
let game = settle(bind(f, 'yes', raw(f, use(f.effect)), f.game, copy).game);
const quoted = viewGame(game, f.target).distrans; assert.ok(quoted);
assert.ok(quoted.choices.find(choice => choice.recipient === fullRecipient)?.blocked);
rejected(game, f.target, { type: 'card', card: f.card, target: fullRecipient, give: f.give! });
rejected(game, f.target, { type: 'card', card: f.card, target: f.asker, give: f.card });
const alternative = quoted.choices.find(choice => !choice.blocked && choice.cards.some(card => card.id === f.give)); assert.ok(alternative);
const cash = player(game, f.target).spice;
game = settle(act(game, f.target, transfer(game, f, alternative.recipient)));
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, cash);
assert.equal(player(game, fullRecipient).hand.length, fullHandCount);
assert.ok(player(game, alternative.recipient).hand.some(card => card.id === f.give)); assert.ok(clean(game)); physical(game, f);
});
void test('free Distrans, a later Guild shipment and payments share the same four owned spice', () => {
const f = fixture({ advanced: true });
const unfunded = raw(f, group('and', use(f.effect), payment(f.recipient, 4), ship()));
const asked = ask(declare(f).game, f.asker, unfunded);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const funded = raw(f, group('and', use(f.effect), payment(f.recipient, 3), ship()));
let game = settle(bind(f, 'yes', funded).game); game = pay(game, f, 3);
game = settle(act(game, f.target, transfer(game, f))); assert.equal(stage(game, f)?.played, true);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, f.shipment)); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.target).spice, 0); physical(game, f);
});
void test('Sapho AND frozen fact and payment accepts either real queued mode while preserving the current completed shipment and unsplit turn', () => {
const cases = [{ advanced: false, guildTake: false, mode: 'first' }, { advanced: false, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: false, mode: 'first' }, { advanced: true, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: true, mode: 'first' }] as const;
for (const { advanced, guildTake, mode } of cases) {
const f = fixture({ source: 'sapho', advanced, guildTake }); assert.ok(f.sapho);
const actions: TruthTransferOrderStep[] = [];
let game = settle(bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient))), f.game, 0, actions).game, actions);
game = pay(game, f, 2, actions); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const current = f.sapho.current, beforeCurrent = structuredClone(player(game, current)), nativeOrder = [...game.order];
const native = viewGame(game, f.target).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode); assert.ok(native);
assert.equal((native as typeof native & { afterCurrent?: string }).afterCurrent, current);
if (guildTake) {
assert.equal(current, f.sapho.guild);
assert.equal(viewGame(game, f.target).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'last'), false);
rejected(game, f.target, { ...order(game, f, 'first'), mode: 'last' });
assert.deepEqual(player(game, current), beforeCurrent); assert.equal(stage(game, f)?.played, false);
}
game = settle(act(game, f.target, order(game, f, mode), actions), actions);
assert.equal(game.active, current); assert.deepEqual(player(game, current), beforeCurrent);
assert.deepEqual(game.order, nativeOrder); assert.equal(player(game, f.target).spice, 7);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const from = actions.length; game = advance(game, state => state.phase !== 5, actions, true);
const ended = actions.slice(from).filter(step => step.action.type === 'endMovement').map(step => step.actor);
assert.equal(ended[0], current); assert.equal(ended.filter(actor => actor === f.target).length, 1);
assert.equal(new Set(ended).size, ended.length); assert.equal(stage(game, f)?.played, true); physical(game, f);
}
});
void test('a genuinely purchased Semuta claims the original Truth cost before or after the first normal bid without changing its standing auction', () => {
for (const firstBid of [false, true]) {
const f = semutaFixture({ firstBid });
assert.ok(f.acquisition.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(f.game.auction!.bid, firstBid ? 1 : 0);
const position = (game: Game) => structuredClone({ status: game.status, turn: game.turn, phase: game.phase,
active: game.active, order: game.order, ready: game.ready, auction: game.auction,
richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding, richeseFunding: game.richeseFunding,
currentAuctionSale: game.currentAuctionSale, deck: game.deck,
players: game.players.map(candidate => ({ ...candidate, hand: undefined })) });
const standing = position(f.game), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
let game = answer(asked, f.target, 'no');
const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, discardedBy: entry.discardedBy })),
[{ card: f.truth, discardedBy: f.asker }]);
assert.equal(viewGame(game, f.target).semutaReaction?.canCommit, true); assert.deepEqual(position(game), standing);
const event = pending.batch.event; game = act(game, f.target, { type: 'semutaCommit', event });
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(position(game), standing);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(stage(game, f)?.played, false);
assert.equal(game.resolvedTreacheryDiscardSequence, pending.sequence); assert.equal(promise(game).answer, false);
rejected(game, f.target, { type: 'semutaCommit', event }); physical(game, f);
}
});
void test('genuine early Basic Sapho can promise its free upcoming native movement play after paying all current cash', () => {
const f = earlySaphoFixture(); assert.equal(f.game.phase, 4); assert.equal(f.game.advanced, false);
assert.ok(!f.actions.some(step => step.action.type === 'ship')); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length);
const cash = player(f.game, f.target).spice;
const question = raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, cash)));
const declared = declare(f), asked = ask(declared.game, declared.asker, question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no']);
let game = settle(answer(asked, f.target, 'yes')); game = pay(game, f, cash);
assert.equal(player(game, f.target).spice, 0); assert.equal(stage(game, f)?.played, false);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const earlyGuide = viewGame(game, f.target).turnActionGuide;
assert.ok(!earlyGuide || earlyGuide.action.type !== 'card' || earlyGuide.action.card !== f.card,
'The future movement option is not a currently executable Basic Revival card packet');
game = advance(game, state => state.phase === 5 && clean(state) && viewGame(state, f.target).saphoOptions.some(option => option.scope === 'movement'), [], true);
const before = game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: structuredClone(candidate.forces),
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved }));
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
game = settle(act(game, f.target, guide.action));
assert.deepEqual(game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: candidate.forces,
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved })), before);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, cash); physical(game, f);
});
void test('whole OR keeps both manual printed use and late ordinary payment routes for transfer and order', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), question = raw(f, group('or', use(f.effect), payment(f.recipient)));
const bound = settle(bind(f, 'yes', question).game);
const played = settle(act(bound, f.target, nativeCard(bound, f)));
assert.equal(promise(played).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
let paid = advance(bound, state => state.phase === 8 && clean(state), [], true);
assert.equal(stage(paid, f)?.played, false); assert.ok(!promise(paid).released && !promise(paid).fulfilled);
paid = pay(paid, f, 2); assert.equal(promise(paid).fulfilled, true);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card)); physical(played, f); physical(paid, f);
}
});
void test('whole AND No allows one printed branch or its payment but cannot complete both conditions', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), bound = settle(bind(f, 'no').game), packet = nativeCard(bound, f);
let played = settle(act(bound, f.target, packet)); assert.equal(stage(played, f)?.played, true);
played = pay(played, f, 1); rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
let paid = pay(bound, f, 2); rejected(paid, f.target, packet); assert.equal(stage(paid, f)?.played, false);
played = finish(played); paid = finish(paid); assert.equal(promise(played).fulfilled, true); assert.equal(promise(paid).fulfilled, true);
assert.ok(!promise(played).released && !promise(paid).released); physical(played, f); physical(paid, f);
}
});
void test('whole OR No preserves cumulative caps and rejects both printed roles until the actual turn end', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), originalPacket = nativeCard(f.game, f);
let game = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
const forbidden = viewGame(game, f.target);
if (source === 'distrans') assert.match(forbidden.distrans?.blocked ?? '', /Truthtrance/);
else assert.deepEqual(forbidden.saphoOptions, []);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
assert.ok(!botActions(view).some(action => action.type === 'card' && action.card === f.card));
}
rejected(game, f.target, originalPacket); game = pay(game, f, 1);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game).released); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, false); assert.equal(stage(game, f)?.closed, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
}
});
void test('earlier native Distrans and Sapho use without an owned recovery route leaves only No for a new future interval', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); const earlier = settle(act(f.game, f.target, nativeCard(f.game, f)));
assert.equal(earlier.discard.filter(card => card.id === f.card).length, 1);
const declared = declare(f, earlier), asked = ask(declared.game, declared.asker, raw(f, use(f.effect)));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); physical(game, f);
}
});
void test('an actual turn2 Box recovery alone is not a Distrans play but a later native replay with an earned outgoing card fulfills', () => {
const f = fixture({ recoveredDistrans: true }); assert.ok(f.recovery);
assert.equal(f.game.turn, f.recovery.before.turn + 1);
assert.ok(player(f.recovery.afterRecovery, f.target).hand.some(card => card.id === f.card));
assert.equal(f.recovery.afterRecovery.discard.filter(card => card.id === f.recovery!.box).length, 1);
assert.ok(f.recovery.acquisition.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.recovery.outgoingPurchase.card, f.give); assert.equal(f.recovery.outgoingPurchase.amount, 1);
const cash = player(f.game, f.target).spice; let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
assert.equal(stage(game, f)?.played, false); assert.ok(!promise(game).fulfilled);
game = settle(act(game, f.target, transfer(game, f)));
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, cash); assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('exact transfer/order guide packets preserve native choices and reject stale owner, promise and Sapho event through JSON', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card');
assert.equal(guide.action.card, f.card); assert.equal(guide.event, promise(game).event);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase);
if (source === 'distrans') {
const native = view.distrans?.choices.find(choice => choice.recipient === guide.action.target && !choice.blocked); assert.ok(native);
assert.ok(native.cards.some(card => card.id === guide.action.give)); assert.notEqual(guide.action.give, f.card);
} else {
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
rejected(game, f.target, { ...guide.action, event: 'stale-native-sapho-event' });
}
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-printed-role-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 8 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, true); rejected(game, f.target, packet); physical(game, f);
}
});
void test('all four minimal profiles use real transfer/order guides and native Basic Semuta continuation without extra costs', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTransferOrderStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${source}/${advanced}/${difficulty}`);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 100 && (!promise(game).fulfilled || !clean(game)); count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
if (view.turnActionGuide) {
const guide = view.turnActionGuide;
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action, `${source}/${advanced}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const step = nativeStep(game); assert.ok(step); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(stage(game, f)?.played, true);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card).length, 1);
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice - 2); assert.equal(player(game, f.recipient).bribes, 2);
if (!advanced) assert.ok(actions.some(step => step.action.type === 'semutaPass'));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
}
}
});