Bind printed Box use to shared Truthtrance funding
This commit is contained in:
1 parent
3abde581db
commit
3e959504ef
20 files changed
+948
-57
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user