Files
Dune/tests/truth-box-play-runtime.test.ts

212 lines
17 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 {
actTruthBoxPlay as act, advanceTruthBoxPlay as advance, answerTruthBoxPlay as answer,
askTruthBoxPlay as ask, bindTruthBoxPlay as bind, chooseTruthBoxPlay as choose,
createTruthBoxPlayFixture as fixture, declareTruthBoxPlay as declare, finishTruthBoxPlay as finish,
nextTruthBoxPlayNative as nativeStep, settleTruthBoxPlay as settle, truthBoxPlayClean as clean,
truthBoxPlayFact as fact, truthBoxPlayGroup as group, truthBoxPlayInventory as inventory,
truthBoxPlayPayment as payment, truthBoxPlayPlayer as player, truthBoxPlayPromise as promise,
truthBoxPlayShip as ship, truthBoxPlaySpice as spice, truthBoxPlayTraitors as traitors, truthBoxPlayUse as use,
type TruthBoxPlayExpression, type TruthBoxPlayFixture, type TruthBoxPlayStep,
} from './fixture-truth-box-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: TruthBoxPlayFixture) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthBoxPlayFixture, mixed: TruthBoxPlayExpression): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed });
const pay = (game: Game, f: TruthBoxPlayFixture, amount: number, actions: TruthBoxPlayStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
const stage = (game: Game, index = 0) => game.turnActionPromises![index].cardPlays?.nullentropyBox;
void test('printed Box sources preserve real cache and ordinary purchases, inventories and Basic Semuta chronology', () => {
for (const advanced of [false, true]) for (const multiple of [false, true]) {
const f = fixture({ advanced, multiple }); 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.sourceCounts.selected, f.actions.length); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceActionCount);
assert.equal(f.sourceCounts.paidCacheBids, 1); assert.equal(f.sourceCounts.paidOrdinaryBids, 1);
assert.equal(player(f.game, f.target).spice, multiple ? 6 : 8); assert.equal(!!f.game.semutaPreview, !advanced);
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.box));
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.ok(!f.game.pendingNullentropy);
assert.equal(f.game.discard.filter(card => card.id === f.hajr).length, multiple ? 1 : 0);
if (multiple) {
assert.ok(f.sourcePreparation); assert.equal(player(f.sourcePreparation.before, f.target).spice, 8);
assert.equal(player(f.sourcePreparation.after, f.target).spice, 6);
assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'ship' && step.action.amount === 1));
assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'move'));
assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'card' && step.action.card === f.hajr));
}
physical(f.game, f);
}
});
void test('Basic printed Box AND payment and shipment share eight actual spice after declined Semuta with frozen facts', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), use(), payment(f.recipient, 4), ship());
const actions: TruthBoxPlayStep[] = []; let game = bind(f, 'yes', raw(f, mixed), f.game, 0, actions).game;
assert.equal(game.semutaPreview, true); game = settle(game, actions);
assert.ok(actions.some(step => step.action.type === 'semutaPass'), 'The original fresh Truth cost completes actual declined Semuta controls');
assert.deepEqual(game.discard.map(card => card.id), [f.truth]); assert.equal(viewGame(game, f.target).nullentropy?.search, null);
game = pay(game, f, 4, actions); assert.equal(player(game, f.target).spice, 4);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
game = settle(act(game, f.target, f.shipment, actions), actions); assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(stage(game)?.played, false);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(game.pendingNullentropy, null, 'A sole actual eligible discard completes without a private selection');
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
assert.equal(player(game, f.recipient).bribes, 4); assert.deepEqual(promise(game).mixed, mixed); physical(game, f);
});
void test('Advanced paid Box activation records before its real multi-choice selection and keeps modal priority over remaining payment', () => {
const f = fixture({ advanced: true, multiple: true }); const mixed = group('and', use(), payment(f.recipient, 4));
let game = bind(f, 'yes', raw(f, mixed)).game; assert.equal(game.discard.length, 2);
assert.equal(viewGame(game, f.target).nullentropy?.search, null);
game = act(game, f.target, f.boxAction); assert.equal(player(game, f.target).spice, 4);
assert.equal(stage(game)?.played, true); assert.ok(!promise(game).fulfilled); assert.equal(game.decision?.kind, 'nullentropy');
assert.equal(game.discard.filter(card => card.id === f.box).length, 0, 'The physical Box awaits its actual selection disposal');
const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search);
assert.deepEqual(search.cards.map(card => card.id).sort(), [f.truth, f.hajr].sort());
rejected(game, f.target, f.payment); rejected(game, f.target, f.end);
rejected(game, f.asker, { type: 'decision', event: search.event, card: f.hajr });
rejected(game, f.target, { type: 'decision', event: 'stale-paid-box', card: f.hajr });
game = settle(choose(game, f.target, f.hajr)); assert.equal(player(game, f.target).spice, 4);
assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr)); assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
game = pay(game, f, 4); assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game).fulfilled, true);
const receipt = structuredClone(game.turnActionPromises![0].cardPlays);
game = settle(act(game, f.target, f.end)); assert.deepEqual(game.turnActionPromises![0].cardPlays, receipt); physical(game, f);
});
void test('whole OR permits either manual printed Box use or a later Mentat payment without selecting a fixed recovery branch', () => {
const f = fixture({ advanced: true }), mixed = group('or', use(), payment(f.recipient));
const bound = bind(f, 'yes', raw(f, mixed)).game;
let played = settle(act(bound, f.target, f.boxAction));
assert.equal(promise(played).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
assert.equal(player(played, f.target).spice, 6); assert.equal(stage(played)?.played, true);
let paid = settle(act(bound, f.target, f.end));
paid = advance(paid, state => state.phase === 8 && clean(state), [], true);
assert.ok(!promise(paid).fulfilled && !promise(paid).released); assert.equal(stage(paid)?.played, false);
paid = pay(paid, f, 2); assert.equal(promise(paid).fulfilled, true);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.box)); assert.equal(player(paid, f.target).spice, 6);
played = finish(played); assert.equal(stage(played)?.played, true); physical(played, f); physical(paid, f);
});
void test('whole AND No permits one actual alternative but rejects the payment or printed activation that would make both true', () => {
const f = fixture({ advanced: true }), bound = bind(f, 'no').game;
let played = settle(act(bound, f.target, f.boxAction)); assert.equal(stage(played)?.played, true);
played = pay(played, f, 1); rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(player(played, f.target).spice, 5); assert.ok(!promise(played).released);
let paid = pay(bound, f, 2); rejected(paid, f.target, f.boxAction);
assert.equal(stage(paid)?.played, false); assert.equal(player(paid, f.target).spice, 6);
played = finish(played); paid = finish(paid);
assert.equal(promise(played).fulfilled, true); assert.equal(promise(paid).fulfilled, true); physical(played, f); physical(paid, f);
});
void test('whole OR No retains its cumulative payment cap and rejects printed Box through the actual turn deadline', () => {
const f = fixture({ advanced: true }), mixed = group('or', use(), payment(f.recipient));
let game = bind(f, 'no', raw(f, mixed)).game; rejected(game, f.target, f.boxAction);
assert.match(viewGame(game, f.target).nullentropy?.blocked ?? '', /whole Truthtrance/);
game = pay(game, f, 1); game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, f.boxAction); 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)?.played, false); assert.equal(stage(game)?.closed, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.box)); physical(game, f);
});
void test('the printed Box fee cannot be financed twice or by an unpaid hidden recovered-card effect', () => {
const f = fixture({ advanced: true });
for (const mixed of [group('and', use(), payment(f.recipient, 7)), group('and', use(), payment(f.recipient, 5), ship())]) {
const asked = ask(declare(f).game, f.asker, raw(f, mixed)), before = structuredClone(asked);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); assert.deepEqual(asked, before);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(player(asked, f.target).spice, 8); assert.equal(viewGame(asked, f.target).nullentropy?.search, null);
}
physical(f.game, f);
});
void test('earlier physical Box activation and its recovered card do not satisfy a second original Truth future-play interval', () => {
const f = fixture({ advanced: true }); let game = bind(f, 'yes', raw(f, use())).game;
game = settle(act(game, f.target, f.boxAction)); assert.equal(promise(game).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(stage(game)?.played, true);
const prior = structuredClone(game.turnActionPromises![0]), declared = declare(f, game, 1);
const asked = ask(declared.game, declared.asker, raw(f, use()));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, f.target, 'no'); assert.deepEqual(game.turnActionPromises![0], prior);
assert.equal(stage(game, 1)?.played, false); assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
assert.equal(player(game, f.target).spice, 6); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, 1)?.played, false); physical(game, f);
});
void test('a definite late Box answer reopens actual own Ready and cannot expire its required paid activation', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, late: true }), source = act(f.game, f.target, { type: 'ready' });
assert.ok(source.ready.includes(f.target)); const actions: TruthBoxPlayStep[] = [];
let game = bind(f, 'yes', raw(f, use()), source, 0, actions).game; game = settle(game, actions);
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card');
assert.equal(guide.action.card, f.box); assert.equal(guide.action.turnActionPromisePhase, 8);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, true); assert.equal(player(game, f.target).spice, 6);
assert.equal(!!game.semutaPreview, !advanced); physical(game, f);
}
});
void test('exact printed Box guide envelopes reject stale owners and preselection, then native JSON selection spends only once', () => {
const f = fixture({ advanced: true, multiple: true }); let game = bind(f, 'yes', raw(f, use())).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.event, promise(game).event); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase);
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-box-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 }); rejected(game, f.target, { ...guide.action, give: f.hajr });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
assert.equal(player(game, f.target).spice, 4); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true);
const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search); rejected(game, f.target, packet);
const selection: Action = JSON.parse(JSON.stringify({ type: 'decision', event: search.event, card: f.hajr }));
game = settle(act(game, f.target, selection)); assert.equal(player(game, f.target).spice, 4);
assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr)); rejected(game, f.target, selection); physical(game, f);
});
void test('all four minimal profiles perform real printed Box payment and native private selection in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, multiple: true }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthBoxPlayStep[] = [];
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, `${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 (game.pendingNullentropy) {
const search = view.nullentropy?.search; assert.ok(search);
const choice = botActions(view).find(candidate => candidate.type === 'decision' && candidate.event === search.event);
assert.ok(choice, `${advanced}/${difficulty}`); game = act(game, f.target, choice, actions);
} else if (view.turnActionGuide) {
const guide = view.turnActionGuide;
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action, `${advanced}/${difficulty}`); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
} else {
const step = nativeStep(game); assert.ok(step, `${advanced}/${difficulty}`); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(stage(game)?.played, true);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.box).length, 1);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'decision' && typeof step.action.card === 'string').length, 1);
assert.equal(game.discard.filter(card => card.id === f.box).length, 1); assert.equal(player(game, f.target).spice, 2);
assert.equal(player(game, f.recipient).bribes, 2); assert.equal(!!game.semutaPreview, !advanced); physical(game, f);
}
}
});