Files
Dune/tests/fixture-truth-semuta-play.ts
T

215 lines
17 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { semutaBattleKaramaFixture } from './fixture-semuta-battle-karama';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actTruthTransferOrderPlay, createTruthSemutaPrebidFixture, nextTruthTransferOrderPlayNative,
truthTransferOrderPlayInventory, truthTransferOrderPlayPlayer, truthTransferOrderPlaySpice,
truthTransferOrderPlayTraitors,
} from './fixture-truth-transfer-order-play';
export type TruthSemutaPlayLeaf = { kind: 'cardPlay'; effect: 'semutaDrug' } |
{ kind: 'payment'; recipient: string; minimum: number };
export type TruthSemutaPlayExpression = MixedActionExpression<TruthSemutaPlayLeaf>;
export type TruthSemutaPlayStep = { actor: string; action: Action };
export const actTruthSemutaPlay = actTruthTransferOrderPlay;
export const truthSemutaPlayPlayer = truthTransferOrderPlayPlayer;
export const truthSemutaPlayInventory = truthTransferOrderPlayInventory;
export const truthSemutaPlaySpice = truthTransferOrderPlaySpice;
export const truthSemutaPlayTraitors = truthTransferOrderPlayTraitors;
export const truthSemutaPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
export const truthSemutaPlayUse = (): TruthSemutaPlayExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect: 'semutaDrug' } });
export const truthSemutaPlayFact = (fact: TruthFact): TruthSemutaPlayExpression => ({ kind: 'fact', fact });
export const truthSemutaPlayPayment = (recipient: string, minimum = 1): TruthSemutaPlayExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthSemutaPlayGroup = (kind: 'and' | 'or', ...terms: TruthSemutaPlayExpression[]): TruthSemutaPlayExpression => ({ kind, terms });
export type TruthSemutaPlaySource = {
game: Game; source: Game; sourceKind: 'nativeSingleton' | 'controlledMulti'; nativeAcquisition: boolean;
target: string; asker: string; recipient: string; truths: string[]; truthHolders: string[]; truth: string;
semuta: string; card: string; effect: 'semutaDrug'; spiceFact: TruthFact; mixed: TruthSemutaPlayExpression; question: TruthQuestion;
setup: Game; originalCards: string[]; originalSpice: string[]; originalTraitors: string[];
actions: TruthSemutaPlayStep[]; sourceActions: TruthSemutaPlayStep[]; sourceActionCount: number;
sourceCounts: Record<string, number | null>; sourceDisclosure: string;
};
/** Native neutral controls never volunteer the promised Semuta action. The
* caller explicitly uses its guide, passes a permitted offer or selects. */
export function nextTruthSemutaPlayNative(game: Game): TruthSemutaPlayStep | null {
if (game.truthtrance || game.pendingNullentropy || game.pendingTreacheryDiscard?.reaction?.stage === 'select') return null;
return game.players.some(player => player.id === 'g') ? nextTruthTransferOrderPlayNative(game) : nextRicheseStrongholdsNativeStep(game);
}
export function advanceTruthSemutaPlay(game: Game, until: (state: Game) => boolean, actions: TruthSemutaPlayStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextTruthSemutaPlayNative(game); assert.ok(step, 'Supply the actual Truth answer or committed private Semuta choice');
game = actTruthSemutaPlay(game, step.actor, step.action, actions);
}
throw Error('The finite native Semuta continuation did not reach its boundary');
}
export function passTruthSemutaOffer(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
const event = pending.batch.event;
return advanceTruthSemutaPlay(game, state => state.pendingTreacheryDiscard?.batch.event !== event, actions);
}
export function settleTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
return advanceTruthSemutaPlay(game, truthSemutaPlayClean, actions);
}
export function finishTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
const turn = game.turn;
return advanceTruthSemutaPlay(game, state => state.turn === turn + 1, actions);
}
/** Actual turn1 Distrans use and turn2 cache Semuta1 acquisition. A later
* physical Truth answer supplies its original foreign singleton discard. */
export function createTruthSemutaPlayFixture(options: { firstBid?: boolean } = {}) {
const base = createTruthSemutaPrebidFixture(options), game = structuredClone(base.game), actions = structuredClone(base.actions);
const effect = 'semutaDrug' as const, mixed = truthSemutaPlayGroup('and', truthSemutaPlayUse(),
truthSemutaPlayPayment(base.recipient, viewGame(game, base.target).bribeOptions.available));
const question: TruthQuestion = { kind: 'turnActions', target: base.target, turn: game.turn, mixed };
const source = { ...base, game, source: structuredClone(game), sourceKind: 'nativeSingleton' as const, nativeAcquisition: true,
effect, card: base.semuta, mixed, question, base,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceDisclosure: base.sourceDisclosure + ' This adapter asks only about a future completed printed Semuta use after the actual answer. ' +
'Committing a multi-card search alone is not completion; no selected-card predicate or hypothetical future discard is added. ' +
'The current declared foreign Truth supplies the genuine fresh singleton. All source counts derive from actual arrays.',
};
return source satisfies TruthSemutaPlaySource;
}
/** Explicit unit supplement: inherit the existing bare Advanced conserved
* battle setup, then conserve only original hands before the real question.
* Acquisition is NOT native evidence. No fresh batch/event is installed. */
export function createTruthSemutaMultiFixture() {
const inherited = semutaBattleKaramaFixture('capture'), game = structuredClone(inherited.staged);
const originalCards = truthSemutaPlayInventory(game), originalSpice = truthSemutaPlaySpice(game), originalTraitors = truthSemutaPlayTraitors(game);
const target = inherited.holder, asker = inherited.winner, recipient = asker, opponent = inherited.loser;
for (const actor of [asker, opponent]) game.deck.push(...truthSemutaPlayPlayer(game, actor).hand.splice(0));
const move = (id: string, holder: string) => {
for (const zone of [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...game.players.map(player => player.hand)]) {
const at = zone.findIndex(card => card.id === id);
if (at >= 0) { truthSemutaPlayPlayer(game, holder).hand.push(zone.splice(at, 1)[0]); return id; }
}
throw Error('Conserve the original physical card for this explicitly controlled supplement');
};
const truths = game.deck.filter(card => card.effect === 'truthtrance').map(card => card.id); assert.equal(truths.length, 2);
move(truths[0], asker); move(truths[1], opponent);
const weapons = game.deck.filter(card => card.kind === 'projectile').slice(0, 2).map(card => card.id);
const defenses = game.deck.filter(card => card.kind === 'shield').slice(0, 2).map(card => card.id);
assert.equal(weapons.length, 2); assert.equal(defenses.length, 2);
const winnerCards = [move(weapons[0], asker), move(defenses[0], asker)], loserCards = [move(weapons[1], opponent), move(defenses[1], opponent)];
assert.ok(!game.battle && !game.pendingTreacheryDiscard && truthSemutaPlayClean(game));
assert.ok(truthSemutaPlayPlayer(game, target).hand.some(card => card.id === inherited.semuta));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
assert.deepEqual(truthSemutaPlayInventory(game), originalCards);
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthSemutaPlayPlayer(game, target).spice };
const mixed = truthSemutaPlayGroup('or', truthSemutaPlayUse(), truthSemutaPlayPayment(recipient));
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
const actions: TruthSemutaPlayStep[] = [];
const source = { game, source: structuredClone(game), sourceKind: 'controlledMulti' as const, nativeAcquisition: false,
target, asker, recipient, truth: truths[0], truths, truthHolders: [asker, opponent], semuta: inherited.semuta,
card: inherited.semuta, effect: 'semutaDrug' as const, spiceFact, mixed, question,
setup: structuredClone(inherited.staged), originalCards, originalSpice, originalTraitors,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, inheritedRecordedActions: null },
producer: { actor: game.active!, opponent, territory: 'arrakeen', winner: asker, loser: opponent, winnerCards, loserCards },
sourceDisclosure: 'CONTROLLED CONSERVED UNIT SUPPLEMENT, not native acquisition/history evidence. ' +
'Inherit semutaBattleKaramaFixture(capture).staged, whose hands, wallets, armies, phase and turn were explicitly positioned by that older unit fixture. ' +
'Conserve its original hands into separate projectile/Shield pairs and two original foreign Truths. ' +
'The actual Truth declaration/answer, permitted first-offer passes, chooseBattle, plans, Traitor declines and original outcome acknowledgements later generate a genuine two-card loser-disposal event. ' +
'No live battle, fresh discard batch, event, selection, receipt or promise is installed. The inherited fixture records no action array; its action count is explicitly unavailable. Only new actual controls are counted.',
};
return source satisfies TruthSemutaPlaySource;
}
export type TruthSemutaMultiFixture = ReturnType<typeof createTruthSemutaMultiFixture>;
export function declareTruthSemutaPlay(f: TruthSemutaPlaySource, source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
const asker = f.truthHolders[copy]; let game = actTruthSemutaPlay(source, asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthSemutaPlay(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
}
export function askTruthSemutaPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthSemutaPlayStep[] = []): Game {
return actTruthSemutaPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthSemutaPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthSemutaPlayStep[] = []): Game {
return actTruthSemutaPlay(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthSemutaPlay(f: TruthSemutaPlaySource, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
const declared = declareTruthSemutaPlay(f, source, copy, actions), asked = askTruthSemutaPlay(declared.game, declared.asker, question, actions);
return { game: answerTruthSemutaPlay(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthSemutaPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
}
export function openTruthSemutaMultiBatch(f: TruthSemutaMultiFixture, source: Game, actions: TruthSemutaPlayStep[] = []): Game {
let game = source;
if (game.pendingTreacheryDiscard) game = passTruthSemutaOffer(game, actions);
assert.ok(truthSemutaPlayClean(game));
game = actTruthSemutaPlay(game, f.producer.actor, { type: 'chooseBattle', territory: f.producer.territory,
target: f.producer.actor === f.producer.winner ? f.producer.loser : f.producer.winner }, actions);
game = advanceTruthSemutaPlay(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && truthSemutaPlayClean(state), actions);
const strongest = truthSemutaPlayPlayer(game, f.producer.winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const weakest = truthSemutaPlayPlayer(game, f.producer.loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength);
game = actTruthSemutaPlay(game, f.producer.winner, { type: 'battlePlan', dial: 1, support: 1, leader: strongest.id,
weapon: f.producer.winnerCards[0], defense: f.producer.winnerCards[1] }, actions);
game = actTruthSemutaPlay(game, f.producer.loser, { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id,
weapon: f.producer.loserCards[0], defense: f.producer.loserCards[1] }, actions);
game = advanceTruthSemutaPlay(game, state => !!state.pendingTreacheryDiscard && state.pendingTreacheryDiscard.batch.entries.length >= 2, actions);
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id).sort(), [...f.producer.loserCards].sort());
assert.equal(game.pendingTreacheryDiscard!.reaction?.stage, 'offer'); return game;
}
export function commitTruthSemutaPlay(game: Game, target: string, actions: TruthSemutaPlayStep[] = []): Game {
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'offer');
return actTruthSemutaPlay(game, target, { type: 'semutaCommit', event: offer.batch.event }, actions);
}
export function selectTruthSemutaPlay(game: Game, target: string, selected: string, actions: TruthSemutaPlayStep[] = []): Game {
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'select');
return actTruthSemutaPlay(game, target, { type: 'semutaSelect', event: offer.batch.event, card: selected }, actions);
}
/** One actual recovery producer, rather than installed recovered custody.
* Basic Guild retains its original3 after free Semuta use, then spends the
* next original Box cache1 and printed search2 before choosing Semuta. */
export function recoverTruthSemutaWithNativeBox(f: TruthSemutaPlaySource, source: Game, actions: TruthSemutaPlayStep[] = []) {
assert.equal(f.sourceKind, 'nativeSingleton'); assert.equal(source.advanced, false);
assert.equal(source.discard.filter(card => card.id === f.semuta).length, 1);
let game = source; const turn = game.turn;
game = advanceTruthSemutaPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions);
const beforePurchase = structuredClone(game), box = game.richeseCache?.find(card => card.effect === 'nullentropyBox'); assert.ok(box);
const from = actions.length;
game = actTruthSemutaPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: box.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id) &&
truthSemutaPlayClean(game) && !game.richeseAuction && !!game.auction && game.auction.bid === 0 && game.auction.passed.length === 0); count++) {
const lot = game.richeseAuction;
const step = truthSemutaPlayClean(game) && lot?.cardId === box.id && !lot.outcome && lot.active === f.target && lot.bid === 0
? { actor: f.target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
: nextTruthSemutaPlayNative(game);
assert.ok(step); game = actTruthSemutaPlay(game, step.actor, step.action, actions);
}
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id));
assert.equal(truthSemutaPlayPlayer(beforePurchase, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 1);
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
const beforeRecovery = structuredClone(game);
game = actTruthSemutaPlay(game, f.target, { type: 'card', card: box.id }, actions); const paid = structuredClone(game);
if (game.pendingNullentropy) {
const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search && search.cards.some(card => card.id === f.semuta));
game = actTruthSemutaPlay(game, f.target, { type: 'decision', event: search.event, card: f.semuta }, actions);
}
game = settleTruthSemutaPlay(game, actions);
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(truthSemutaPlayPlayer(beforeRecovery, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 2);
assert.deepEqual(truthSemutaPlayInventory(game), f.originalCards);
return { game, box: box.id, purchase, beforeRecovery, paid, afterRecovery: structuredClone(game), actions };
}