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

236 lines
19 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 { printedCardPlayCount } from '../game/printed-card-play';
import { richeseCards } from '../game/richese-cards';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthSemutaPlay as act, advanceTruthSemutaPlay as advance, answerTruthSemutaPlay as answer,
askTruthSemutaPlay as ask, bindTruthSemutaPlay as bind, commitTruthSemutaPlay as commit,
createTruthSemutaPlayFixture as fixture, createTruthSemutaMultiFixture as multiFixture,
declareTruthSemutaPlay as declare, finishTruthSemutaPlay as finish, nextTruthSemutaPlayNative as nativeStep,
openTruthSemutaMultiBatch as multiBatch, passTruthSemutaOffer as passOffer,
recoverTruthSemutaWithNativeBox as recover, selectTruthSemutaPlay as select, settleTruthSemutaPlay as settle,
truthSemutaPlayClean as clean, truthSemutaPlayFact as fact, truthSemutaPlayGroup as group,
truthSemutaPlayInventory as inventory, truthSemutaPlayPayment as payment, truthSemutaPlayPlayer as player,
truthSemutaPlayPromise as promise, truthSemutaPlaySpice as spice, truthSemutaPlayTraitors as traitors,
truthSemutaPlayUse as use, type TruthSemutaPlayExpression, type TruthSemutaPlaySource, type TruthSemutaPlayStep,
} from './fixture-truth-semuta-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: TruthSemutaPlaySource) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthSemutaPlaySource, mixed: TruthSemutaPlayExpression, turn = f.game.turn): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn, mixed });
const stage = (game: Game, index = 0) => game.turnActionPromises![index].cardPlays?.semutaDrug;
const count = (game: Game, f: TruthSemutaPlaySource) => printedCardPlayCount(game.printedCardPlays, f.target, game.turn, 'semutaDrug');
const pay = (game: Game, f: TruthSemutaPlaySource, amount: number, actions: TruthSemutaPlayStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
void test('Semuta sources distinguish genuine cache acquisition from the explicitly conserved multi-card producer supplement', () => {
for (const firstBid of [false, true]) {
const f = fixture({ firstBid }); assert.equal(f.nativeAcquisition, true); assert.equal(f.sourceKind, 'nativeSingleton');
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.game.auction!.bid, firstBid ? 1 : 0); assert.equal(f.game.semutaPreview, true);
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(player(f.game, f.target).spice, 3);
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.equal(count(f.game, f), 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0); physical(f.game, f);
}
const supplement = multiFixture(); assert.equal(supplement.nativeAcquisition, false); assert.equal(supplement.sourceKind, 'controlledMulti');
assert.match(supplement.sourceDisclosure, /CONTROLLED CONSERVED UNIT SUPPLEMENT/);
assert.equal(supplement.sourceCounts.inheritedRecordedActions, null); assert.equal(supplement.sourceActionCount, supplement.actions.length);
assert.ok(!supplement.game.battle && !supplement.game.pendingTreacheryDiscard);
assert.ok(!supplement.game.homeworlds && !supplement.game.nexusCards && !supplement.game.leaderSkills);
physical(supplement.game, supplement);
});
void test('genuine Semuta AND frozen fact and current-budget payment uses the actual foreign Truth cost for free before paying', () => {
for (const firstBid of [false, true]) {
const f = fixture({ firstBid }), cash = player(f.game, f.target).spice,
budget = viewGame(f.game, f.target).bribeOptions.available;
assert.equal(budget, cash - Number(firstBid));
if (firstBid) {
const declared = declare(f), unfunded = ask(declared.game, declared.asker,
raw(f, group('and', fact(f.spiceFact), use(), payment(f.recipient, cash))));
assert.deepEqual(viewGame(unfunded, f.target).truthTurnActionAnswers, ['no']);
rejected(unfunded, f.target, { type: 'truthAnswer', answer: 'yes' });
}
const mixed = group('and', fact(f.spiceFact), use(), payment(f.recipient, budget));
let game = bind(f, 'yes', raw(f, mixed)).game;
const event = game.pendingTreacheryDiscard!.batch.event, lot = structuredClone(game.auction);
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.truth);
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: budget });
game = commit(game, f.target); assert.ok(!game.pendingTreacheryDiscard);
assert.equal(player(game, f.target).spice, cash); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'yes'); assert.deepEqual(game.auction, lot);
rejected(game, f.target, { type: 'semutaCommit', event }); game = pay(game, f, budget);
assert.equal(player(game, f.target).spice, cash - budget); assert.equal(player(game, f.recipient).bribes, budget);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).mixed, mixed); assert.equal(count(game, f), 1); physical(game, f);
}
});
void test('required Yes cannot voluntarily Pass its only known Semuta trigger while foreign neutral passes retain their native rights', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game;
const event = game.pendingTreacheryDiscard!.batch.event;
rejected(game, f.target, { type: 'semutaPass', event });
assert.ok(viewGame(game, f.target).semutaReaction?.passBlocked);
for (const actor of game.players.filter(candidate => candidate.id !== f.target).map(candidate => candidate.id))
game = act(game, actor, { type: 'semutaPass', event });
assert.equal(game.pendingTreacheryDiscard?.batch.event, event); assert.equal(stage(game)?.played, false);
rejected(game, f.target, { type: 'semutaPass', event });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaCommit');
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
assert.equal(player(game, f.target).spice, 3); physical(game, f);
});
void test('whole OR permits passing Semuta and retaining the physical card for a later actual Mentat payment route', () => {
const f = fixture(), budget = player(f.game, f.target).spice;
let game = bind(f, 'yes', raw(f, group('or', use(), payment(f.recipient, budget)))).game;
const event = game.pendingTreacheryDiscard!.batch.event;
game = act(game, f.target, { type: 'semutaPass', event }); game = passOffer(game);
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
game = advance(game, state => state.phase === 8 && clean(state));
assert.ok(!promise(game).fulfilled && !promise(game).released); game = pay(game, f, budget);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
});
void test('whole OR No blocks Semuta commitment before inspection and retains its cumulative payment cap through the turn', () => {
const f = fixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
const reaction = viewGame(game, f.target).semutaReaction; assert.ok(reaction);
assert.equal(reaction.canCommit, false); assert.deepEqual(reaction.candidates, []);
rejected(game, f.target, { type: 'semutaCommit', event: reaction.event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
game = passOffer(game); game = pay(game, f, 1);
game = advance(game, state => state.phase === 8 && clean(state));
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.semuta)); physical(game, f);
});
void test('whole AND No permits the real Semuta exchange but forbids the later payment that would complete both terms', () => {
const f = fixture(); let game = bind(f, 'no', raw(f, group('and', use(), payment(f.recipient, 2)))).game;
game = commit(game, f.target); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
game = pay(game, f, 1); 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, true); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
});
void test('current offer guide packets retain exact native event and phase through JSON and reject stale owner or preselection', () => {
const f = fixture({ firstBid: true }); let game = bind(f, 'yes', raw(f, use())).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'semutaCommit'); assert.equal(guide.event, promise(game).event);
assert.equal(guide.action.event, game.pendingTreacheryDiscard!.batch.event);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 3);
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-fresh-semuta-source' });
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-whole-answer' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 }); rejected(game, f.target, { ...guide.action, card: f.truth });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); rejected(game, f.target, packet); physical(game, f);
});
void test('controlled native two-card batches count neither commitment nor inspection, and either completed choice or its exact selection guide fulfills', () => {
const f = multiFixture();
for (const route of ['first', 'second', 'guide'] as const) {
const actions: TruthSemutaPlayStep[] = []; let game = bind(f, 'yes', f.question, f.game, 0, actions).game;
game = multiBatch(f, game, actions); const event = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(viewGame(game, f.target).semutaReaction?.candidates, []); assert.equal(count(game, f), 0);
game = commit(game, f.target, actions); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false); assert.ok(!promise(game).fulfilled);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 0);
const candidates = viewGame(game, f.target).semutaReaction!.candidates; assert.equal(candidates.length, 2);
assert.deepEqual(candidates.map(card => card.id).sort(), [...f.producer.loserCards].sort());
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
assert.equal(guide.action.event, event); assert.equal(guide.action.turnActionPromiseEvent, promise(game).event);
assert.equal(guide.action.turnActionPromisePhase, 6); assert.ok(candidates.some(card => card.id === guide.action.card));
rejected(game, f.target, { type: 'semutaPass', event }); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-multi-source' });
rejected(game, f.target, { type: 'semutaSelect', event, card: f.truth });
const chosen = route === 'guide' ? String(guide.action.card) : candidates[route === 'first' ? 0 : 1].id;
game = route === 'guide' ? act(game, f.target, JSON.parse(JSON.stringify(guide.action)) as Action, actions)
: select(game, f.target, chosen, actions);
assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === chosen)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
const receipt = structuredClone(stage(game)); game = settle(game, actions); assert.deepEqual(stage(game), receipt);
assert.equal(count(game, f), 1); assert.equal(f.nativeAcquisition, false); assert.ok(actions.some(step => step.action.type === 'chooseBattle'));
assert.ok(actions.filter(step => step.action.type === 'battlePlan').length === 2); physical(game, f);
}
});
void test('a controlled native multi-card offer still rejects whole No commitment before exposing either fresh choice', () => {
const f = multiFixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
game = multiBatch(f, game); const event = game.pendingTreacheryDiscard!.batch.event;
const view = viewGame(game, f.target); assert.equal(view.semutaReaction?.canCommit, false); assert.deepEqual(view.semutaReaction?.candidates, []);
rejected(game, f.target, { type: 'semutaCommit', event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false);
game = settle(passOffer(game)); assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
});
void test('an earlier completed physical Semuta use cannot satisfy a second original Truth future-play interval', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
const prior = structuredClone(viewGame(game, f.target).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 = settle(answer(asked, f.target, 'no'));
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 1);
assert.deepEqual(viewGame(game, f.target).turnActionPromises[0], prior); physical(game, f);
});
void test('real paid Box recovery of the original Semuta is not another play, while a later original Truth cost permits genuine replay', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
assert.equal(player(game, f.target).spice, 3); assert.equal(count(game, f), 1);
const actions: TruthSemutaPlayStep[] = [], recovered = recover(f, game, actions); game = recovered.game;
assert.equal(game.turn, f.game.turn + 1); assert.equal(player(game, f.target).spice, 0); assert.equal(count(game, f), 0);
assert.ok(recovered.purchase.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(recovered.paid.pendingNullentropy); assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.semuta));
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
const question = raw(f, use(), game.turn); game = bind(f, 'yes', question, game, 1).game;
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 0);
game = commit(game, f.target); assert.equal(stage(game, 1)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(count(game, f), 1); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
});
void test('all four minimal profiles execute native Semuta offer and committed selection guides through physical completion', () => {
const f = fixture(), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthSemutaPlayStep[] = [];
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); assert.equal(response.answer, 'yes');
game = act(game, f.target, response, actions);
for (let step = 0; step < 80 && (!promise(game).fulfilled || !clean(game)); step++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
if (view.turnActionGuide) {
const guide = view.turnActionGuide, action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(count(game, f), 1);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 3); physical(game, f);
}
const supplement = multiFixture();
for (const difficulty of DIFFICULTIES) {
let game = bind(supplement).game; game = multiBatch(supplement, game); game = commit(game, supplement.target);
assert.equal(count(game, supplement), 0);
const view = viewGame(game, supplement.target); view.players.find(candidate => candidate.id === supplement.target)!.bot = difficulty;
const guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action); assert.deepEqual(action, guide.action); game = settle(act(game, supplement.target, action));
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, supplement), 1); physical(game, supplement);
}
});