Bind printed Box use to shared Truthtrance funding

This commit is contained in:
Quintine committed 2026-10-27 07:09:30 +11:00
1 parent 3abde581db
commit 3e959504ef
20 files changed
+948 -57

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
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);
}
+26
View File
@@ -2,11 +2,37 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
PrintedCardPlayError,
PRINTED_CARD_EFFECTS,
printedCardPlayCount,
recordPrintedCardPlay,
type PrintedCardPlayCounts,
} from '../game/printed-card-play';
void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => {
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox']);
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
const replay = recordPrintedCardPlay(first, 'richese', 3, 'nullentropyBox');
assert.equal(printedCardPlayCount(replay, 'richese', 3, 'nullentropyBox'), 2);
assert.equal(printedCardPlayCount(replay, 'richese', 3, 'ghola'), 1);
assert.equal(printedCardPlayCount(ghola, 'richese', 3, 'nullentropyBox'), 0);
assert.deepEqual(first.plays[1], { player: 'richese', effect: 'nullentropyBox', count: 1 });
assert.deepEqual(recordPrintedCardPlay(replay, 'richese', 4, 'nullentropyBox'),
{ turn: 4, plays: [{ player: 'richese', effect: 'nullentropyBox', count: 1 }] });
});
void test('Box activation counts retain safe overflow and strict per-player role metadata', () => {
const full: PrintedCardPlayCounts = { turn: 3, plays: [{ player: 'richese', effect: 'nullentropyBox', count: Number.MAX_SAFE_INTEGER }] };
const before = structuredClone(full);
assert.throws(() => recordPrintedCardPlay(full, 'richese', 3, 'nullentropyBox'), PrintedCardPlayError);
assert.deepEqual(full, before);
for (const plays of [[...full.plays, ...full.plays], [{ ...full.plays[0], card: 'physical-box' }],
[{ ...full.plays[0], effect: 'Nullentropy Box' }], [{ ...full.plays[0], count: 0 }]])
assert.throws(() => printedCardPlayCount({ turn: 3, plays } as PrintedCardPlayCounts,
'richese', 4, 'nullentropyBox'), PrintedCardPlayError);
});
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
+211
View File
@@ -0,0 +1,211 @@
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);
}
}
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { viewGame, type Game } from '../game/engine';
import {
askTruthTurnCombat as ask,
createTruthTurnCombatFixture as fixture,
declareTruthTurnCombat as declare,
truthTurnCombatGroup as group,
truthTurnCombatInventory as inventory,
truthTurnCombatPlan as plan,
truthTurnCombatPlayer as player,
truthTurnCombatTraitors as traitors,
type TruthTurnCombatFixture,
} from './fixture-truth-turn-combat';
type OpaqueQuote = Game & { _turnActionUnknownCards: string[] };
/** Detached-forecast-only metadata supplement. The fixture's card was really
* acquired and is physically held; this marker models an opaque future card
* only for read-only quotation. It is never installed by a gameplay action. */
function opaqueQuote(source: Game, card: string): OpaqueQuote {
return Object.assign(structuredClone(source), { _turnActionUnknownCards: [card] });
}
function originalWeapon(f: TruthTurnCombatFixture) {
assert.ok(f.ordinary);
assert.equal(f.game.battle!.plans[f.target], undefined);
const held = player(f.game, f.target).hand.find(card => card.id === f.ordinary);
assert.ok(held);
const canonical = baseDeck().find(card => card.id === held.id);
assert.ok(canonical);
assert.equal(canonical.kind, 'projectile');
assert.deepEqual(held, canonical);
assert.equal(f.sourceActions.length, f.sourceActionCount);
assert.equal(Object.hasOwn(f.game, '_turnActionUnknownCards'), false);
return held.id;
}
function physical(source: Game, f: TruthTurnCombatFixture) {
assert.deepEqual(inventory(source), f.originalCards);
assert.deepEqual(traitors(source), f.originalTraitors);
assert.equal(inventory(source).length, 33);
}
void test('detached opaque forecast cannot fund a whole positive weapon-plan quote with the same physically held canonical card', () => {
const f = fixture(), card = originalWeapon(f), original = structuredClone(f.game);
const mixed = plan(f.event, f.territory, f.weaponClaim);
const asked = ask(declare(f).game, f.asker, f.target, mixed), before = structuredClone(asked);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no']);
const opaque = opaqueQuote(asked, card), quoteBefore = structuredClone(opaque);
assert.ok(player(opaque, f.target).hand.some(held => held.id === card));
const opaqueView = viewGame(opaque, f.target);
assert.deepEqual(opaqueView.truthTurnActionAnswers, ['no']);
assert.equal(Object.hasOwn(opaqueView, '_turnActionUnknownCards'), false);
assert.deepEqual(opaque, quoteBefore);
assert.deepEqual(asked, before);
assert.deepEqual(f.game, original);
assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false);
physical(asked, f); physical(opaque, f); physical(f.game, f);
});
void test('detached opaque weapon forecast retains an independent positive dial-plan alternative and whole No', () => {
const f = fixture(), card = originalWeapon(f), original = structuredClone(f.game);
const mixed = group('or', plan(f.event, f.territory, f.weaponClaim),
plan(f.event, f.territory, { kind: 'dial', compare: 'gte', value: 1 }));
const asked = ask(declare(f).game, f.asker, f.target, mixed), before = structuredClone(asked);
const known = viewGame(asked, f.target).truthTurnActionAnswers;
assert.ok(known?.includes('yes'));
assert.ok(known?.includes('no'), 'An unarmed zero-dial plan satisfies the whole No');
const opaque = opaqueQuote(asked, card), quoteBefore = structuredClone(opaque);
const answers = viewGame(opaque, f.target).truthTurnActionAnswers;
assert.ok(answers?.includes('yes'), 'The positive-dial branch does not borrow the opaque weapon');
assert.ok(answers?.includes('no'), 'An unarmed zero-dial plan remains legal with the opaque card held');
assert.deepEqual(opaque, quoteBefore);
assert.deepEqual(asked, before);
assert.deepEqual(f.game, original);
assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false);
physical(asked, f); physical(opaque, f); physical(f.game, f);
});
+90 -6
View File
@@ -31,7 +31,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
@@ -141,6 +141,14 @@ function openingAmal(phase = 4, next: Action = { type: 'card', card: 'controlled
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('amal', `phase-opening:2:${phase}`), payment()] };
return game;
}
function boxPlay(phase = 7): GameView {
const game = projected(phase, { type: 'card', card: 'controlled-box' });
const card = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4; me.spice = 3;
game.active = 'a'; game.nullentropy = { card, blocked: null, search: null };
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('nullentropyBox'), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -1252,24 +1260,36 @@ void test('turn-action printed-role editor uses public names without holdings or
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola']);
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox']);
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
for (const extra of [{ card: 'private-box-id' }, { selected: 'private-recovery' }, { amount: 2 }])
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox', ...extra } }, game, 'p'));
const draft = editor({ kind: 'action', action: { kind: 'cardPlay', effect: 'unknown-role' } } as unknown as Expression, false, game);
assert.ok(draft.error()); select(draft.tree(), '1', 'effect', 'nullentropyBox');
assert.deepEqual(draft.value, printedCard('nullentropyBox')); assert.equal(draft.error(), null);
const html = e.html(); assert.match(html, /Play Hajr in its printed role after this answer this turn/);
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
assert.match(html, /Activate Nullentropy Box and pay 2 spice after this answer this turn/);
assert.match(html, /The selected recovered card stays private and is not promised/);
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('ghola')] }, false, game);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay'); }
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game);
for (let count = 3; count <= 16; count++) {
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
select(full.tree(), `1.${count}`, 'effect', 'nullentropyBox');
}
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), printedCard('ghola')] }, false, game);
const deep = editor({ kind: 'and', terms: [fact(), printedCard('nullentropyBox')] }, false, game);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
@@ -1345,9 +1365,72 @@ void test('printed Hajr guides require the actual held card and current own imme
assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).props.disabled, true);
});
void test('printed Box guides activate the quoted physical card without inspecting private recovery choices', () => {
for (const phase of [0, 6, 8]) {
const game = boxPlay(phase), sent: Action[] = [];
Object.defineProperty(game, 'discard', { get() { throw Error('The activation guide does not search the discard'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Box receipt inspection'); } });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'card', card: 'controlled-box', turnActionPromiseEvent: event, turnActionPromisePhase: phase });
const html = markup(game);
assert.match(html, /Pay 2 spice to activate Nullentropy Box/);
assert.match(html, /you activate Nullentropy Box in its printed role and pay its 2-spice search cost/);
assert.match(html, /The selected recovered card stays private and is not promised/);
assert.match(html, /complete the paid search with the existing native controls/);
assert.doesNotMatch(html, /cardPlays|controlled-box|recovered-card-id/);
}
});
void test('printed Box activation callbacks reject changed native quotes, custody, source and paid-search state', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.nullentropy = null; },
game => { game.nullentropy = { ...game.nullentropy! }; },
game => { game.nullentropy!.blocked = 'The printed search cost is not currently available.'; },
game => { game.nullentropy!.card = { ...game.nullentropy!.card, id: 'different-box' }; },
game => { game.players.find(player => player.id === 'p')!.hand = []; },
game => { game.players.find(player => player.id === 'p')!.hand![0].kind = 'worthless'; },
game => { game.players.find(player => player.id === 'p')!.spice = 2; },
game => { game.players.find(player => player.id === 'p')!.handLimit = 5; },
game => { game.turnActionPromises[0].mixed = payment(); },
game => { game.phase = 8; },
game => { game.turnActionGuide!.event = 'different-answer'; },
game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => {
game.nullentropy!.search = { event: 'now-paid-search', cards: [] };
Object.defineProperty(game.nullentropy!.search, 'cards', { get() { throw Error('An activation guide cannot inspect paid recovery choices'); } });
},
];
for (const change of changes) {
const game = boxPlay(2), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const change of [
(game: GameView) => { game.nullentropy = null; },
(game: GameView) => { game.nullentropy!.blocked = 'No legal search is currently quoted.'; },
(game: GameView) => { game.nullentropy!.search = { event: 'already-paid', cards: [] }; },
]) {
const game = boxPlay(); change(game);
const button = guide({ game, busy: false, act() { assert.fail('An unavailable or already paid Box cannot activate'); } });
assert.equal(button.props.disabled, true); press(button);
}
});
void test('printed Box activation accepts no private selection or extra native parameters through generic preparation', () => {
for (const extra of [{ amount: 2 }, { mode: 'shipment' }, { target: 'a' }, { leader: 'private-leader' },
{ selected: 'recovered-card-id' }, { cards: ['recovered-card-id'] }]) {
const game = boxPlay(2); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('A Box activation must remain the exact quoted card packet'); } });
assert.equal(button.props.disabled, true); press(button);
}
const game = boxPlay(8); game.turnActionPromises[0].mixed = payment();
assert.equal(guide({ game, busy: false, act() { assert.fail('A turn-end Box activation requires its matching raw role'); } }).props.disabled, true);
});
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
const game = projected(8); game.turnActionGuide = null;
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola')] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox')] }] };
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
@@ -1355,6 +1438,7 @@ void test('printed-card history retains only raw aggregate conditions and their
assert.match(html, /Hajr counts even if its extra movement is unused/);
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/);
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
});
+24
View File
@@ -15,6 +15,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
import {
truthFactAnswer,
truthQuestionText,
validateTruthQuestionReceipt,
type TruthFact,
type TruthQuestion,
} from '../game/truthtrance';
@@ -31,6 +32,29 @@ void test('whole card questions name Amal, Thumper and the actual printed Karama
assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
});
void test('whole Box questions describe future printed activation and its search payment without choosing the recovered card', () => {
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } } };
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, /activate Nullentropy Box.*printed role.*pay its 2-spice search cost after this answer this turn/);
assert.doesNotMatch(text, /Ghola|selected|recover.*Shield/);
});
void test('Box raw question parsing needs no hidden possession and eagerly rejects extra selected-card data in folded clauses', () => {
const game = fixture();
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'nullentropyBox'));
const action = { kind: 'cardPlay' as const, effect: 'nullentropyBox' as const };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { cost: 2 }, { recipient: 'h' }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } };
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid as TruthQuestion));
}
});
function fixture() {
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(
+74 -3
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from '../game/window-card-play';
import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from '../game/printed-card-play';
import {
compileMixedActionExpression,
type MixedActionExpression,
@@ -56,7 +57,7 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
const call = (call = true, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const card = (effect: PrintedCardEffect = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
@@ -98,9 +99,16 @@ function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
}
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), cardPlays: {
const row: TurnActionPromise = { ...promise(mixed, answer, id), cardPlays: {
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
} };
const visit = (node: Expression): void => {
if (node.kind === 'action' && node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox')
row.cardPlays!.nullentropyBox = { played: false, closed: false };
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
return row;
}
function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
const row: TurnActionPromise = { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag',
@@ -917,7 +925,7 @@ void test('native off-board combat locations retain exact event and location bin
});
void test('printed card-play leaves contain only the printed effect and every raw clause retains its own stage', () => {
for (const effect of ['hajr', 'ghola'] as const) {
for (const effect of PRINTED_CARD_EFFECTS) {
const original = { kind: 'cardPlay', effect };
assert.deepEqual(parseTurnActionLeaf(original), original);
assert.notEqual(parseTurnActionLeaf(original), original);
@@ -1618,3 +1626,66 @@ void test('Amal whole AND and OR keep printed receipt and later payment alternat
assert.equal(expired.fulfilled, true);
validateTurnActionPromises([expired], seats);
});
void test('Box AND and OR branches count only an actual future printed activation in each answer interval', () => {
const original = cards(group('and', card('nullentropyBox'), card('ghola'), pay()));
const alternative = cards(group('or', card('nullentropyBox'), pay()), true, 'turn-action:2');
const signed = { kind: 'cardPlay', effect: 'nullentropyBox', answer: true };
assert.deepEqual(branches(original), [[signed, { kind: 'cardPlay', effect: 'ghola', answer: true }, signedPay()]]);
for (const [player, turn] of [['other', 1], ['respondent', 2]] as const)
assert.equal(recordTurnActionCardPlay([original], player, turn, 'nullentropyBox')[0], original);
const used = recordTurnActionCardPlay([original, alternative], 'respondent', 1, 'nullentropyBox');
assert.deepEqual(branches(used[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }, signedPay()]]);
assert.equal(used[1].fulfilled, true);
const fresh = cards(card('nullentropyBox'), true, 'turn-action:3');
assert.equal(fresh.cardPlays!.nullentropyBox!.played, false, 'An earlier accepted play or later recovery is not a future receipt');
const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'nullentropyBox');
assert.equal(replay[0], used[0]);
assert.equal(replay[1].fulfilled, true);
assert.equal(original.cardPlays!.nullentropyBox!.played, false);
const ghola = recordTurnActionCardPlay([used[0]], 'respondent', 1, 'ghola');
assert.equal(recordTurnActionPayment(ghola, 'respondent', 1, payment())[0].fulfilled, true);
validateTurnActionPromises(used, seats);
});
void test('Box No remains binding through transport and payment closure and settles only at the turn deadline', () => {
const no = cards(card('nullentropyBox'), false);
const moved = closeTurnActionTransport([no], 'respondent', 1);
assert.deepEqual(moved[0].cardPlays!.nullentropyBox, { played: false, closed: false });
const unpaid = closeTurnActionPayments(moved, 'respondent', 1);
assert.equal(unpaid[0].fulfilled, false);
assert.throws(() => recordTurnActionCardPlay(unpaid, 'respondent', 1, 'nullentropyBox'), TurnActionError);
assert.equal(unpaid[0].cardPlays!.nullentropyBox!.played, false);
const expired = finishTurnActionTurn(unpaid, 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.cardPlays!.nullentropyBox, { played: false, closed: true });
assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, 'nullentropyBox')[0], expired);
const wholeNo = cards(group('and', card('nullentropyBox'), pay()), false, 'turn-action:2');
const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, 'nullentropyBox');
assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(used[0].payments, []);
const yes = cards(card('nullentropyBox'), true, 'turn-action:3');
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
validateTurnActionPromises([expired], seats);
});
void test('optional Box stage preserves legacy bytes and strict raw folded metadata without recording a selected card', () => {
const old = cards(card('ghola'), false);
const before = JSON.stringify(old);
const unchanged = recordTurnActionCardPlay([old], 'respondent', 1, 'nullentropyBox')[0];
assert.equal(unchanged, old);
assert.equal(JSON.stringify(unchanged), before);
assert.equal(Object.hasOwn(closeTurnActionTransport([old], 'respondent', 1)[0].cardPlays!, 'nullentropyBox'), false);
assert.equal(Object.hasOwn(finishTurnActionTurn([old], 'respondent', 1)[0].cardPlays!, 'nullentropyBox'), false);
const row = cards(group('or', fact(), card('nullentropyBox')));
const stage = row.cardPlays!.nullentropyBox!;
for (const value of [undefined, null, { ...stage, selected: 'physical-recovered-card' },
{ ...stage, cost: 2 }, { ...stage, played: 1 }, { ...stage, closed: 'yes' }])
assert.throws(() => turnActionValue({ ...row, cardPlays: { hajr: null, ghola: null,
nullentropyBox: value } } as unknown as TurnActionPromise), TurnActionError);
assert.throws(() => turnActionValue({ ...row, cardPlays: { hajr: null, ghola: null } }), TurnActionError);
const optional = { ...old, cardPlays: { ...old.cardPlays!, nullentropyBox: null } };
validateTurnActionPromises([optional], seats);
for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { minimum: 2 }, { recipient: 'other' }])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'nullentropyBox', ...extra }), TurnActionError);
});