148 lines
12 KiB
TypeScript
148 lines
12 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 { createHajrReplayFixture, hajrReplayMove } from './fixture-hajr-replay';
|
||
import { actTruthMovementBox, truthMovementBoxInventory, truthMovementBoxPlayer } from './fixture-truth-movement-box';
|
||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||
import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper';
|
||
|
||
export type TruthBoxPlayLeaf = { kind: 'cardPlay'; effect: 'nullentropyBox' } |
|
||
{ kind: 'payment'; recipient: string; minimum: number } | { kind: 'ship'; territory: string; minimum: number };
|
||
export type TruthBoxPlayExpression = MixedActionExpression<TruthBoxPlayLeaf>;
|
||
export type TruthBoxPlayStep = { actor: string; action: Action };
|
||
export const actTruthBoxPlay = actTruthMovementBox;
|
||
export const truthBoxPlayPlayer = truthMovementBoxPlayer;
|
||
export const truthBoxPlayInventory = truthMovementBoxInventory;
|
||
export const truthBoxPlaySpice = truthTurnThumperSpice;
|
||
export const truthBoxPlayTraitors = truthTurnThumperTraitors;
|
||
export const truthBoxPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment;
|
||
export const truthBoxPlayUse = (): TruthBoxPlayExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } });
|
||
export const truthBoxPlayFact = (fact: TruthFact): TruthBoxPlayExpression => ({ kind: 'fact', fact });
|
||
export const truthBoxPlayPayment = (recipient: string, minimum = 2): TruthBoxPlayExpression =>
|
||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||
export const truthBoxPlayShip = (territory = 'polar_sink', minimum = 1): TruthBoxPlayExpression =>
|
||
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
|
||
export const truthBoxPlayGroup = (kind: 'and' | 'or', ...terms: TruthBoxPlayExpression[]): TruthBoxPlayExpression => ({ kind, terms });
|
||
|
||
/** Original controls only. The actual paid private selection is left to the
|
||
* caller; no free discard inspection or selected-card promise is introduced. */
|
||
export function nextTruthBoxPlayNative(game: Game): TruthBoxPlayStep | null {
|
||
if (game.truthtrance || game.pendingNullentropy) return null;
|
||
return nextRicheseStrongholdsNativeStep(game);
|
||
}
|
||
export function nextTruthBoxPlay(game: Game): TruthBoxPlayStep | null {
|
||
if (game.truthtrance || game.pendingNullentropy) return null;
|
||
for (const player of game.players) {
|
||
const guide = viewGame(game, player.id).turnActionGuide;
|
||
if (guide) return { actor: player.id, action: guide.action };
|
||
}
|
||
return nextTruthBoxPlayNative(game);
|
||
}
|
||
export function advanceTruthBoxPlay(game: Game, until: (state: Game) => boolean,
|
||
actions: TruthBoxPlayStep[] = [], nativeOnly = false): Game {
|
||
for (let count = 0; count < 1800; count++) {
|
||
if (until(game)) return game;
|
||
assert.notEqual(game.status, 'finished', 'The original Box chronology ended before its requested boundary');
|
||
const step = nativeOnly ? nextTruthBoxPlayNative(game) : nextTruthBoxPlay(game);
|
||
assert.ok(step, 'Supply the actual Truthtrance, paid Box selection or native sealed plan');
|
||
game = actTruthBoxPlay(game, step.actor, step.action, actions);
|
||
}
|
||
throw Error('The original Box chronology did not reach its finite boundary');
|
||
}
|
||
export function settleTruthBoxPlay(game: Game, actions: TruthBoxPlayStep[] = []): Game {
|
||
return advanceTruthBoxPlay(game, truthBoxPlayClean, actions, true);
|
||
}
|
||
export function finishTruthBoxPlay(game: Game, actions: TruthBoxPlayStep[] = []): Game {
|
||
const turn = game.turn;
|
||
return advanceTruthBoxPlay(game, state => state.turn === turn + 1, actions, true);
|
||
}
|
||
|
||
/** Exact native prefix of the existing public Richese/Emperor/Guild source.
|
||
* Box is bought from the real cache for1 and Hajr in the real normal auction
|
||
* for1. Stop BEFORE its recorded six-spice shipment, retaining Emperor8.
|
||
* Basic's real Semuta marker is deliberately retained. */
|
||
export function createTruthBoxPlayFixture(options: { advanced?: boolean; multiple?: boolean; late?: boolean } = {}) {
|
||
const advanced = options.advanced ?? false, multiple = options.multiple ?? false, late = options.late ?? false;
|
||
const base = createHajrReplayFixture({ advanced, truthForGuild: true, truthForRichese: true });
|
||
const shipmentIndex = base.actions.findIndex(step => step.actor === base.shipmentStep.actor &&
|
||
step.action.type === 'ship' && step.action.territory === base.shipmentStep.action.territory && step.action.amount === 3);
|
||
assert.ok(shipmentIndex >= base.acquiredActionCount);
|
||
let game = structuredClone(base.beforeShipment); const actions = structuredClone(base.actions.slice(0, shipmentIndex));
|
||
const inheritedActions = actions.length, target = base.owner, asker = 'guild', recipient = asker;
|
||
assert.ok(base.guildTruth && base.richeseTruth);
|
||
const truths = [base.guildTruth, base.richeseTruth], truthHolders = [asker, 'richese'];
|
||
const originalCards = truthBoxPlayInventory(base.setup), originalSpice = truthBoxPlaySpice(base.setup), originalTraitors = truthBoxPlayTraitors(base.setup);
|
||
assert.equal(truthBoxPlayPlayer(game, target).spice, 8); assert.equal(truthBoxPlayPlayer(game, target).shipped, false);
|
||
assert.ok(truthBoxPlayPlayer(game, target).hand.some(card => card.id === base.box));
|
||
assert.equal(game.discard.length, 0, 'No played cost or discard is inserted in the original prefix');
|
||
const sourcePreparation: { before: Game; steps: TruthBoxPlayStep[]; after: Game } | null = multiple ? (() => {
|
||
const before = structuredClone(game), from = actions.length;
|
||
game = settleTruthBoxPlay(actTruthBoxPlay(game, target,
|
||
{ type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 }, actions), actions);
|
||
game = settleTruthBoxPlay(actTruthBoxPlay(game, target, hajrReplayMove(game, 'imperial_basin:9'), actions), actions);
|
||
game = settleTruthBoxPlay(actTruthBoxPlay(game, target, { type: 'card', card: base.hajr }, actions), actions);
|
||
assert.equal(truthBoxPlayPlayer(game, target).spice, 6); assert.equal(truthBoxPlayPlayer(game, target).moved, 1);
|
||
assert.equal(game.discard.filter(card => card.id === base.hajr).length, 1);
|
||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||
})() : null;
|
||
if (late) game = advanceTruthBoxPlay(game, state => state.phase === 8 && truthBoxPlayClean(state), actions, true);
|
||
assert.ok(truthBoxPlayClean(game)); assert.equal(game.advanced, advanced); assert.equal(!!game.semutaPreview, !advanced);
|
||
assert.ok(truthBoxPlayPlayer(game, target).hand.some(card => card.id === base.box));
|
||
for (const [index, truth] of truths.entries()) assert.ok(truthBoxPlayPlayer(game, truthHolders[index]).hand.some(card => card.id === truth));
|
||
assert.deepEqual(truthBoxPlayInventory(game), originalCards); assert.deepEqual(truthBoxPlaySpice(game), originalSpice);
|
||
assert.deepEqual(truthBoxPlayTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthBoxPlayPlayer(game, target).spice };
|
||
const mixed = truthBoxPlayGroup('and', truthBoxPlayUse(), truthBoxPlayPayment(recipient));
|
||
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
|
||
return { game, source: structuredClone(game), base, advanced, multiple, late, asker, target, recipient,
|
||
truths, truthHolders, truth: truths[0], box: base.box, hajr: base.hajr, boxFee: 2, spiceFact, mixed, question,
|
||
setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal,
|
||
originalCards, originalSpice, originalTraitors, sourcePreparation,
|
||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||
sourceCounts: { inherited: inheritedActions, nativeContinuation: actions.length - inheritedActions, selected: actions.length,
|
||
paidCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length,
|
||
paidOrdinaryBids: actions.filter(step => step.actor === target && step.action.type === 'bid' && step.action.amount === 1).length },
|
||
boxAction: { type: 'card', card: base.box } satisfies Action,
|
||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
|
||
end: { type: 'endMovement' } satisfies Action,
|
||
sourceDisclosure: 'Genuine public Richese/Emperor/Guild setup with original selected-CHOAM deck and Richese cache. ' +
|
||
'Only the two original unopened Guild/Richese Truth deals, original unopened Hajr/Spice positions and scalar lotteries are selected. ' +
|
||
'The exact actual cache1/normal1 action prefix stops before the older fixture’s desert shipment, preserving Emperor8 and unused shipment. ' +
|
||
'Optional multiple-choice preparation actually ships one ordinary force for2, completes one move and plays the original Hajr; Advanced uses its earned immediate timing. ' +
|
||
'Basic retains its native Semuta preview and actual declined fresh-discard acknowledgements. A future answered Truth supplies its real disposal before Box activation. ' +
|
||
'No selected recovery or hidden recovered-card effect is promised or forecast. No played hand, purse, force, phase, receipt or marker is installed. Counts derive from actual arrays.',
|
||
};
|
||
}
|
||
export type TruthBoxPlayFixture = ReturnType<typeof createTruthBoxPlayFixture>;
|
||
export function declareTruthBoxPlay(f: TruthBoxPlayFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthBoxPlayStep[] = []) {
|
||
const asker = f.truthHolders[copy]; let game = actTruthBoxPlay(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 = actTruthBoxPlay(game, actor.id, { type: 'truthPass' }, actions);
|
||
}
|
||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
|
||
}
|
||
export function askTruthBoxPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthBoxPlayStep[] = []): Game {
|
||
return actTruthBoxPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
|
||
}
|
||
export function answerTruthBoxPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBoxPlayStep[] = []): Game {
|
||
return actTruthBoxPlay(game, target, { type: 'truthAnswer', answer }, actions);
|
||
}
|
||
export function bindTruthBoxPlay(f: TruthBoxPlayFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
|
||
source = f.game, copy: 0 | 1 = 0, actions: TruthBoxPlayStep[] = []) {
|
||
const declared = declareTruthBoxPlay(f, source, copy, actions), asked = askTruthBoxPlay(declared.game, declared.asker, question, actions);
|
||
return { game: answerTruthBoxPlay(asked, f.target, answer, actions), declared, asked, actions };
|
||
}
|
||
export function truthBoxPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? 'emperor') {
|
||
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
|
||
}
|
||
/** Caller chooses only after actual payment has opened the private search. */
|
||
export function chooseTruthBoxPlay(game: Game, target: string, selected: string, actions: TruthBoxPlayStep[] = []): Game {
|
||
const search = viewGame(game, target).nullentropy?.search; assert.ok(search);
|
||
assert.ok(search.cards.some(card => card.id === selected));
|
||
return actTruthBoxPlay(game, target, { type: 'decision', event: search.event, card: selected }, actions);
|
||
}
|