Connect Truthtrance promises to printed Hajr and Ghola plays

This commit is contained in:
Quintine committed 2026-10-27 01:06:08 +11:00
1 parent f548039cd8
commit c93b693137
17 files changed
+1554 -104

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
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 { createTruthTravelFixture } from './fixture-truth-travel';
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
import { createTruthMovementGholaFixture } from './fixture-truth-movement-ghola';
import { createTruthRevivalFixture } from './fixture-truth-revival';
import { createTruthLeaderRevivalFixture } from './fixture-truth-leader-revival';
import { location } from '../game/board';
import {
actHajrReplay, advanceHajrReplay, createHajrReplayFixture, hajrReplayClean, hajrReplayMove,
} from './fixture-hajr-replay';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actTruthMovement, truthMovementInventory, truthMovementPlayer, type TruthMovementStep } from './fixture-truth-movement';
import type { TruthTurnCombatLeaf } from './fixture-truth-turn-combat';
export type TruthTurnCardPlayLeaf = TruthTurnCombatLeaf | { kind: 'cardPlay'; effect: 'hajr' | 'ghola' };
export type TruthTurnCardPlayExpression = MixedActionExpression<TruthTurnCardPlayLeaf>;
export type TruthTurnCardPlayStep = TruthMovementStep;
export const actTruthTurnCardPlay = actTruthMovement;
export const truthTurnCardPlayInventory = truthMovementInventory;
export const truthTurnCardPlayPlayer = truthMovementPlayer;
export const truthTurnCardPlayClean = (game: Game) => !game.truthtrance && !game.response && !game.decision &&
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
export const truthTurnCardPlayFact = (fact: TruthFact): TruthTurnCardPlayExpression => ({ kind: 'fact', fact });
export const truthTurnCardPlayUse = (effect: 'hajr' | 'ghola'): TruthTurnCardPlayExpression =>
({ kind: 'action', action: { kind: 'cardPlay', effect } });
export const truthTurnCardPlayPayment = (recipient: string, minimum = 2): TruthTurnCardPlayExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnCardPlayShip = (territory = 'polar_sink', minimum = 1): TruthTurnCardPlayExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnCardPlayForce = (minimum = 2, counter: 'total' | 'normal' | 'elite' = 'total'):
TruthTurnCardPlayExpression => ({ kind: 'action', action: { kind: 'forces', minimum, counter } });
export const truthTurnCardPlayGroup = (kind: 'and' | 'or', ...terms: TruthTurnCardPlayExpression[]): TruthTurnCardPlayExpression =>
({ kind, terms });
/** The supported Advanced original avoids Basic Richese's Semuta preview.
* Stop after move1 then its actual Hajr, preserving one native movement for
* after the future paid Box recovery. The Box itself supplies no Hajr timing. */
function createSupportedHajrReplaySource() {
const base = createHajrReplayFixture({ advanced: true, truthForGuild: true });
const actions = structuredClone(base.actions), inherited = actions.length;
let game = structuredClone(base.game);
const firstMove = hajrReplayMove(game, base.source);
game = actHajrReplay(game, base.owner, firstMove, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
game = actHajrReplay(game, base.owner, { type: 'card', card: base.hajr }, actions);
game = advanceHajrReplay(game, hajrReplayClean, actions);
const truth = base.guildTruth; assert.ok(truth);
const originalCards = truthMovementInventory(base.setup);
assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
assert.equal(truthMovementPlayer(game, base.owner).moved, 1);
assert.equal(truthMovementPlayer(game, base.owner).hajrPlays, 1);
assert.equal(truthMovementPlayer(game, base.owner).spice, 2);
assert.ok(truthMovementPlayer(game, base.owner).hand.some(card => card.id === base.box));
assert.equal(game.discard.filter(card => card.id === base.hajr).length, 1);
assert.deepEqual(truthMovementInventory(game), originalCards);
return { ...base, game, actions, asker: 'guild', target: base.owner, truth, originalCards,
firstMove, secondMoveFrom: location(String(firstMove.territory), Number(firstMove.sector)),
sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length },
sourceDisclosure: `${base.staging.join(' ')} This is the original Advanced preview with Guild's unopened Truthtrance deal. ` +
'A real first typed movement is followed immediately by the first actual Hajr, which is now physically discarded. ' +
'The source stops at moved1/max2 with the owned Box and its exact two-spice fee. After a future answer, Box recovery must precede ' +
'the second real movement that earns new Advanced timing for replay. No Basic Semuta composition or installed timing window is claimed.',
};
}
/** Every option selects an existing genuine original source or its exact
* earlier action prefix. Native deals, casualties, purchases and already used
* Hajr all remain physical chronology; no played state is installed. */
export function createTruthTurnCardPlayFixture(options: {
source?: 'hajr' | 'gholaForces' | 'gholaRevival' | 'gholaLeader' | 'hajrReplay';
advanced?: boolean; early?: boolean; mixed?: TruthTurnCardPlayExpression;
} = {}) {
const sourceKind = options.source ?? 'hajr', requestedAdvanced = options.advanced ?? false;
assert.ok(!requestedAdvanced || sourceKind !== 'gholaForces',
'Use the genuine typed Revival source for Advanced Ghola');
const basicHajr = sourceKind === 'hajr' && !requestedAdvanced ? createTruthTravelFixture({ window: options.early ? 'charity' : 'active' }) : null;
const advancedHajr = sourceKind === 'hajr' && requestedAdvanced ? createTruthMixedMovementFixture({ advanced: true, window: 'active' }) : null;
const gholaForces = sourceKind === 'gholaForces' ? createTruthMovementGholaFixture() : null;
const gholaRevival = sourceKind === 'gholaRevival' ? createTruthRevivalFixture({ targetGhola: true, advanced: requestedAdvanced }) : null;
const gholaLeader = sourceKind === 'gholaLeader' ? createTruthLeaderRevivalFixture({ ghola: true, advanced: requestedAdvanced }) : null;
const hajrReplay = sourceKind === 'hajrReplay' ? createSupportedHajrReplaySource() : null;
const original = basicHajr ?? advancedHajr ?? gholaForces ?? gholaRevival ?? gholaLeader ?? hajrReplay!;
const game = structuredClone(original.game), actions = structuredClone(original.actions), advanced = game.advanced;
const asker = original.asker, target = original.target, recipient = asker;
const effect: 'hajr' | 'ghola' = sourceKind === 'hajr' || sourceKind === 'hajrReplay' ? 'hajr' : 'ghola';
const hajr = basicHajr?.hajr ?? advancedHajr?.hajr ?? hajrReplay?.hajr ?? null;
const ghola = gholaForces?.ghola ?? gholaRevival?.ghola ?? gholaLeader?.ghola ?? null;
const card = effect === 'hajr' ? hajr : ghola; assert.ok(card);
const truths = structuredClone(gholaRevival?.truths ?? gholaLeader?.truths ?? [original.truth]);
const mixed = structuredClone(options.mixed ?? (hajrReplay ? truthTurnCardPlayUse(effect)
: truthTurnCardPlayGroup('and', truthTurnCardPlayUse(effect), truthTurnCardPlayPayment(recipient))));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMovementPlayer(game, target).spice };
const firstMove: Action = basicHajr?.firstMove ?? advancedHajr?.firstMove ??
{ type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 };
const cardAction: Action = effect === 'hajr' ? { type: 'card', card } : gholaLeader
? { type: 'card', card, leader: gholaLeader.leader }
: { type: 'card', card, amount: 1, elite: advanced ? 1 : 0 };
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
assert.ok(truthTurnCardPlayClean(game)); assert.equal(game.turnActionPromises?.length ?? 0, 0);
assert.ok(truths.every(truth => truthMovementPlayer(game, asker).hand.some(held => held.id === truth)));
assert.deepEqual(truthMovementInventory(game), original.originalCards);
return {
game, source: structuredClone(game), sourceKind, effect, advanced, early: options.early ?? false,
basicHajr, advancedHajr, gholaForces, gholaRevival, gholaLeader, hajrReplay,
asker, target, recipient, truth: truths[0], truths, card, hajr, ghola, mixed, question, spiceFact,
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
originalCards: original.originalCards, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, inherited: original.actions.length },
cardAction, firstMove,
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,
ordinaryForces: { type: 'revive', amount: 2, elite: 0 } satisfies Action,
leader: gholaLeader?.leader ?? null, box: hajrReplay?.box ?? null,
sourceDisclosure: `${sourceDisclosure} This adapter changes only the raw turn-action question. Future accepted printed-role play ` +
'must occur after the actual answer; earlier use, recovery, an ordinary return or another role of the same card is not a new Hajr/Ghola play. ' +
'All selected source counts derive from recorded arrays. No played hand, purse, army, casualty, clock, card, answer or receipt is installed.',
};
}
export type TruthTurnCardPlayFixture = ReturnType<typeof createTruthTurnCardPlayFixture>;
export function declareTruthTurnCardPlay(f: TruthTurnCardPlayFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnCardPlayStep[] = []) {
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnCardPlay(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = actTruthTurnCardPlay(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, before, actions, actionCount: actions.length - from };
}
export function askTruthTurnCardPlay(game: Game, asker: string, target: string, mixed: TruthTurnCardPlayExpression,
actions: TruthTurnCardPlayStep[] = []): Game {
return actTruthTurnCardPlay(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnCardPlay(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthTurnCardPlayStep[] = []): Game {
return actTruthTurnCardPlay(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnCardPlay(f: TruthTurnCardPlayFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnCardPlayStep[] = []) {
const from = actions.length, declared = declareTruthTurnCardPlay(f, source, copy, actions);
const asked = askTruthTurnCardPlay(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnCardPlay(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnCardPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
}
export function nextTruthTurnCardPlayNative(game: Game): TruthTurnCardPlayStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
return nextRicheseStrongholdsNativeStep(game);
}
export function nextTruthTurnCardPlay(game: Game): TruthTurnCardPlayStep | null {
if (game.truthtrance) 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 nextTruthTurnCardPlayNative(game);
}
export function advanceTruthTurnCardPlay(game: Game, until: (state: Game) => boolean,
actions: TruthTurnCardPlayStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original card-play source ended before its requested native boundary');
const next = nativeOnly ? nextTruthTurnCardPlayNative(game) : nextTruthTurnCardPlay(game);
assert.ok(next, 'Supply the original Truthtrance, paid private selection or unsealed native battle');
game = actTruthTurnCardPlay(game, next.actor, next.action, actions);
}
throw Error('The genuine card-play source did not reach its finite boundary');
}
export function settleTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
return advanceTruthTurnCardPlay(game, truthTurnCardPlayClean, actions);
}
export function finishTruthTurnCardPlay(game: Game, actions: TruthTurnCardPlayStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnCardPlay(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnCardPlayClean(state), actions);
}
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PrintedCardPlayError,
printedCardPlayCount,
recordPrintedCardPlay,
type PrintedCardPlayCounts,
} from '../game/printed-card-play';
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');
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'hajr'), 1);
assert.equal(printedCardPlayCount(counts, 'atreides', 2, 'hajr'), 0);
assert.equal(printedCardPlayCount(counts, 'emperor', 1, 'hajr'), 0);
assert.equal(printedCardPlayCount(counts, 'atreides', 1, 'ghola'), 0);
});
void test('accepted printed-role plays count once per call and genuine replay increments without physical IDs', () => {
const first = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
const second = recordPrintedCardPlay(first, 'atreides', 3, 'hajr');
const ghola = recordPrintedCardPlay(second, 'atreides', 3, 'ghola');
const other = recordPrintedCardPlay(ghola, 'emperor', 3, 'hajr');
assert.deepEqual(other, { turn: 3, plays: [
{ player: 'atreides', effect: 'hajr', count: 2 }, { player: 'atreides', effect: 'ghola', count: 1 },
{ player: 'emperor', effect: 'hajr', count: 1 },
] });
assert.deepEqual(first, { turn: 3, plays: [{ player: 'atreides', effect: 'hajr', count: 1 }] });
assert.notEqual(second, first);
assert.notEqual(second.plays, first.plays);
assert.notEqual(second.plays[0], first.plays[0]);
});
void test('a newer turn resets the list while a past-turn write rejects without changing the baseline', () => {
const old = recordPrintedCardPlay(undefined, 'atreides', 3, 'hajr');
const next = recordPrintedCardPlay(old, 'emperor', 4, 'ghola');
assert.deepEqual(next, { turn: 4, plays: [{ player: 'emperor', effect: 'ghola', count: 1 }] });
assert.equal(printedCardPlayCount(next, 'atreides', 3, 'hajr'), 0);
const before = structuredClone(next);
assert.throws(() => recordPrintedCardPlay(next, 'atreides', 3, 'hajr'), PrintedCardPlayError);
assert.deepEqual(next, before);
});
void test('safe integer counts reject overflow immutably', () => {
const full: PrintedCardPlayCounts = { turn: 1, plays: [{ player: 'atreides', effect: 'hajr', count: Number.MAX_SAFE_INTEGER }] };
const before = structuredClone(full);
assert.equal(printedCardPlayCount(full, 'atreides', 1, 'hajr'), Number.MAX_SAFE_INTEGER);
assert.throws(() => recordPrintedCardPlay(full, 'atreides', 1, 'hajr'), PrintedCardPlayError);
assert.deepEqual(full, before);
assert.equal(recordPrintedCardPlay(full, 'atreides', 1, 'ghola').plays[1].count, 1);
});
void test('strict stored counts reject malformed state even when another turn would reset or miss it', () => {
const play = { player: 'atreides', effect: 'hajr', count: 1 };
const invalid: unknown[] = [null, [], {}, { turn: 0, plays: [] }, { turn: 1, plays: null },
{ turn: 1, plays: [], source: 'card-id' }, { turn: 1, plays: [play, play] },
...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(count => ({ turn: 1, plays: [{ ...play, count }] })),
{ turn: 1, plays: [{ ...play, player: ' atreides' }] }, { turn: 1, plays: [{ ...play, effect: 'karama' }] },
{ turn: 1, plays: [{ ...play, card: 'hajr' }] }, { turn: 1, plays: [{ ...play, printed: true }] }];
for (const value of invalid) {
assert.throws(() => printedCardPlayCount(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
assert.throws(() => recordPrintedCardPlay(value as PrintedCardPlayCounts, 'atreides', 2, 'hajr'), PrintedCardPlayError);
}
assert.doesNotThrow(() => printedCardPlayCount({ turn: 1, plays: [] }, 'atreides', 1, 'hajr'));
});
void test('request metadata remains exact, safe and uncoerced for both lookup and recording', () => {
for (const [player, turn, effect] of [['', 1, 'hajr'], ['atreides ', 1, 'ghola'], ['atreides', 0, 'hajr'],
['atreides', 1.5, 'hajr'], ['atreides', Number.MAX_SAFE_INTEGER + 1, 'hajr'], ['atreides', '1', 'hajr'],
['atreides', 1, 'Ghola'], ['atreides', 1, 'karama']]) {
assert.throws(() => printedCardPlayCount(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
assert.throws(() => recordPrintedCardPlay(undefined, player as string, turn as number, effect as never), PrintedCardPlayError);
}
});
+126 -2
View File
@@ -6,6 +6,7 @@ import { TurnActionFields, turnActionInputError } from '../components/turn-actio
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BattleClaimFields } from '../components/battle-promises';
import { baseDeck } from '../game/cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
@@ -28,6 +29,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 planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
const traitorClaim = (call = true): Expression =>
@@ -73,6 +75,17 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
if (next.type === 'card' && typeof next.card === 'string') {
const effect = next.card.includes('hajr') ? 'hajr' : next.card.includes('ghola') ? 'ghola' : 'karama';
const face = baseDeck().find(card => card.effect === effect); assert.ok(face);
const me = game.players.find(player => player.id === 'p')!;
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
if (effect === 'ghola') {
me.tanks = 5; me.elites = { reserves: 4, tanks: 1, forces: {}, revived: 0 };
game.ghola = { cards: [next.card], leaders: [{ id: 'controlled-dead-leader', name: 'Returned Commander', strength: 3 }],
kwisatz: false, maxForces: 5, eliteRemaining: 1, eliteBlock: null, available: true, reason: null };
}
}
return game;
}
function auction(next: Action = { type: 'passBid' }): GameView {
@@ -479,6 +492,117 @@ void test('turn-action combat callbacks retain current battle ownership and raw
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
});
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
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.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']);
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'));
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'));
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, /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'); }
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);
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');
select(frozen.tree(), '1', 'action', 'payment'); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, printedCard());
});
void test('printed Ghola guides forward exact native force, leader and KH choices out of turn and reject unavailable or forged choices', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }); game.active = 'a'; game.advanced = true;
game.turnActionPromises[0].mixed = printedCard('ghola');
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Ghola stage inspection'); } });
const sent: Action[] = [], 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.match(markup(game), /Play Tleilaxu Ghola to return 3 physical forces/);
}
for (const leader of ['controlled-dead-leader', 'kwisatz']) {
const game = projected(7, { type: 'card', card: 'controlled-ghola', leader }); game.active = 'a';
if (leader === 'kwisatz') { game.advanced = true; game.players.find(player => player.id === 'p')!.faction = 'atreides'; game.ghola.kwisatz = true; }
const sent: Action[] = [], 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.match(markup(game), leader === 'kwisatz' ? /return Kwisatz Haderach/ : /return Returned Commander/);
if (leader === 'kwisatz') game.ghola.kwisatz = false; else game.ghola.leaders = [];
press(button); assert.equal(sent.length, 1);
}
for (const change of [
(game: GameView) => { game.ghola = { ...game.ghola }; }, (game: GameView) => { game.ghola.available = false; },
(game: GameView) => { game.ghola.cards = []; }, (game: GameView) => { game.ghola.maxForces = 0; },
(game: GameView) => { game.ghola.eliteRemaining = 0; }, (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
(game: GameView) => { game.phase = 7; }, (game: GameView) => { game.truthtrance = answering(2).truthtrance; },
]) {
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 }), sent: Action[] = [];
const 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 malformed of [
{ card: 'missing-ghola' }, { amount: 0 }, { amount: 6 }, { amount: 1.5 }, { amount: 3, elite: 2 }, { amount: 3, elite: -1 },
{ amount: 5, elite: 0 }, { leader: 'foreign-living-leader' }, { mode: 'shipment' },
]) {
const game = projected(2, { type: 'card', card: 'controlled-ghola', amount: 3, elite: 1 });
Object.assign(game.turnActionGuide!.action, malformed);
const sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
}
});
void test('printed Hajr guides require the actual held card and current own immediate Movement opportunity', () => {
for (const advanced of [false, true]) {
const game = projected(5, { type: 'card', card: 'controlled-hajr' }); game.advanced = advanced;
game.turnActionPromises[0].mixed = printedCard();
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Hajr stage inspection'); } });
const sent: Action[] = [], 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.match(markup(game), /Play Hajr in its printed role while honoring your whole answer/);
}
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrAvailable = false; },
(game: GameView) => { const player = game.players.find(player => player.id === 'p')!; player.moved = (player.moved ?? 0) + 1; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hajrPlays = 1; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
(game: GameView) => { game.active = 'a'; }, (game: GameView) => { game.phase = 4; },
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
]) {
const game = projected(5, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
const 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 phase of [0, 2, 4, 6, 8]) {
const game = projected(phase, { type: 'card', card: 'controlled-hajr' }), sent: Action[] = [];
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
}
const missing = projected(5, { type: 'card', card: 'controlled-hajr' }); missing.turnActionGuide!.action.card = 'missing-hajr';
assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).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')] }] };
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);
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered Yes/);
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, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
@@ -534,7 +658,7 @@ void test('turn-action ordinary-payment guide retains native out-of-turn timing
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
if (phase === 8) assert.match(markup(game), /Late recipients collect next turn/);
if (phase === 8) assert.match(markup(game), /Late payment recipients collect next turn/);
}
const game = projected(8, { type: 'ready' }); game.active = null;
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
@@ -570,7 +694,7 @@ void test('turn-action current early native opportunities retain exact preparati
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
revival({ type: 'card', card: 'controlled-ghola' }), richese('silent'), richese('onceAround'),
revival({ type: 'card', card: 'controlled-ghola', amount: 1, elite: 0 }), richese('silent'), richese('onceAround'),
];
for (const game of cases) {
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
+328
View File
@@ -0,0 +1,328 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
actTruthTurnCardPlay as act, advanceTruthTurnCardPlay as advance, answerTruthTurnCardPlay as answer,
askTruthTurnCardPlay as ask, bindTruthTurnCardPlay as bind, createTruthTurnCardPlayFixture as fixture,
declareTruthTurnCardPlay as declare, finishTruthTurnCardPlay as finish, nextTruthTurnCardPlayNative as nativeStep,
settleTruthTurnCardPlay as settle, truthTurnCardPlayClean as clean, truthTurnCardPlayFact as fact,
truthTurnCardPlayForce as force, truthTurnCardPlayGroup as group, truthTurnCardPlayInventory as inventory,
truthTurnCardPlayPayment as payment, truthTurnCardPlayPlayer as player, truthTurnCardPlayPromise as promise,
truthTurnCardPlayShip as ship, truthTurnCardPlayUse as use,
type TruthTurnCardPlayFixture, type TruthTurnCardPlayStep,
} from './fixture-truth-turn-card-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: TruthTurnCardPlayFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
const quotas = (game: Game, target: string) => {
const owner = player(game, target);
return [owner.revived, owner.freeForcesRevived ?? 0, owner.leaderRevived];
};
void test('native printed-card adapters retain original deals, actual casualties and counted action prefixes', () => {
for (const options of [
{ source: 'hajr', early: true }, { source: 'hajr' }, { source: 'hajr', advanced: true },
{ source: 'gholaForces' }, { source: 'gholaRevival' }, { source: 'gholaLeader' }, { source: 'hajrReplay' },
] as const) {
const f = fixture(options); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.sourceCounts.selected, f.sourceActionCount); assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(inventory(f.game), f.originalCards);
if (f.sourceKind === 'hajr') {
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
assert.equal(f.sourceActionCount, f.early ? 13 : f.advanced ? 23 : 19);
} else if (f.sourceKind === 'hajrReplay') {
assert.equal(f.advanced, true); assert.equal(f.game.advanced, true); assert.ok(!f.game.semutaPreview);
assert.equal(player(f.game, f.target).hajrPlays, 1); assert.equal(player(f.game, f.target).moved, 1);
assert.equal(player(f.game, f.target).spice, 2); assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(f.hajrReplay!.sourceCounts.inherited + f.hajrReplay!.sourceCounts.nativeContinuation, f.sourceActionCount);
} else {
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
assert.equal(f.game.turn, 2); assert.ok(player(f.game, f.target).tanks > 0);
if (f.sourceKind === 'gholaLeader') assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
}
}
});
void test('native Basic Hajr AND payment records the actual printed play without requiring use of its extra movement', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), use('hajr'), payment(f.recipient));
const initial = structuredClone(f.game); let game = bind(f, 'yes', mixed).game;
assert.deepEqual(f.game, initial); rejected(game, f.target, f.end);
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 8);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
game = settle(act(game, f.target, f.cardAction));
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, true);
assert.equal(player(game, f.target).hajrPlays, 1); assert.equal(player(game, f.target).moved, 0);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
const receipt = structuredClone(game.turnActionPromises![0].cardPlays);
game = settle(act(game, f.target, f.end));
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.turnActionPromises![0].cardPlays, receipt);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('native Advanced Hajr guide first earns its real completed-move timing and leaves the extra movement optional', () => {
const f = fixture({ advanced: true }); let game = bind(f).game;
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
assert.equal(player(game, f.target).moved, 0); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
const preparation = viewGame(game, f.target).turnActionGuide; assert.ok(preparation);
assert.equal(preparation.action.type, 'move');
game = settle(act(game, f.target, f.firstMove)); assert.equal(player(game, f.target).moved, 1);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
game = settle(act(game, f.target, guide.action)); assert.equal(promise(game, f.target).fulfilled, true);
game = settle(act(game, f.target, f.end)); assert.equal(player(game, f.target).moved, 1);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('native whole Hajr AND payment No preserves either component and rejects only actual whole completion atomically', () => {
const f = fixture();
for (const first of ['payment', 'card'] as const) {
let game = bind(f, 'no').game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.cardAction);
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
} else {
game = settle(act(game, f.target, f.cardAction)); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
game = settle(act(game, f.target, f.end)); assert.ok(!promise(game, f.target).released);
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native Hajr OR payment retains direct play and own-Movement-close then late-payment routes', () => {
const f = fixture(), mixed = group('or', use('hajr'), payment(f.recipient)), bound = bind(f, 'yes', mixed).game;
const played = settle(act(bound, f.target, f.cardAction));
assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
let paid = settle(act(bound, f.target, f.end));
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.closed, true);
assert.equal(paid.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(paid, f.target).fulfilled);
paid = advance(paid, state => state.phase === 8 && clean(state), [], true);
rejected(paid, f.target, { type: 'ready' }); paid = act(paid, f.target, f.payment);
assert.equal(promise(paid, f.target).fulfilled, true);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card));
for (const state of [played, paid]) physical(state, f);
});
void test('native held Ghola force play AND payment returns the actual Storm casualty before a separately paid shipment', () => {
const f = fixture({ source: 'gholaForces' }), mixed = group('and', use('ghola'), payment(f.recipient), ship());
let game = bind(f, 'yes', mixed).game; const ordinary = quotas(game, f.target);
assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).reserves, 9);
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.target).tanks, 0);
assert.equal(player(game, f.target).reserves, 10); assert.deepEqual(quotas(game, f.target), ordinary);
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('native Ghola OR payment preserves either actual late Mentat play or payment after own Movement has ended', () => {
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
let late = bind(f, 'yes', mixed).game; late = settle(act(late, f.target, f.end));
assert.equal(late.turnActionPromises![0].cardPlays?.ghola?.closed, false);
late = advance(late, state => state.phase === 8 && clean(state), [], true);
rejected(late, f.target, { type: 'ready' });
for (const action of [f.cardAction, f.payment]) {
const game = settle(act(late, f.target, action)); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, action.type === 'card');
assert.equal(player(game, f.target).tanks, action.type === 'card' ? 0 : 1); physical(game, f);
}
});
void test('controlled known Double payment expiry retains the actual late Ghola alternative', () => {
const f = fixture({ source: 'gholaForces' });
// Preserve the actual played source, held card and casualty. Only the public
// token is controlled; this does not assert an original CHOAM source journey.
const controlled = structuredClone(f.game);
controlled.inflation = { side: 'cancel', placedTurn: 0, updatedTurn: 0, flipped: false };
for (const mode of ['or', 'and'] as const) {
const declared = declare(f, controlled).game;
const asked = ask(declared, f.asker, f.target, group(mode, use('ghola'), payment(f.recipient)));
let game = answer(asked, f.target, 'yes');
game = settle(act(game, f.target, f.end));
game = advance(game, state => state.phase === 7 && clean(state), [], true);
assert.equal(game.inflation?.side, 'cancel');
if (mode === 'and') {
rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, f.payment);
}
game = act(game, f.target, { type: 'ready' });
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.closed, false);
assert.ok(!promise(game, f.target).fulfilled);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.equal(game.inflation?.side, 'double');
rejected(game, f.target, f.payment);
rejected(game, f.target, { type: 'ready' });
game = settle(act(game, f.target, f.cardAction));
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native whole Ghola OR payment No forbids printed use and cumulative payment through actual turn end', () => {
const f = fixture({ source: 'gholaForces' }), mixed = group('or', use('ghola'), payment(f.recipient));
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.cardAction); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 }); game = settle(act(game, f.target, f.end));
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, f.cardAction); rejected(game, f.target, { ...f.payment, amount: 1 });
assert.ok(!promise(game, f.target).fulfilled); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
});
void test('native held Ghola and ordinary returns share the original Tanks and purse without double reservation or event substitution', () => {
const f = fixture({ source: 'gholaRevival' }); assert.equal(player(f.game, f.target).tanks, 3);
const overbooked = group('and', use('ghola'), force(3));
const asked = ask(declare(f).game, f.asker, f.target, overbooked);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const mixed = group('and', use('ghola'), force(2), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, f.ordinaryForces));
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).tanks, 1);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, false);
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 0);
assert.ok(!promise(game, f.target).fulfilled); const ordinary = quotas(game, f.target);
game = settle(act(game, f.target, f.cardAction));
assert.equal(player(game, f.target).reserves, 20); assert.equal(player(game, f.target).tanks, 0);
assert.deepEqual(quotas(game, f.target), ordinary);
assert.equal(game.turnActionPromises![0].preparation?.revival?.total, 2);
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
// A separate original branch actually uses all three ordinary returns before
// asking: an ordinary return never becomes an earlier printed Ghola use.
const returned = settle(act(f.game, f.target, { type: 'revive', amount: 3, elite: 0 }));
assert.equal(player(returned, f.target).tanks, 0); assert.equal(player(returned, f.target).spice, 0);
const unavailable = ask(declare(f, returned).game, f.asker, f.target, use('ghola'));
assert.deepEqual(viewGame(unavailable, f.target).truthTurnActionAnswers, ['no']);
assert.ok(player(unavailable, f.target).hand.some(card => card.id === f.card));
});
void test('native personally dead Hidar returns by the actual held Ghola alongside payment without spending ordinary leader revival', () => {
const f = fixture({ source: 'gholaLeader' }); assert.ok(f.leader);
let game = bind(f).game; const ordinary = quotas(game, f.target), dead = structuredClone(player(game, f.target).leaders.find(disc => disc.id === f.leader)!);
assert.equal(dead.dead, true); assert.equal(dead.deaths, 1); game = act(game, f.target, f.payment);
game = settle(act(game, f.target, f.cardAction));
const returned = player(game, f.target).leaders.find(disc => disc.id === f.leader)!;
assert.equal(returned.dead, false); assert.equal(returned.deaths, 1); assert.equal(returned.usedAt, undefined);
// The original four spice pay two, then native Ghola's allowed Tleilaxu
// income response pays the printed one spice; it is not ordinary Revival.
assert.equal(player(game, f.target).spice, 3); assert.deepEqual(quotas(game, f.target), ordinary);
assert.equal(game.turnActionPromises![0].cardPlays?.ghola?.played, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
physical(game, f);
});
void test('native Advanced printed Ghola returns an original Storm-killed Sardaukar while retaining typed ordinary quotas', () => {
const f = fixture({ source: 'gholaRevival', advanced: true });
let game = bind(f).game; const ordinary = quotas(game, f.target), reserveElite = player(game, f.target).elites!.reserves;
assert.equal(player(game, f.target).elites!.tanks, 1);
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.cardAction));
assert.equal(player(game, f.target).elites!.tanks, 0); assert.equal(player(game, f.target).elites!.reserves, reserveElite + 1);
assert.equal(player(game, f.target).tanks, 2); assert.deepEqual(quotas(game, f.target), ordinary);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native earlier Hajr use does not satisfy a new interval but an owned paid Box can recover and replay the same physical card', () => {
const f = fixture({ source: 'hajrReplay' }); assert.ok(f.box);
let game = bind(f).game; assert.equal(player(game, f.target).hajrPlays, 1);
assert.equal(player(game, f.target).moved, 1); assert.equal(game.advanced, true); assert.ok(!game.semutaPreview);
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); assert.ok(!promise(game, f.target).fulfilled);
const actions: TruthTurnCardPlayStep[] = [];
for (let count = 0; count < 6 && !promise(game, f.target).fulfilled; count++) {
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
if (count === 0) {
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'old-box-guide' });
rejected(game, f.target, { ...guide.action, selected: f.card });
}
const originalPacket = structuredClone(guide.action);
game = settle(act(game, f.target, guide.action, actions), actions);
assert.deepEqual(guide.action, originalPacket);
}
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).hajrPlays, 2);
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.box));
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.card));
const recoveredAt = actions.findIndex(step => step.action.type === 'decision' && step.action.card === f.card);
const moveAt = actions.findIndex(step => step.action.type === 'move');
const replayAt = actions.findIndex(step => step.action.type === 'card' && step.action.card === f.card);
assert.ok(recoveredAt >= 0 && moveAt > recoveredAt && replayAt > moveAt,
'The actual second move must earn timing after paid recovery and before replay');
assert.equal(actions.filter(step => step.action.type === 'move').length, 1);
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.card));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('native earlier printed Hajr without a held recovery route leaves only No for another future play', () => {
const f = fixture(); const played = settle(act(f.game, f.target, f.cardAction));
assert.equal(player(played, f.target).hajrPlays, 1);
const asked = ask(declare(f, played).game, f.asker, f.target, use('hajr'));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
game = settle(act(game, f.target, f.end)); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); physical(game, f);
});
void test('native printed-card guides retain exact event phase card and Ghola typed packets with only raw own history projected', () => {
for (const source of ['hajr', 'gholaForces'] as const) {
const f = fixture({ source }); let game = bind(f).game; game = act(game, f.target, f.payment);
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, game.phase);
if (source === 'gholaForces') { assert.equal(guide.action.amount, 1); assert.equal(guide.action.elite, 0); }
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-card-play-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 });
rejected(game, f.target, { ...guide.action, card: f.truth });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
assert.equal(promise(game, f.target).fulfilled, true); rejected(game, f.target, wire); physical(game, f);
}
});
void test('all four minimal profiles actually complete early Basic Hajr and native Ghola force or leader guides', () => {
for (const options of [{ source: 'hajr', early: true }, { source: 'gholaForces' }, { source: 'gholaLeader' }] as const) {
const f = fixture(options), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnCardPlayStep[] = [];
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, difficulty);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 100 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide;
if (guide) {
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${f.sourceKind}/${difficulty}`); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action, actions), actions);
} else {
const next = nativeStep(game); assert.ok(next, `${f.sourceKind}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${f.sourceKind}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
assert.equal(game.turnActionPromises![0].cardPlays?.[f.effect]?.played, true); physical(game, f);
}
}
});
+196
View File
@@ -10,12 +10,14 @@ import {
TurnActionError,
closeTurnActionBidding,
closeTurnActionCombat,
closeTurnActionPayments,
closeTurnActionRevival,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionBid,
recordTurnActionCardPlay,
recordTurnActionPlan,
recordTurnActionTraitor,
recordTurnActionMovement,
@@ -48,6 +50,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 fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
@@ -80,6 +83,11 @@ function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
leader: 'other-0', closed: false, call: null } } };
}
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: {
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
} };
}
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
@@ -872,3 +880,191 @@ void test('native off-board combat locations retain exact event and location bin
for (const kind of ['ship', 'move'])
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
});
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) {
const original = { kind: 'cardPlay', effect };
assert.deepEqual(parseTurnActionLeaf(original), original);
assert.notEqual(parseTurnActionLeaf(original), original);
for (const invalid of [{ ...original, card: 'physical-card' }, { ...original, played: true },
{ ...original, minimum: 1 }, { ...original, printed: true }, { ...original, answer: true }])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
assert.throws(() => turnActionValue(promise(card(effect))), TurnActionError);
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
'Compiler folding never removes the original raw interval requirement');
}
for (const effect of ['', 'Hajr', 'karama', 'thumper', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
});
void test('whole printed-card AND and OR retain dynamic alternatives and settle only actual future printed-role plays', () => {
const hajr = { kind: 'cardPlay', effect: 'hajr', answer: true };
const ghola = { kind: 'cardPlay', effect: 'ghola', answer: true };
const and = cards(group('and', card(), card('ghola'), pay()));
const or = cards(group('or', card(), card('ghola')), true, 'turn-action:2');
assert.deepEqual(branches(and), [[hajr, ghola, signedPay()]]);
assert.deepEqual(branches(or), [[hajr], [ghola]]);
const played = recordTurnActionCardPlay([and, or], 'respondent', 1, 'hajr');
assert.equal(played[0].fulfilled, false);
assert.equal(played[1].fulfilled, true);
assert.deepEqual(branches(played[0]), [[ghola, signedPay()]]);
const gholaPlayed = recordTurnActionCardPlay(played, 'respondent', 1, 'ghola');
assert.equal(gholaPlayed[0].fulfilled, false);
assert.deepEqual(branches(gholaPlayed[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(gholaPlayed, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(gholaPlayed[1], played[1]);
assert.equal(and.cardPlays!.hajr!.played, false);
assert.equal(recordTurnActionCardPlay([or], 'respondent', 1, 'ghola')[0].fulfilled, true);
});
void test('whole card-play No applies De Morgan and rejects the actual accepted play completing its aggregate', () => {
const andNo = cards(group('and', card(), card('ghola')), false);
assert.deepEqual(branches(andNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false }],
[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
const one = recordTurnActionCardPlay([andNo], 'respondent', 1, 'hajr');
assert.deepEqual(branches(one[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
const before = structuredClone(one);
assert.throws(() => recordTurnActionCardPlay(one, 'respondent', 1, 'ghola'), TurnActionError);
assert.deepEqual(one, before);
const orNo = cards(group('or', card(), card('ghola')), false);
assert.deepEqual(branches(orNo), [[{ kind: 'cardPlay', effect: 'hajr', answer: false },
{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
for (const effect of ['hajr', 'ghola'] as const)
assert.throws(() => recordTurnActionCardPlay([orNo], 'respondent', 1, effect), TurnActionError);
assert.deepEqual(branches(cards(group('and', card(), card()))), [[{ kind: 'cardPlay', effect: 'hajr', answer: true }]]);
});
void test('earlier card receipts do not satisfy a later answer and genuine replay updates only matching live intervals', () => {
const mixed = group('and', card(), pay());
const old = cards(mixed);
const played = recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0];
const fresh = cards(mixed, true, 'turn-action:2');
const other = { ...cards(mixed, true, 'turn-action:3'), player: 'other' };
const laterTurn = { ...cards(mixed, true, 'turn-action:4'), turn: 2 };
assert.equal(turnActionValue(fresh), false);
assert.equal(fresh.cardPlays!.hajr!.played, false);
const replay = recordTurnActionCardPlay([played, fresh, other, laterTurn], 'respondent', 1, 'hajr');
assert.equal(replay[0], played, 'One boolean receipt never counts or duplicates a later replay');
assert.equal(replay[1].cardPlays!.hajr!.played, true);
assert.equal(replay[2], other);
assert.equal(replay[3], laterTurn);
assert.equal(old.cardPlays!.hajr!.played, false);
assert.equal(fresh.cardPlays!.hajr!.played, false);
});
void test('actual transport closure closes Hajr while Ghola and ordinary payment alternatives retain their own deadline', () => {
const original = cards(group('or', card(), card('ghola'), pay()));
const closed = closeTurnActionTransport([original], 'respondent', 1);
assert.deepEqual(closed[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: false } });
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }], [signedPay()]]);
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'hajr')[0], closed[0]);
assert.equal(recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola')[0].fulfilled, true);
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
const no = cards(group('and', card(), card('ghola')), false);
const noClosed = closeTurnActionTransport([no], 'respondent', 1);
assert.equal(noClosed[0].fulfilled, false, 'Whole No waits for its remaining relevant native family');
const gholaPlayed = recordTurnActionCardPlay(noClosed, 'respondent', 1, 'ghola');
assert.equal(gholaPlayed[0].fulfilled, false);
assert.equal(finishTurnActionTurn(gholaPlayed, 'respondent', 1)[0].fulfilled, true);
assert.equal(closeTurnActionTransport([cards(card(), false)], 'respondent', 1)[0].fulfilled, true);
validateTurnActionPromises(closed, seats);
});
void test('full-turn closure settles absence, releases unmet Yes and leaves terminal card receipts immutable', () => {
const no = cards(card('ghola'), false);
const yes = cards(card('ghola'), true, 'turn-action:2');
const fulfilled = recordTurnActionCardPlay([cards(card(), true, 'turn-action:3')], 'respondent', 1, 'hajr')[0];
const finished = finishTurnActionTurn([no, yes, fulfilled], 'respondent', 1);
assert.equal(finished[0].fulfilled, true);
assert.equal(finished[1].released, true);
assert.equal(finished[2], fulfilled);
assert.deepEqual(finished[0].cardPlays, { hajr: { played: false, closed: true }, ghola: { played: false, closed: true } });
for (const transform of [
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'hajr'),
(rows: TurnActionPromise[]) => recordTurnActionCardPlay(rows, 'respondent', 1, 'ghola'),
(rows: TurnActionPromise[]) => closeTurnActionTransport(rows, 'respondent', 1),
(rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1),
]) assert.ok(transform(finished).every((row, index) => row === finished[index]));
validateTurnActionPromises(finished, seats);
});
void test('strict printed-card stages preserve old rows and reject metadata, missing effects and foreign compiled clauses', () => {
const original = cards(group('and', card(), card('ghola')));
for (const cardPlays of [undefined, null, {}, { hajr: null, ghola: null },
{ hajr: { played: false, closed: false } },
{ ...original.cardPlays, card: 'physical-card' },
{ ...original.cardPlays, hajr: { played: 1, closed: false } },
{ ...original.cardPlays, ghola: { played: false, closed: 'yes' } },
{ ...original.cardPlays, hajr: { played: false, closed: false, count: 1 } }])
assert.throws(() => turnActionValue({ ...original, cardPlays } as TurnActionPromise), TurnActionError);
const old = promise(pay());
assert.equal('cardPlays' in recordTurnActionCardPlay([old], 'respondent', 1, 'hajr')[0], false);
assert.equal('cardPlays' in closeTurnActionTransport([old], 'respondent', 1)[0], false);
assert.equal('cardPlays' in finishTurnActionTurn([old], 'respondent', 1)[0], false);
const optional = { ...prepared(group('and', bid(), forces())), cardPlays: { hajr: null, ghola: null } };
validateTurnActionPromises([optional], seats);
const foreign = { ...cards(card()), compiled: { kind: 'action', action: { kind: 'cardPlay', effect: 'ghola' } } };
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
assert.throws(() => recordTurnActionCardPlay([original], 'respondent', 1, 'karama' as never), TurnActionError);
});
void test('card-play events reject all affected No violations atomically and share the outer logical budget', () => {
const yes = cards(group('and', card(), pay()));
const no = cards(card(), false, 'turn-action:2');
const before = structuredClone([yes, no]);
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, 'hajr'), TurnActionError);
assert.deepEqual([yes, no], before);
assert.doesNotThrow(() => turnActionValue(cards(group('or', ...Array.from({ length: 16 }, () => card())))));
assert.throws(() => turnActionValue(cards(group('or', ...Array.from({ length: 17 }, () => card())))), TurnActionError);
});
void test('payment expiry preserves a later Ghola OR alternative while an AND still requires its missing payment', () => {
const or = cards(group('or', card('ghola'), pay()));
const and = cards(group('and', card('ghola'), pay()), true, 'turn-action:2');
const closed = closeTurnActionPayments([or, and], 'respondent', 1);
for (const row of closed) {
assert.equal(row.paymentClosed, true);
assert.equal(row.transportClosed, false);
assert.deepEqual(row.shipment, { pending: true, event: null });
assert.deepEqual(row.cardPlays, or.cardPlays);
assert.equal(row.cardPlays!.ghola!.closed, false);
assert.equal(row.fulfilled, false);
assert.equal(row.released, false);
}
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }]]);
assert.deepEqual(branches(closed[1]), []);
assert.ok(recordTurnActionPayment(closed, 'respondent', 1, payment()).every((row, index) => row === closed[index]));
const laterGhola = recordTurnActionCardPlay(closed, 'respondent', 1, 'ghola');
assert.equal(laterGhola[0].fulfilled, true);
assert.equal(laterGhola[1].fulfilled, false);
assert.equal(finishTurnActionTurn(laterGhola, 'respondent', 1)[1].released, true);
const expiredTooEarly = finishTurnActionTurn([or], 'respondent', 1)[0];
assert.equal(expiredTooEarly.released, true);
assert.equal(expiredTooEarly.cardPlays!.ghola!.closed, true);
assert.equal(closeTurnActionPayments(laterGhola, 'respondent', 1)[0], laterGhola[0], 'Terminal rows remain identical');
assert.equal(closeTurnActionPayments(closed, 'respondent', 1)[1], closed[1], 'Repeated payment closure retains the same row');
assert.equal(or.paymentClosed, false);
validateTurnActionPromises(closed, seats);
});
void test('payment-only closure keeps whole No binding through later Ghola and validates its untouched native stage', () => {
const orNo = cards(group('or', card('ghola'), pay()), false);
const andNo = cards(group('and', card('ghola'), pay()), false, 'turn-action:2');
const closed = closeTurnActionPayments([orNo, andNo], 'respondent', 1);
assert.equal(closed[0].fulfilled, false);
assert.equal(closed[1].fulfilled, false);
assert.deepEqual(branches(closed[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: false }]]);
assert.throws(() => recordTurnActionCardPlay([closed[0]], 'respondent', 1, 'ghola'), TurnActionError);
const played = recordTurnActionCardPlay([closed[1]], 'respondent', 1, 'ghola');
assert.equal(played[0].fulfilled, false);
assert.equal(finishTurnActionTurn(played, 'respondent', 1)[0].fulfilled, true);
const paymentOnlyNo = closeTurnActionPayments([promise(pay(), false)], 'respondent', 1)[0];
assert.equal(paymentOnlyNo.fulfilled, true);
const other = { ...orNo, player: 'other' }, nextTurn = { ...orNo, turn: 2 };
assert.equal(closeTurnActionPayments([other], 'respondent', 1)[0], other);
assert.equal(closeTurnActionPayments([nextTurn], 'respondent', 1)[0], nextTurn);
const malformed = { ...orNo, cardPlays: { hajr: null, ghola: { played: false, closed: 'yes' } } };
assert.throws(() => closeTurnActionPayments([malformed as unknown as TurnActionPromise], 'respondent', 1), TurnActionError);
validateTurnActionPromises(closed, seats);
});