Expose full Truthtrance battle-plan and shipment editors

This commit is contained in:
Quintine committed 2026-10-26 09:02:16 +11:00
1 parent 8e2f2c6a95
commit 4ee51917bc
21 files changed
+1958 -92

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { PlanClaim } from '../game/battle-promises';
import type { TruthAnswer, TruthQuestion } from '../game/truthtrance';
import {
createTruthTraitorDecisionFixture,
actTruthTraitorDecision,
truthTraitorDecisionClean,
truthTraitorDecisionPlayer,
truthTraitorDecisionInventory,
type TruthTraitorDecisionStep,
} from './fixture-truth-traitor-decision';
export type TruthPlanExpressionStep = TruthTraitorDecisionStep;
export type TruthPlanExpressionScenario = {
id: string;
claim: PlanClaim;
answer: 'yes' | 'no';
plan: Action;
contradictingPlan: Action;
};
export const actTruthPlanExpression = actTruthTraitorDecision;
export const truthPlanExpressionClean = truthTraitorDecisionClean;
export const truthPlanExpressionPlayer = truthTraitorDecisionPlayer;
export const truthPlanExpressionInventory = truthTraitorDecisionInventory;
/** Stop at the genuine source's earned beforePlans boundary. The prefix omits
* its last two actual seal actions, rather than installing a new battle. */
export function createTruthPlanExpressionFixture() {
const base = createTruthTraitorDecisionFixture();
assert.deepEqual(base.actions.slice(-base.planSteps.length), base.planSteps);
const actions = structuredClone(base.actions.slice(0, -base.planSteps.length));
const game = structuredClone(base.beforePlans);
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.equal(game.phase, 6); assert.equal(game.turn, 1); assert.deepEqual(game.expansions, []);
assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length);
assert.ok(truthPlanExpressionClean(game));
const projectile = truthPlanExpressionPlayer(game, base.target).hand.find(card => card.id === base.ordinary);
assert.ok(projectile && projectile.kind === 'projectile');
assert.ok(truthPlanExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
assert.deepEqual(truthPlanExpressionInventory(game), base.originalCards);
const claim: PlanClaim = { kind: 'and', terms: [
{ kind: 'dial', compare: 'eq', value: 0 },
{ kind: 'weapon', name: null },
{ kind: 'defense', name: null },
] };
const question: Extract<TruthQuestion, { kind: 'battlePlan' }> = {
kind: 'battlePlan', target: base.target, territory: base.territory, claim,
};
const declarationStep: TruthPlanExpressionStep = { actor: base.asker,
action: { type: 'card', card: base.card } };
const askStep: TruthPlanExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
const answerStep: TruthPlanExpressionStep = { actor: base.target, action: { type: 'truthAnswer', answer: 'yes' } };
const targetPlan = structuredClone(base.targetPlan);
const ownPlan = structuredClone(base.ownPlan);
const projectilePlan: Action = { ...targetPlan, weapon: projectile.id };
return {
game, source: structuredClone(game), beforePlans: structuredClone(game), base,
initial: base.initial, lobby: base.lobby, setup: base.setup,
beforeStartingDeal: base.beforeStartingDeal, afterSetup: base.afterSetup,
beforeShipment: base.beforeShipment, shipment: base.shipment, beforeBattle: base.beforeBattle,
beforePreparation: base.beforePreparation,
asker: base.asker, target: base.target, card: base.card, truth: base.card,
ordinary: base.ordinary, projectile, territory: base.territory, sector: base.sector, event: base.event,
targetPlan, ownPlan, projectilePlan,
targetPlanStep: { actor: base.target, action: targetPlan },
ownPlanStep: { actor: base.asker, action: ownPlan },
projectilePlanStep: { actor: base.target, action: projectilePlan },
claim, question, declarationStep, askStep, answerStep,
deal: base.deal, dealOrder: base.dealOrder, spiceOrder: base.spiceOrder,
originalCards: base.originalCards, originalInventory: base.originalCards,
originalTraitors: base.originalTraitors, originalSpice: base.originalSpice,
actions, steps: actions, sourceActionCount: actions.length,
sourceCounts: { beforePlans: actions.length, inheritedReveal: base.sourceActionCount },
staging: [
...base.staging.slice(0, 2),
'Use the inherited actual Storm, Spice Blow, Charity, Bidding, Revival, paid Emperor shipment and battle preparation actions. Stop at its earned beforePlans snapshot; the exposed action prefix omits precisely its two later seal actions. Neither private plan is installed.',
'Atreides still holds its original Truthtrance and Emperor its original projectile. Declaration, priority, whole question, definite answer, one discard and the original zero-dial unarmed or actual projectile plan remain caller-owned actions.',
],
};
}
export type TruthPlanExpressionFixture = ReturnType<typeof createTruthPlanExpressionFixture>;
/** Both original plans are feasible; their weapon slot changes the aggregate. */
export function truthPlanExpressionClaims(fixture: TruthPlanExpressionFixture): {
closedTrue: PlanClaim[]; projectileFalse: PlanClaim[];
} {
return {
closedTrue: [
{ kind: 'dial', compare: 'eq', value: 0 },
{ kind: 'weapon', name: null },
{ kind: 'defense', name: null },
{ kind: 'support', compare: 'eq', value: 0 },
{ kind: 'leader', leader: fixture.base.leaders.emperor },
{ kind: 'kwisatz', use: false },
{ kind: 'dial', compare: 'lte', value: 1 },
{ kind: 'support', compare: 'lte', value: 0 },
],
projectileFalse: [
{ kind: 'dial', compare: 'gte', value: 1 },
{ kind: 'weapon', name: null },
{ kind: 'leader', leader: null },
{ kind: 'kwisatz', use: true },
{ kind: 'support', compare: 'gte', value: 1 },
{ kind: 'dial', compare: 'eq', value: 1 },
{ kind: 'weapon', name: null },
{ kind: 'leader', leader: null },
],
};
}
export function truthPlanExpressionScenario(fixture: TruthPlanExpressionFixture,
kind: 'and' | 'or' = 'and', count = 3, answer: 'yes' | 'no' = 'yes',
): TruthPlanExpressionScenario {
assert.ok(Number.isInteger(count) && count >= 3 && count <= 8);
const { closedTrue, projectileFalse } = truthPlanExpressionClaims(fixture);
return {
id: `${count}-${kind}-${answer}`,
claim: { kind, terms: (kind === 'and' ? closedTrue : projectileFalse).slice(0, count) }, answer,
plan: structuredClone(answer === 'yes' ? fixture.targetPlan : fixture.projectilePlan),
contradictingPlan: structuredClone(answer === 'yes' ? fixture.projectilePlan : fixture.targetPlan),
};
}
export function truthPlanExpressionNestedScenario(fixture: TruthPlanExpressionFixture,
kind: 'and' | 'or' = 'and', answer: 'yes' | 'no' = 'yes',
): TruthPlanExpressionScenario {
const { closedTrue, projectileFalse } = truthPlanExpressionClaims(fixture);
const claim: PlanClaim = kind === 'and'
? { kind: 'and', terms: [closedTrue[0], { kind: 'or', terms: [
projectileFalse[0], projectileFalse[2], closedTrue[1],
] }, closedTrue[2]] }
: { kind: 'or', terms: [projectileFalse[0], { kind: 'and', terms: closedTrue.slice(0, 3) }, projectileFalse[2]] };
return { ...truthPlanExpressionScenario(fixture, kind, 3, answer), id: `nested-${kind}-${answer}`, claim };
}
export function passTruthPlanExpressionPriority(game: Game, actions: TruthPlanExpressionStep[] = []): Game {
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthPlanExpression(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function declareTruthPlanExpression(fixture: TruthPlanExpressionFixture) {
const before = structuredClone(fixture.game), actions: TruthPlanExpressionStep[] = [];
const declaration = structuredClone(fixture.declarationStep);
const game = actTruthPlanExpression(before, declaration.actor, declaration.action, actions);
assert.equal(game.truthtrance?.stage, 'priority');
return { game, before, declaration, opened: structuredClone(game), actions };
}
/** Raw native Ask source for SSR and actual edit/callback runtime proof. */
export function createTruthPlanExpressionAskSource(fixture: TruthPlanExpressionFixture) {
const declared = declareTruthPlanExpression(fixture);
const game = passTruthPlanExpressionPriority(declared.game, declared.actions);
return { ...declared, game, beforeQuestion: structuredClone(game) };
}
export function askTruthPlanExpression(fixture: TruthPlanExpressionFixture, claim: PlanClaim = fixture.claim) {
const source = createTruthPlanExpressionAskSource(fixture), actions = source.actions;
const question: TruthPlanExpressionStep = { actor: fixture.asker, action: { type: 'truthAsk', question: {
kind: 'battlePlan', target: fixture.target, territory: fixture.territory, claim: structuredClone(claim),
} } };
const game = actTruthPlanExpression(source.game, question.actor, question.action, actions);
assert.equal(game.truthtrance?.stage, 'answer');
return { ...source, game, question, actions };
}
export function answerTruthPlanExpression(game: Game, answer: TruthAnswer,
actions: TruthPlanExpressionStep[] = []): Game {
const target = game.truthtrance?.question?.target; assert.ok(target);
assert.ok(viewGame(game, target).truthBattleAnswers?.includes(answer));
return actTruthPlanExpression(game, target, { type: 'truthAnswer', answer }, actions);
}
export function sealTruthPlanExpression(game: Game, fixture: TruthPlanExpressionFixture,
plan: Action = fixture.targetPlan, actions: TruthPlanExpressionStep[] = []) {
game = actTruthPlanExpression(game, fixture.target, plan, actions);
const afterTargetPlan = structuredClone(game);
game = actTruthPlanExpression(game, fixture.asker, fixture.ownPlan, actions);
assert.ok(game.battle?.revealed);
return { game, afterTargetPlan, actions };
}
export function finishTruthPlanExpression(fixture: TruthPlanExpressionFixture,
scenario: TruthPlanExpressionScenario = truthPlanExpressionScenario(fixture)) {
const asked = askTruthPlanExpression(fixture, scenario.claim), actions = asked.actions;
const answered = answerTruthPlanExpression(asked.game, scenario.answer, actions);
assert.ok(truthPlanExpressionClean(answered));
const sealed = sealTruthPlanExpression(answered, fixture, scenario.plan, actions);
return { ...asked, asked: asked.game, answered, ...sealed, scenario };
}
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import type { ShipmentExpression } from '../game/shipment-promises';
import type { TruthQuestion } from '../game/truthtrance';
import {
createTruthTraitorDecisionFixture,
actTruthTraitorDecision,
advanceTruthTraitorDecision,
truthTraitorDecisionClean,
truthTraitorDecisionInventory,
truthTraitorDecisionPlayer,
type TruthTraitorDecisionStep,
} from './fixture-truth-traitor-decision';
export type TruthShipmentExpressionStep = TruthTraitorDecisionStep;
export type TruthShipmentExpressionScenario = {
id: string;
expression: ShipmentExpression;
answer: 'yes' | 'no';
};
export const actTruthShipmentExpression = actTruthTraitorDecision;
export const truthShipmentExpressionClean = truthTraitorDecisionClean;
export const truthShipmentExpressionInventory = truthTraitorDecisionInventory;
export const truthShipmentExpressionPlayer = truthTraitorDecisionPlayer;
const leaf = (territory = 'arrakeen', minimum = 1): ShipmentExpression => ({ territory, minimum });
/** The original one-force Arrakeen shipment determines every selected answer.
* AND No retains true earlier clauses; OR Yes needs the final destination. */
export function truthShipmentExpressionScenario(
op: 'and' | 'or' = 'and', count = 3, answer: 'yes' | 'no' = 'yes',
): TruthShipmentExpressionScenario {
assert.ok(Number.isInteger(count) && count >= 3 && count <= 16);
const alternatives = TERRITORIES.filter(place => place.id !== 'arrakeen');
const terms = op === 'and'
? Array.from({ length: count }, (_, index) => leaf('arrakeen', answer === 'no' && index === count - 1 ? 2 : 1))
: [...alternatives.slice(0, count - 1).map(place => leaf(place.id)), leaf('arrakeen', answer === 'yes' ? 1 : 2)];
return { id: `${count}-${op}-${answer}`, expression: { op, terms }, answer };
}
export function truthShipmentExpressionNestedScenario(
op: 'and' | 'or' = 'and', answer: 'yes' | 'no' = 'yes',
): TruthShipmentExpressionScenario {
const decisive = leaf('arrakeen', answer === 'yes' ? 1 : 2);
const expression: ShipmentExpression = op === 'and'
? { op, terms: [leaf(), { op: 'or', terms: [leaf('carthag'), leaf('sietch_tabr'), decisive] }, leaf()] }
: { op, terms: [leaf('carthag'), { op: 'and', terms: [leaf(), leaf(), decisive] }, leaf('sietch_tabr')] };
return { id: `nested-${op}-${answer}`, expression, answer };
}
export function truthShipmentExpressionDepthScenario(depth = 4): TruthShipmentExpressionScenario {
let expression = leaf();
for (let level = 0; level < depth; level++) expression = level % 2 === 0
? { op: 'and', terms: [leaf(), expression] }
: { op: 'or', terms: [leaf('carthag'), expression] };
return { id: `depth-${depth}`, expression, answer: 'yes' };
}
/** Reuse the genuine two-seat Basic source only through its beforeShipment
* boundary. The prefix excludes that original shipment and every later battle
* action. No postdeal hand, wallet, army, clock or promise is installed. */
export function createTruthShipmentExpressionFixture() {
const base = createTruthTraitorDecisionFixture();
const shipmentIndex = base.actions.findIndex(step => step.actor === base.shipmentStep.actor &&
JSON.stringify(step.action) === JSON.stringify(base.shipmentStep.action));
assert.ok(shipmentIndex >= 0);
const actions = structuredClone(base.actions.slice(0, shipmentIndex));
const game = structuredClone(base.beforeShipment);
assert.equal(game.phase, 5); assert.equal(game.active, base.target);
assert.ok(truthShipmentExpressionClean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.equal(truthShipmentExpressionPlayer(game, base.target).shipped, false);
assert.equal(truthShipmentExpressionPlayer(game, base.target).reserves, 20);
assert.ok(truthShipmentExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
assert.deepEqual(truthShipmentExpressionInventory(game), base.originalCards);
const scenario = truthShipmentExpressionScenario();
const question: Extract<TruthQuestion, { kind: 'shipment' }> = {
kind: 'shipment', target: base.target, claim: scenario.expression,
};
const declarationStep: TruthShipmentExpressionStep = { actor: base.asker, action: { type: 'card', card: base.card } };
const askStep: TruthShipmentExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
const answerStep: TruthShipmentExpressionStep = { actor: base.target, action: { type: 'truthAnswer', answer: scenario.answer } };
const continuationStep: TruthShipmentExpressionStep = { actor: base.target, action: { type: 'endMovement' } };
return {
game, source: structuredClone(game), base, beforeShipment: structuredClone(game),
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
afterSetup: base.afterSetup, initial: base.initial, deal: base.deal, dealOrder: base.dealOrder,
spiceOrder: base.spiceOrder, originalSpice: base.originalSpice, originalCards: base.originalCards,
originalInventory: base.originalCards, asker: base.asker, target: base.target,
card: base.card, truth: base.card, ordinary: base.ordinary, location: base.location,
scenario, expression: scenario.expression, question, declarationStep, askStep, answerStep,
shipmentStep: structuredClone(base.shipmentStep), continuationStep,
actions, steps: actions, sourceActionCount: actions.length,
sourceCounts: { shipment: shipmentIndex, completeBase: base.sourceActionCount },
staging: [...base.staging.slice(0, 2),
'Only the original action prefix through Emperor’s unused active reserve-shipment turn is retained. Atreides has genuinely ended Movement. Truthtrance declaration, priority, the full pure shipment question, answer, original paid one-force Arrakeen delivery and Emperor endMovement remain caller actions. No later source shipment or battle action is counted as prefix evidence.'],
};
}
export type TruthShipmentExpressionFixture = ReturnType<typeof createTruthShipmentExpressionFixture>;
export function declareTruthShipmentExpression(fixture: TruthShipmentExpressionFixture) {
const before = structuredClone(fixture.game), actions: TruthShipmentExpressionStep[] = [];
const declaration = structuredClone(fixture.declarationStep);
const game = actTruthShipmentExpression(before, declaration.actor, declaration.action, actions);
assert.equal(game.truthtrance?.stage, 'priority');
return { game, before, declaration, opened: structuredClone(game), actions };
}
export function passTruthShipmentExpressionPriority(game: Game, actions: TruthShipmentExpressionStep[] = []): Game {
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthShipmentExpression(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function askTruthShipmentExpression(fixture: TruthShipmentExpressionFixture, expression = fixture.expression) {
const declared = declareTruthShipmentExpression(fixture), actions = declared.actions;
let game = passTruthShipmentExpressionPriority(declared.game, actions);
const beforeQuestion = structuredClone(game);
const question: TruthShipmentExpressionStep = { actor: fixture.asker, action: { type: 'truthAsk', question: {
kind: 'shipment', target: fixture.target, claim: structuredClone(expression),
} } };
game = actTruthShipmentExpression(game, question.actor, question.action, actions);
assert.equal(game.truthtrance?.stage, 'answer');
return { ...declared, game, beforeQuestion, question, actions };
}
export function answerTruthShipmentExpression(game: Game, answer: 'yes' | 'no', actions: TruthShipmentExpressionStep[] = []) {
const target = game.truthtrance?.question?.target; assert.ok(target);
assert.ok(viewGame(game, target).truthShipmentAnswers!.includes(answer));
return actTruthShipmentExpression(game, target, { type: 'truthAnswer', answer }, actions);
}
/** Settle original shipment responses only; actual delivery remains explicit. */
export function settleTruthShipmentExpression(game: Game, actions: TruthShipmentExpressionStep[] = []) {
return advanceTruthTraitorDecision(game, state => truthShipmentExpressionClean(state) && !state.pendingShipment, actions);
}
export function finishTruthShipmentExpression(
fixture: TruthShipmentExpressionFixture, scenario: TruthShipmentExpressionScenario = fixture.scenario,
) {
const asked = askTruthShipmentExpression(fixture, scenario.expression), actions = asked.actions;
let game = answerTruthShipmentExpression(asked.game, scenario.answer, actions);
const answerStep = actions.at(-1)!, bound = structuredClone(game);
const shipmentStep = structuredClone(fixture.shipmentStep);
game = actTruthShipmentExpression(game, shipmentStep.actor, shipmentStep.action, actions);
const declaredShipment = structuredClone(game);
game = settleTruthShipmentExpression(game, actions);
return { ...asked, asked: asked.game, game, answerStep, bound, shipmentStep, declaredShipment, actions };
}
+286
View File
@@ -0,0 +1,286 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BattleClaimFields } from '../components/battle-promises';
import { BattleClaimExpressionFields } from '../components/battle-claim-expression-fields';
import { TruthBattlePlanQuestion } from '../components/truth-battle-plan-question';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { battleClaimExpressionError, battleClaimExpressionSize } from '../game/battle-claim-expression';
import type { PlanClaim } from '../game/battle-promises';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { TruthQuestion } from '../game/truthtrance';
// Controlled projections supplement the separate earned native battle source.
// Hook-free components are invoked directly; React renders BattleClaimFields
// and its hook. Leaf edits cross the actual controlled onChange boundary.
function projected(): GameView {
const game = createGame('TRUTHBATTLECONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
joinGame(game, newPlayer('t', 'Target', 'emperor'));
joinGame(game, newPlayer('o', 'Observer', 'harkonnen'));
const view = viewGame(game, 'a');
view.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth' }], passed: [], question: null };
view.battle = { attacker: 'a', defender: 't', territory: 'arrakeen' } as NonNullable<GameView['battle']>;
return view;
}
const dial = (value = 0): PlanClaim => ({ kind: 'dial', compare: 'eq', value });
const weapon = (name: string | null = null): PlanClaim => ({ kind: 'weapon', name });
type Question = Extract<TruthQuestion, { kind: 'battlePlan' }>;
const questionFor = (claim: PlanClaim, target = 't', territory = 'arrakeen'): Question => ({ kind: 'battlePlan', target, territory, claim });
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function control(tree: ReactNode, key: string): Element {
const found = elements(tree).find(element => element.props['data-battle-claim-control'] === key);
assert.ok(found, `Control ${key} is rendered`); return found;
}
function click(button: Element) { (button.props.onClick as () => void)(); }
function fieldset(tree: ReactNode, path: string): Element {
const found = elements(tree).find(element => element.props['data-battle-claim-path'] === path);
assert.ok(found, `Condition or group ${path} is rendered`); return found;
}
function editLeaf(tree: ReactNode, path: string, next: PlanClaim) {
const field = elements(fieldset(tree, path)).find(element => element.type === BattleClaimFields);
assert.ok(field, `Plan leaf ${path} is rendered`);
(field.props.onChange as (claim: PlanClaim) => void)(next);
}
function switchGroup(tree: ReactNode, path: string, kind: string) {
const select = elements(fieldset(tree, path)).find(element => element.type === 'select');
assert.ok(select);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: kind } });
}
function editor(initial: PlanClaim, disabled = false) {
let value = initial, changes = 0;
const game = projected();
return {
get value() { return value; }, get changes() { return changes; },
tree: () => BattleClaimExpressionFields({ value, game, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(BattleClaimExpressionFields, { value, game, disabled, onChange() {} })),
};
}
function askTree(game: GameView, question: Question, busy = false) {
const actions: Action[] = [], changes: PlanClaim[] = [];
const props = { game, question, busy, act(action: Action) { actions.push(action); }, onChange(next: PlanClaim) { changes.push(next); } };
const tree = TruthBattlePlanQuestion(props);
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-battle-plan-ask');
assert.ok(button, 'The actual Ask action is rendered');
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthBattlePlanQuestion, props)) };
}
function groupsDeep(depth: number): PlanClaim {
let claim = weapon();
for (let level = 1; level <= depth; level++) claim = { kind: 'and', terms: [claim, dial(level)] };
return claim;
}
void test('actual group and leaf callbacks author three through eight plan conditions without losing earlier leaves', () => {
const e = editor(weapon('Shield'));
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', { kind: 'defense', name: null });
for (let count = 3; count <= 8; count++) {
const before = structuredClone(e.value);
click(control(e.tree(), 'add-1'));
editLeaf(e.tree(), `1.${count}`, dial(count));
assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, count);
assert.deepEqual(e.value.terms.slice(0, count - 1), (before as Extract<PlanClaim, { kind: 'and' | 'or' }>).terms);
assert.deepEqual(e.value.terms[count - 1], dial(count));
}
const before = structuredClone(e.value), changes = e.changes;
assert.equal((e.html().match(/Plan element/g) ?? []).length, 8);
assert.match(e.html(), /9 \/ 16 total clauses/);
assert.equal(control(e.tree(), 'add-1').props.disabled, true);
click(control(e.tree(), 'add-1'));
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
});
void test('nested AND and OR callbacks edit the whole tree, switch meaning and add groups', () => {
const e = editor(dial(2));
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', weapon());
click(control(e.tree(), 'or-1.2'));
editLeaf(e.tree(), '1.2.2', { kind: 'kwisatz', use: false });
click(control(e.tree(), 'add-1.2'));
editLeaf(e.tree(), '1.2.3', { kind: 'support', compare: 'gte', value: 3 });
const before = structuredClone(e.value);
switchGroup(e.tree(), '1.2', 'and');
assert.deepEqual(e.value, { kind: 'and', terms: [dial(2), { kind: 'and', terms: [
weapon(), { kind: 'kwisatz', use: false }, { kind: 'support', compare: 'gte', value: 3 },
] }] });
switchGroup(e.tree(), '1.2', 'or'); assert.deepEqual(e.value, before);
const changes = e.changes;
switchGroup(e.tree(), '1.2', 'single'); assert.equal(e.changes, changes);
click(control(e.tree(), 'nested-1.2'));
assert.ok(e.value.kind === 'and');
const nested = e.value.terms[1]; assert.ok(nested.kind === 'or');
assert.equal(nested.terms.length, 4);
assert.deepEqual(nested.terms[3], { kind: 'and', terms: [dial(), dial()] });
assert.match(e.html(), /Group 1\.2 · OR/); assert.match(e.html(), /Group 1\.2\.4 · AND/);
});
void test('removal collapses singleton groups and retains every branch of a surviving group', () => {
const retained: PlanClaim = { kind: 'leader', leader: CHEAP_HERO_TRAITOR };
const e = editor({ kind: 'or', terms: [weapon(), { kind: 'and', terms: [dial(9), retained] }] });
click(control(e.tree(), 'remove-1.2.1'));
assert.deepEqual(e.value, { kind: 'or', terms: [weapon(), retained] });
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, retained);
const survivor: PlanClaim = { kind: 'or', terms: [dial(1), dial(2), dial(3)] };
const whole = editor({ kind: 'and', terms: [{ kind: 'and', terms: [weapon(), dial()] }, survivor] });
click(control(whole.tree(), 'remove-1.1')); assert.deepEqual(whole.value, survivor);
assert.equal(battleClaimExpressionError(whole.value), null);
});
void test('sixteen nodes remain fully editable while add and wrap callbacks enforce the whole-tree budget', () => {
const full: PlanClaim = { kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
{ kind: 'and', terms: Array.from({ length: 5 }, (_, index) => dial(index + 8)) },
] };
const e = editor(full);
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 16, depth: 2 });
for (const key of ['add-1', 'nested-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, 0); assert.deepEqual(e.value, full);
assert.equal((e.html().match(/Plan element/g) ?? []).length, 13);
editLeaf(e.tree(), '1.2.5', weapon('Snooper'));
assert.equal(e.changes, 1); assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 16, depth: 2 });
});
void test('four grouping levels permit deepest leaf repair and shallow nesting but reject a fifth level', () => {
const e = editor(groupsDeep(4));
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 9, depth: 4 });
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-1.1.1.1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
editLeaf(e.tree(), '1.1.1.1.1', weapon('Snooper'));
click(control(e.tree(), 'add-1.1.1.1'));
click(control(e.tree(), 'or-1.2'));
assert.deepEqual(battleClaimExpressionSize(e.value), { nodes: 12, depth: 4 });
});
void test('busy guards direct add, wrap, remove, switch and leaf callbacks as well as native fieldset controls', () => {
const initial: PlanClaim = { kind: 'and', terms: [weapon(), dial(3)] }, e = editor(initial, true);
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="truth-battle-plan-expression"/);
for (const key of ['add-1', 'nested-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
switchGroup(e.tree(), '1', 'or'); editLeaf(e.tree(), '1.2', dial(-1));
assert.equal(e.changes, 0); assert.deepEqual(e.value, initial);
});
void test('Ask sends exactly the complete three-term, eight-term and nested question with public leader identities', () => {
const game = projected();
const claims: PlanClaim[] = [
{ kind: 'and', terms: [dial(), weapon(), { kind: 'defense', name: null }] },
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
{ kind: 'and', terms: [dial(), { kind: 'or', terms: [weapon(),
{ kind: 'leader', leader: game.allLeaders[0].id }, { kind: 'leader', leader: CHEAP_HERO_TRAITOR }] }] },
];
for (const claim of claims) {
const question = questionFor(claim), before = structuredClone(question), ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false); click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
assert.deepEqual(Object.keys(ask.actions[0]).sort(), ['question', 'type']);
assert.deepEqual(question, before); assert.match(ask.html(), /data-testid="truth-battle-plan-preview"/);
}
});
void test('every invalid third or nested leaf blocks Ask despite valid earlier AND or OR clauses', () => {
const invalid: PlanClaim[] = [dial(-1), dial(0.25), dial(NaN), dial(41),
{ kind: 'support', compare: 'eq', value: 0.5 },
{ kind: 'dial', compare: 'not-a-comparison', value: 1 } as unknown as PlanClaim,
weapon('Not an original card'), { kind: 'leader', leader: 'not-a-public-leader' },
{ kind: 'kwisatz', use: 'yes' } as unknown as PlanClaim,
];
for (const kind of ['and', 'or'] as const) for (const bad of invalid) {
for (const leaf of [bad, { kind: 'and', terms: [dial(), bad] } as PlanClaim]) {
const ask = askTree(projected(), questionFor({ kind, terms: [dial(), weapon(), leaf] }));
assert.equal(ask.button.props.disabled, true, `${kind}: ${bad.kind}`); click(ask.button);
assert.deepEqual(ask.actions, []); assert.match(ask.html(), /Resolve the battle-plan fields before asking/);
}
}
const e = editor({ kind: 'and', terms: [dial(), weapon(), dial()] });
editLeaf(e.tree(), '1.3', dial(-1));
assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[2], dial(-1));
editLeaf(e.tree(), '1.3', dial(0.5));
assert.deepEqual(e.value.terms[2], dial(0.5), 'Invalid typing remains repairable');
});
void test('over-width, over-node, over-depth and singleton questions cannot Ask and keep all leaves visible', () => {
const invalid: PlanClaim[] = [
{ kind: 'and', terms: [dial()] },
{ kind: 'or', terms: Array.from({ length: 9 }, (_, index) => dial(index)) },
{ kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => dial(index)) },
{ kind: 'and', terms: Array.from({ length: 6 }, (_, index) => dial(index)) },
] }, groupsDeep(5),
];
const leaves = (claim: PlanClaim): number => claim.kind === 'and' || claim.kind === 'or'
? claim.terms.reduce((count, term) => count + leaves(term), 0) : 1;
for (const claim of invalid) {
const ask = askTree(projected(), questionFor(claim));
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
assert.match(ask.html(), /role="alert"/); assert.equal((ask.html().match(/Plan element/g) ?? []).length, leaves(claim));
}
});
void test('live queue ownership, target seating, current battle and territory guard Ask and editor callbacks', () => {
const question = questionFor(dial()), cases: { game: GameView; question?: Question; busy?: boolean }[] = [{ game: projected(), busy: true }];
for (const stage of ['priority', 'answer', 'unknown'] as const) {
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
}
for (const owner of ['t', 'not-seated']) {
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
}
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
const noBattle = projected(); noBattle.battle = null; cases.push({ game: noBattle });
for (const target of ['a', 'o', 'not-seated']) cases.push({ game: projected(), question: questionFor(dial(), target) });
cases.push({ game: projected(), question: questionFor(dial(), 't', 'carthag') });
for (const entry of cases) {
const ask = askTree(entry.game, entry.question ?? question, entry.busy);
assert.equal(ask.button.props.disabled, true); click(ask.button);
const fields = elements(ask.tree).find(element => element.type === BattleClaimExpressionFields);
if (fields) {
assert.equal(fields.props.disabled, true); (fields.props.onChange as (claim: PlanClaim) => void)(dial(9));
}
assert.deepEqual(ask.actions, []); assert.deepEqual(ask.changes, []); assert.doesNotThrow(ask.html);
}
for (const change of [
(game: GameView) => { game.truthtrance!.stage = 'answer'; },
(game: GameView) => { game.truthtrance!.queue[0].player = 't'; },
(game: GameView) => { game.battle!.territory = 'carthag'; },
(game: GameView) => { game.players = game.players.filter(player => player.id !== question.target); },
]) {
const game = projected(), stale = askTree(game, question); change(game); click(stale.button);
assert.deepEqual(stale.actions, [], 'An existing callback rechecks current public ownership and battle');
}
});
void test('authoring does not infer private cards, plans, answer choices or later reveal policy', () => {
const game = projected();
game.battle!.revealed = true;
for (const player of game.players) for (const field of ['hand', 'traitors', 'leaders', 'spice', 'forces'])
Object.defineProperty(player, field, { get() { throw Error(`Authoring read private ${field}`); } });
for (const field of ['plans', 'myPlan', 'compliantPlan'])
Object.defineProperty(game.battle!, field, { get() { throw Error(`Authoring read private ${field}`); } });
for (const field of ['truthBattleAnswers', 'truthAnswer', 'truthKnowledge'])
Object.defineProperty(game, field, { get() { throw Error(`Authoring inferred ${field}`); } });
const claim: PlanClaim = { kind: 'and', terms: [
{ kind: 'leader', leader: game.allLeaders[0].id }, weapon('Shield'),
{ kind: 'or', terms: [{ kind: 'leader', leader: CHEAP_HERO_TRAITOR }, dial(0.5)] },
] };
const question = questionFor(claim), ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false); assert.doesNotThrow(ask.html); click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
const html = ask.html();
assert.match(html, /AND requires all conditions; OR requires at least one/);
assert.match(html, /No makes the whole expression false, not necessarily every condition/);
assert.match(html, /App limits: 8 terms per group, 16 total clauses/);
assert.match(html, /not additional printed rules/); assert.ok(html.includes(game.allLeaders[0].name));
assert.match(html, /Cheap Hero \/ Heroine/); assert.doesNotMatch(html, /https?:\/\//);
});
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { matchesPlanClaim, type PlanClaim } from '../game/battle-promises';
import { battleClaimExpressionSize } from '../game/battle-claim-expression';
import {
createTruthPlanExpressionFixture as fixture,
actTruthPlanExpression as act,
truthPlanExpressionClean as clean,
truthPlanExpressionPlayer as player,
truthPlanExpressionInventory as inventory,
truthPlanExpressionClaims as claims,
truthPlanExpressionScenario as scenario,
truthPlanExpressionNestedScenario as nestedScenario,
declareTruthPlanExpression as declare,
passTruthPlanExpressionPriority as passPriority,
createTruthPlanExpressionAskSource as askSource,
askTruthPlanExpression as ask,
answerTruthPlanExpression as answer,
sealTruthPlanExpression as seal,
finishTruthPlanExpression as finish,
type TruthPlanExpressionFixture,
} from './fixture-truth-plan-expression';
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
const before = structuredClone(game), run = () => applyAction(game, actor, action);
if (message) assert.throws(run, message); else assert.throws(run);
assert.deepEqual(game, before);
}
function battleControls(game: Game) {
const battle = game.battle; assert.ok(battle);
return {
status: game.status, turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
event: battle.event, territory: battle.territory, attacker: battle.attacker, defender: battle.defender,
revealed: battle.revealed, plans: battle.plans,
preparation: battle.preparation ?? null, preLeader: battle.preLeader ?? null,
traitorCalls: battle.traitorCalls,
};
}
function answered(game: Game, f: TruthPlanExpressionFixture, claim: PlanClaim, expected: 'yes' | 'no') {
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
assert.deepEqual(battleControls(game), battleControls(f.source));
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
assert.equal(game.discard.length, f.source.discard.length + 1);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalCards);
assert.deepEqual(game.battle!.truthPromises, [{ player: f.target, asker: f.asker, claim, answer: expected === 'yes' }]);
assert.deepEqual(game.truthHistory!.at(-1), {
turn: 1, phase: 6, asker: f.asker,
question: { kind: 'battlePlan', target: f.target, territory: f.territory, claim }, answer: expected,
});
}
function actualPlan(game: Game, f: TruthPlanExpressionFixture, claim: PlanClaim, expected: 'yes' | 'no') {
assert.ok(game.battle?.revealed);
assert.equal(game.battle.event, f.event); assert.equal(game.battle.territory, f.territory);
const plan = game.battle.plans[f.target];
assert.equal(matchesPlanClaim(claim, { ...plan, kwisatz: !!plan.kwisatz }, player(game, f.target).hand), expected === 'yes');
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
void test('native battle expression source stops at its honest action prefix before both original plans', () => {
const f = fixture();
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor']);
assert.equal(f.sourceActionCount, f.base.sourceActionCount - f.base.planSteps.length);
assert.equal(f.actions.length, f.sourceActionCount);
assert.deepEqual(f.actions, f.base.actions.slice(0, -2));
assert.deepEqual(f.game, f.base.beforePlans); assert.ok(clean(f.game));
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1);
assert.equal(f.game.battle!.revealed, false); assert.deepEqual(f.game.battle!.plans, {});
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.ordinary]);
assert.equal(f.projectile.kind, 'projectile');
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1);
assert.equal(player(f.game, f.target).reserves, 19);
assert.deepEqual(f.claim, { kind: 'and', terms: [
{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'weapon', name: null }, { kind: 'defense', name: null },
] });
const prePromise = act(f.game, f.target, f.projectilePlan);
assert.equal(prePromise.battle!.plans[f.target].weapon, f.ordinary, 'The original held projectile is legal before the promise');
assert.equal(prePromise.battle!.revealed, false);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
});
void test('three through eight condition AND and OR answers bind actual original empty or projectile plans', () => {
const f = fixture();
for (const kind of ['and', 'or'] as const) for (const count of [3, 4, 5, 6, 7, 8])
for (const expected of ['yes', 'no'] as const) {
const selected = scenario(f, kind, count, expected), asked = ask(f, selected.claim);
assert.equal(asked.actions.length, 3, 'Declaration, the other seat priority pass and whole question');
assert.deepEqual(viewGame(asked.game, f.target).truthBattleAnswers, ['yes', 'no']);
assert.deepEqual(battleControls(asked.game), battleControls(f.source));
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /legal plans/);
const done = answer(asked.game, expected, asked.actions);
answered(done, f, selected.claim, expected);
reject(done, f.target, selected.contradictingPlan, /honor your Truthtrance/);
const sealed = seal(done, f, selected.plan, asked.actions);
assert.equal(asked.actions.length, 6);
assert.equal(sealed.afterTargetPlan.battle!.revealed, false);
actualPlan(sealed.game, f, selected.claim, expected);
assert.equal(sealed.game.battle!.plans[f.target].weapon, expected === 'yes' ? null : f.ordinary);
}
});
void test('mixed nested AND inside OR and OR inside AND enforce the complete aggregate through sealing', () => {
const f = fixture();
for (const kind of ['and', 'or'] as const) for (const expected of ['yes', 'no'] as const) {
const selected = nestedScenario(f, kind, expected), asked = ask(f, selected.claim);
assert.deepEqual(asked.game.truthtrance!.question, {
kind: 'battlePlan', target: f.target, territory: f.territory, claim: selected.claim,
});
const done = answer(asked.game, expected);
answered(done, f, selected.claim, expected);
reject(done, f.target, selected.contradictingPlan, /honor your Truthtrance/);
actualPlan(seal(done, f, selected.plan).game, f, selected.claim, expected);
}
});
void test('one definite answer discards once and the original battle completes after the same two seal controls', () => {
const f = fixture(), declared = declare(f);
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
reject(declared.game, f.asker, f.askStep.action, /priority/);
let game = passPriority(declared.game, declared.actions);
reject(game, f.target, f.askStep.action, /holder/);
game = act(game, f.asker, f.askStep.action, declared.actions);
reject(game, f.asker, f.answerStep.action, /questioned player/);
reject(game, f.target, f.targetPlan);
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
game = answer(game, 'yes', declared.actions);
answered(game, f, f.claim, 'yes');
reject(game, f.target, f.projectilePlan, /honor your Truthtrance/);
reject(game, f.target, f.answerStep.action);
const sealed = seal(game, f, f.targetPlan, declared.actions);
game = sealed.game; assert.equal(declared.actions.length, 6);
actualPlan(game, f, f.claim, 'yes');
for (const actor of [f.target, f.asker]) game = act(game, actor, { type: 'traitorCall', call: false }, declared.actions);
assert.equal(game.battle, null); assert.equal(declared.actions.length, 8);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
assert.equal(game.truthHistory!.length, 1);
});
void test('all leaves are server validated beyond the former second condition before consuming the original card', () => {
const f = fixture(), source = askSource(f), game = source.game;
const invalid: PlanClaim[] = [
{ kind: 'and', terms: [f.claim, { kind: 'dial', compare: 'eq', value: -1 }] },
{ kind: 'or', terms: [{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'weapon', name: null },
{ kind: 'weapon', name: 'Not an original card' }] },
{ kind: 'and', terms: [{ kind: 'dial', compare: 'eq', value: 1 }, { kind: 'weapon', name: null },
{ kind: 'or', terms: [{ kind: 'dial', compare: 'eq', value: 0 }, { kind: 'leader', leader: 'not-a-leader' }] }] },
];
for (const claim of invalid) reject(game, f.asker, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.target, territory: f.territory, claim,
} }, /valid battle-plan condition/);
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
assert.deepEqual(battleControls(game), battleControls(f.source));
assert.deepEqual(game.discard, f.source.discard); assert.deepEqual(inventory(game), f.originalCards);
});
void test('existing sixteen-node and four-level boundaries ask, answer and seal the real committed plan', () => {
const f = fixture(), { closedTrue } = claims(f);
const sixteen: PlanClaim = { kind: 'and', terms: [
{ kind: 'and', terms: closedTrue }, { kind: 'and', terms: closedTrue.slice(0, 5) },
] };
let depthFour: PlanClaim = closedTrue[1];
for (let depth = 0; depth < 4; depth++) depthFour = { kind: 'and', terms: [closedTrue[0], depthFour] };
assert.deepEqual(battleClaimExpressionSize(sixteen), { nodes: 16, depth: 2 });
assert.deepEqual(battleClaimExpressionSize(depthFour), { nodes: 9, depth: 4 });
for (const [id, claim] of [['sixteen-nodes', sixteen], ['depth-four', depthFour]] as const) {
const selected = { ...scenario(f), id, claim }, done = finish(f, selected);
assert.equal(done.actions.length, 6);
answered(done.answered, f, claim, 'yes'); actualPlan(done.game, f, claim, 'yes');
}
});
void test('a ninth child, seventeenth node, singleton or fifth grouping level rejects before the original card is consumed', () => {
const f = fixture(), game = askSource(f).game, { closedTrue } = claims(f);
let depthFive: PlanClaim = closedTrue[1];
for (let depth = 0; depth < 5; depth++) depthFive = { kind: 'and', terms: [closedTrue[0], depthFive] };
const invalid: PlanClaim[] = [
{ kind: 'and', terms: [] }, { kind: 'or', terms: [closedTrue[0]] },
{ kind: 'or', terms: [...closedTrue, closedTrue[0]] },
{ kind: 'and', terms: [{ kind: 'and', terms: closedTrue }, { kind: 'and', terms: closedTrue.slice(0, 6) }] },
depthFive,
];
for (const claim of invalid) reject(game, f.asker, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.target, territory: f.territory, claim,
} }, /valid battle-plan condition/);
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
assert.deepEqual(game.discard, f.source.discard);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles choose feasible whole answers and seal legal plans for grouped questions', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
const declared = declare(f), priorityView = viewGame(declared.game, f.target);
priorityView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(priorityView), [{ type: 'truthPass' }]);
const passed = act(declared.game, f.target, botActions(priorityView)[0]);
assert.equal(passed.truthtrance?.stage, 'ask');
for (const selected of [scenario(f, 'and', 8), scenario(f, 'or', 8), nestedScenario(f, 'and'), nestedScenario(f, 'or')]) {
const asked = ask(f, selected.claim), answerView = viewGame(asked.game, f.target);
answerView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(answerView), response = botActions(answerView)[0];
assert.deepEqual(answerView, before); assert.equal(response?.type, 'truthAnswer');
assert.ok(answerView.truthBattleAnswers?.includes(response.answer as 'yes' | 'no' | 'unknown'));
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
let game = act(asked.game, f.target, response);
const planView = viewGame(game, f.target); planView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const plan = botActions(planView).find(action => action.type === 'battlePlan');
assert.ok(plan, `${difficulty} supplies a legal plan after its aggregate answer`);
game = act(game, f.target, plan);
const sealed = game.battle!.plans[f.target];
assert.equal(matchesPlanClaim(selected.claim, { ...sealed, kwisatz: !!sealed.kwisatz }, player(game, f.target).hand), response.answer === 'yes');
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
}
});
@@ -0,0 +1,262 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ShipmentClauseFields } from '../components/shipment-claim-fields';
import { ShipmentExpressionFields } from '../components/shipment-expression-fields';
import { TruthShipmentQuestion } from '../components/truth-shipment-question';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { parseShipmentExpression, type ShipmentExpression } from '../game/shipment-promises';
import { shipmentExpressionInputError, shipmentExpressionInputSize } from '../game/shipment-expression-input';
import type { TruthQuestion } from '../game/truthtrance';
// Entitled controlled projections supplement the separate original-card source.
// React renders descendants; direct calls reach only hook-free controlled APIs.
function projected(): GameView {
const source = createGame('SHIPMENTEXPRESSIONCONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
joinGame(source, newPlayer('t', 'Target', 'emperor'));
const game = viewGame(source, 'a');
game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth-card' }], passed: [], question: null };
game.truthShipmentTargets = ['t'];
return game;
}
const leaf = (territory = 'arrakeen', minimum = 1): ShipmentExpression => ({ territory, minimum });
const question = (claim: ShipmentExpression, target = 't'): Extract<TruthQuestion, { kind: 'shipment' }> => ({ kind: 'shipment', target, claim });
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function control(tree: ReactNode, key: string): Element {
const found = elements(tree).find(element => element.props['data-shipment-expression-control'] === key);
assert.ok(found, `Shipment control ${key} is rendered`); return found;
}
const click = (button: Element) => (button.props.onClick as () => void)();
function nodeAt(tree: ReactNode, path: string): Element {
const found = elements(tree).find(element => element.props['data-shipment-expression-path'] === path);
assert.ok(found, `Shipment condition or group ${path} is rendered`); return found;
}
function editLeaf(tree: ReactNode, path: string, next: ShipmentExpression) {
const field = elements(nodeAt(tree, path)).find(element => element.type === ShipmentClauseFields);
assert.ok(field); (field.props.onChange as (value: ShipmentExpression) => void)(next);
}
function switchGroup(tree: ReactNode, path: string, op: string) {
const select = elements(nodeAt(tree, path)).find(element => element.type === 'select');
assert.ok(select);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: op } });
}
function editor(initial: ShipmentExpression, disabled = false, id = 'custom-shipment') {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => ShipmentExpressionFields({ value, id, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(ShipmentExpressionFields, { value, id, disabled, onChange() {} })),
};
}
function askTree(game: GameView, value: Extract<TruthQuestion, { kind: 'shipment' }>, busy = false) {
const actions: Action[] = [], changes: ShipmentExpression[] = [];
const props = { game, question: value, busy, act(action: Action) { actions.push(action); }, onChange(next: ShipmentExpression) { changes.push(next); } };
const tree = TruthShipmentQuestion(props);
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-shipment-ask');
assert.ok(button);
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthShipmentQuestion, props)) };
}
function groupsDeep(depth: number): ShipmentExpression {
let expression = leaf();
for (let i = 0; i < depth; i++) expression = { op: 'and', terms: [expression, leaf('carthag')] };
return expression;
}
void test('actual add and leaf callbacks author three through sixteen shipment conditions without truncation', () => {
const initial = leaf('polar_sink', 3), e = editor(initial);
click(control(e.tree(), 'or-1'));
for (let count = 3; count <= 16; count++) {
const previous = structuredClone(e.value); assert.ok('op' in previous);
click(control(e.tree(), 'add-1'));
editLeaf(e.tree(), `1.${count}`, leaf(TERRITORIES[count - 1].id, count));
assert.ok('op' in e.value); assert.equal(e.value.terms.length, count);
assert.deepEqual(e.value.terms.slice(0, count - 1), previous.terms);
}
assert.ok('op' in e.value); assert.deepEqual(e.value.terms[0], initial);
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 16, depth: 1 });
assert.deepEqual(parseShipmentExpression(e.value), e.value);
const html = e.html();
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 16);
assert.match(html, /16 \/ 16 shipment conditions/);
assert.match(html, /id="custom-shipment-1\.16-minimum"/);
assert.match(html, /for="custom-shipment-1\.16-territory"/);
});
void test('nested AND and OR groups preserve every branch and allow independent group meaning changes', () => {
const e = editor(leaf());
click(control(e.tree(), 'and-1')); editLeaf(e.tree(), '1.2', leaf('carthag', 3));
click(control(e.tree(), 'or-1.2')); editLeaf(e.tree(), '1.2.2', leaf('sietch_tabr', 4));
click(control(e.tree(), 'add-1.2')); editLeaf(e.tree(), '1.2.3', leaf('polar_sink', 5));
const original = structuredClone(e.value);
switchGroup(e.tree(), '1.2', 'and');
assert.deepEqual(e.value, { op: 'and', terms: [leaf(), { op: 'and', terms: [leaf('carthag', 3), leaf('sietch_tabr', 4), leaf('polar_sink', 5)] }] });
switchGroup(e.tree(), '1.2', 'or'); assert.deepEqual(e.value, original);
const changes = e.changes; switchGroup(e.tree(), '1.2', 'single'); assert.equal(e.changes, changes);
click(control(e.tree(), 'nested-and-1'));
click(control(e.tree(), 'nested-or-1'));
assert.ok('op' in e.value);
assert.deepEqual(e.value.terms.slice(0, 2), 'op' in original ? original.terms : []);
assert.deepEqual(e.value.terms[2], { op: 'and', terms: [leaf('carthag'), leaf('carthag')] });
assert.deepEqual(e.value.terms[3], { op: 'or', terms: [leaf('carthag'), leaf('carthag')] });
assert.match(e.html(), /Shipment group 1\.2 · OR/);
});
void test('remove collapses singleton groups and retains the complete remaining expression', () => {
const retained = leaf('polar_sink', 5);
const e = editor({ op: 'or', terms: [leaf(), { op: 'and', terms: [leaf('carthag'), retained] }] });
click(control(e.tree(), 'remove-1.2.1'));
assert.deepEqual(e.value, { op: 'or', terms: [leaf(), retained] });
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, retained);
const survivor: ShipmentExpression = { op: 'or', terms: [leaf(), leaf('carthag'), leaf('sietch_tabr')] };
const grouped = editor({ op: 'and', terms: [{ op: 'and', terms: [leaf(), leaf()] }, survivor] });
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, survivor);
});
void test('sixteen leaves count independently of groups and guard all add and wrap callbacks', () => {
const full: ShipmentExpression = { op: 'and', terms: [
{ op: 'or', terms: Array.from({ length: 8 }, () => leaf()) },
{ op: 'and', terms: Array.from({ length: 8 }, () => leaf('carthag')) },
] };
const e = editor(full), before = structuredClone(full);
assert.deepEqual(shipmentExpressionInputSize(full), { leaves: 16, depth: 2 });
assert.equal(shipmentExpressionInputError(full), null);
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
}
assert.equal(e.changes, 0); assert.deepEqual(e.value, before);
editLeaf(e.tree(), '1.2.8', leaf('polar_sink', 20)); assert.equal(e.changes, 1);
assert.equal((e.html().match(/This-turn shipment destination/g) ?? []).length, 16);
});
void test('depth-four allows deepest leaf edits and shallow nesting while directly invoked fifth-depth controls no-op', () => {
const e = editor(groupsDeep(4));
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 5, depth: 4 });
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1', 'nested-or-1.1.1.1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
editLeaf(e.tree(), '1.1.1.1.1', leaf('arrakeen', 20));
click(control(e.tree(), 'add-1.1.1.1')); click(control(e.tree(), 'or-1.2'));
assert.deepEqual(shipmentExpressionInputSize(e.value), { leaves: 7, depth: 4 });
assert.equal(shipmentExpressionInputError(e.value), null);
});
void test('invalid numeric leaves remain editable and structurally addable while full parser validation blocks Ask', () => {
for (const minimum of [NaN, -1, 0, 0.5, 21, Infinity]) {
const e = editor({ op: 'or', terms: [leaf(), leaf('carthag'), leaf('arrakeen', minimum)] });
assert.equal(shipmentExpressionInputError(e.value), null);
const ask = askTree(projected(), question(e.value));
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
assert.match(ask.html(), /Choose a minimum of one to twenty physical forces/);
click(control(e.tree(), 'add-1')); assert.ok('op' in e.value); assert.equal(e.value.terms.length, 4);
editLeaf(e.tree(), '1.3', leaf('arrakeen', 2));
assert.deepEqual(parseShipmentExpression(e.value), e.value);
}
});
void test('busy disables the fieldset and guards actual add, wrap, remove, switch and leaf callbacks', () => {
const initial: ShipmentExpression = { op: 'and', terms: [leaf(), leaf('carthag')] }, e = editor(initial, true);
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="shipment-expression"/);
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
switchGroup(e.tree(), '1', 'or'); editLeaf(e.tree(), '1.2', leaf('polar_sink', 7));
assert.equal(e.changes, 0); assert.deepEqual(e.value, initial);
});
void test('Ask forwards the exact full three-, eight-, sixteen-leaf and nested question without adding fields', () => {
const expressions: ShipmentExpression[] = [
...[3, 8, 16].map(count => ({ op: 'or' as const, terms: TERRITORIES.slice(0, count).map(place => leaf(place.id)) })),
{ op: 'and', terms: [leaf(), { op: 'or', terms: [leaf('carthag'), leaf('polar_sink'), leaf('sietch_tabr')] }] }, groupsDeep(4),
];
for (const expression of expressions) {
const value = question(expression), before = structuredClone(value), ask = askTree(projected(), value);
assert.equal(ask.button.props.disabled, false); click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question: value }]);
assert.equal((ask.actions[0] as { question: unknown }).question, value);
assert.deepEqual(value, before); assert.match(ask.html(), /data-testid="truth-shipment-preview"/);
}
const legacy: Extract<TruthQuestion, { kind: 'shipment' }> = { kind: 'shipment', target: 't', territory: 'arrakeen', minimum: 3 };
const ask = askTree(projected(), legacy); click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question: legacy }]);
});
void test('constants, malformed late leaves, excessive width or depth and singleton groups block the whole Ask', () => {
const invalid: ShipmentExpression[] = [
{ op: 'or', terms: [leaf(), leaf('carthag'), { constant: 'yes' }] },
{ op: 'or', terms: [leaf(), { op: 'and', terms: [leaf(), { constant: 'no' }] }] },
{ op: 'or', terms: [leaf(), leaf('carthag'), leaf('not-a-territory')] },
{ op: 'or', terms: Array.from({ length: 17 }, () => leaf()) },
{ op: 'and', terms: [leaf()] }, groupsDeep(5),
];
for (const expression of invalid) {
const ask = askTree(projected(), question(expression));
assert.equal(ask.button.props.disabled, true); click(ask.button); assert.deepEqual(ask.actions, []);
assert.doesNotThrow(ask.html); assert.match(ask.html(), /role="alert"/);
}
for (const constant of ['yes', 'no', 'unknown'] as const) {
const expression: ShipmentExpression = { op: 'or', terms: [leaf(), { constant }] };
assert.match(shipmentExpressionInputError(expression)!, /reserved for internal/);
}
});
void test('busy, actual queue owner, target seating and authoritative offered targets guard Ask and editor callbacks', () => {
const cases: { game: GameView; value?: Extract<TruthQuestion, { kind: 'shipment' }>; busy?: boolean }[] = [{ game: projected(), busy: true }];
for (const stage of ['priority', 'answer', 'unknown'] as const) {
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
}
for (const owner of ['t', 'not-seated']) {
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
}
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
const unsupported = projected(); unsupported.truthShipmentTargets = []; cases.push({ game: unsupported });
for (const target of ['a', 'not-seated']) cases.push({ game: projected(), value: question(leaf(), target) });
for (const entry of cases) {
const ask = askTree(entry.game, entry.value ?? question(leaf()), entry.busy);
assert.equal(ask.button.props.disabled, true); click(ask.button);
const fields = elements(ask.tree).find(element => element.type === ShipmentExpressionFields);
if (fields) {
assert.equal(fields.props.disabled, true);
(fields.props.onChange as (next: ShipmentExpression) => void)(leaf('polar_sink'));
}
assert.deepEqual(ask.actions, []); assert.deepEqual(ask.changes, []); assert.doesNotThrow(ask.html);
}
const game = projected(), stale = askTree(game, question(leaf()));
const fields = elements(stale.tree).find(element => element.type === ShipmentExpressionFields); assert.ok(fields);
for (const mutate of [
() => { game.truthtrance!.stage = 'answer'; },
() => { game.truthtrance!.stage = 'ask'; game.truthtrance!.queue[0].player = 't'; },
() => { game.truthtrance!.queue[0].player = 'a'; game.truthShipmentTargets = []; },
() => { game.truthShipmentTargets = ['t']; game.players = game.players.filter(player => player.id !== 't'); },
]) {
mutate(); click(stale.button); (fields.props.onChange as (next: ShipmentExpression) => void)(leaf('polar_sink'));
}
assert.deepEqual(stale.actions, []); assert.deepEqual(stale.changes, []);
});
void test('guidance describes the whole eventual shipment, aggregate No and skip without new consent or private feasibility reads', () => {
const game = projected();
for (const player of game.players) for (const field of ['hand', 'traitors', 'spice', 'reserves', 'forces', 'leaders'])
Object.defineProperty(player, field, { get() { throw Error(`Shipment authoring read private ${field}`); } });
for (const field of ['truthShipmentAnswers', 'shipmentCompletion', 'truthAnswer', 'truthKnowledge'])
Object.defineProperty(game, field, { get() { throw Error(`Shipment authoring read private ${field}`); } });
const ask = askTree(game, question({ op: 'or', terms: [leaf(), leaf('carthag'), leaf('sietch_tabr')] }));
const html = ask.html();
assert.match(html, /same eventual shipment from reserves this turn/);
assert.match(html, /No requires the whole expression to be false/);
assert.match(html, /Skipping shipment makes every condition false/);
assert.match(html, /Groups do not count as conditions/); assert.match(html, /not additional printed rules/);
assert.match(html, /Arrakeen/); assert.match(html, / OR /);
assert.doesNotMatch(html, /Confirm question|Acknowledge|Current fact type/);
click(ask.button); assert.equal(ask.actions.length, 1);
});
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { matchesShipment, type ShipmentExpression } from '../game/shipment-promises';
import { shipmentExpressionInputSize } from '../game/shipment-expression-input';
import {
createTruthShipmentExpressionFixture as fixture,
actTruthShipmentExpression as act,
truthShipmentExpressionClean as clean,
truthShipmentExpressionPlayer as player,
truthShipmentExpressionInventory as inventory,
truthShipmentExpressionScenario as scenario,
truthShipmentExpressionNestedScenario as nestedScenario,
truthShipmentExpressionDepthScenario as depthScenario,
declareTruthShipmentExpression as declare,
passTruthShipmentExpressionPriority as passPriority,
askTruthShipmentExpression as ask,
answerTruthShipmentExpression as answer,
settleTruthShipmentExpression as settle,
finishTruthShipmentExpression as finish,
type TruthShipmentExpressionFixture,
} from './fixture-truth-shipment-expression';
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
const before = structuredClone(game);
const run = () => applyAction(game, actor, action);
if (message) assert.throws(run, message); else assert.throws(run);
assert.deepEqual(game, before);
}
/** Only the native interrupted movement controls, not a recovery campaign. */
function movementControls(game: Game) {
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
movementRemaining: game.movementRemaining, storm: game.storm,
response: game.response ?? null, decision: game.decision ?? null,
phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
}
function bound(game: Game, f: TruthShipmentExpressionFixture, expression: ShipmentExpression, expected: 'yes' | 'no') {
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
assert.deepEqual(movementControls(game), movementControls(f.source));
assert.equal(player(game, f.target).shipped, false); assert.equal(player(game, f.target).reserves, 20);
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
assert.equal(player(game, f.target).spice, player(f.source, f.target).spice);
assert.deepEqual(game.shipmentPromises, [{ player: f.target, asker: f.asker,
turn: f.source.turn, claim: expression, answer: expected === 'yes' }]);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(game.discard.length, f.source.discard.length + 1);
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalCards);
assert.deepEqual(game.truthHistory!.at(-1), { turn: f.source.turn, phase: 5, asker: f.asker,
question: { kind: 'shipment', target: f.target, claim: expression }, answer: expected });
}
function delivered(game: Game, f: TruthShipmentExpressionFixture) {
assert.ok(clean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
assert.equal(player(game, f.target).shipped, true);
assert.equal(player(game, f.target).forces[f.location], 1);
assert.equal(player(game, f.target).reserves, 19);
assert.equal(player(f.source, f.target).spice - player(game, f.target).spice, 1);
assert.equal(game.shipmentPromises!.length, 1);
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
void test('pure shipment source uses only the original public Basic prefix before Emperor’s unused active turn', () => {
const f = fixture();
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor']);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target);
assert.equal(player(f.game, f.target).reserves, 20); assert.deepEqual(player(f.game, f.target).forces, {});
assert.equal(player(f.game, f.target).shipped, false);
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.card));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.ordinary));
assert.deepEqual(f.actions, f.base.actions.slice(0, f.sourceCounts.shipment));
assert.deepEqual(f.base.actions[f.sourceActionCount], f.shipmentStep);
assert.ok(f.sourceActionCount < f.base.sourceActionCount);
assert.equal(f.actions.some(step => step.action.type === 'chooseBattle' || step.action.type === 'battlePlan'), false);
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.shipmentPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
});
void test('native three through eight leaf AND and OR promises retain and fulfill both whole-expression Yes and No', () => {
const f = fixture();
for (const op of ['and', 'or'] as const) for (const count of [3, 4, 5, 6, 7, 8])
for (const expected of ['yes', 'no'] as const) {
const selected = scenario(op, count, expected), done = finish(f, selected);
assert.equal(done.question.action.type, 'truthAsk');
assert.deepEqual(done.beforeQuestion.truthtrance!.question, null);
assert.deepEqual(viewGame(done.asked, f.target).truthShipmentAnswers, ['yes', 'no']);
assert.equal(viewGame(done.asked, f.asker).truthShipmentAnswers, null);
bound(done.bound, f, selected.expression, expected); delivered(done.game, f);
assert.equal(matchesShipment({ claim: selected.expression }, { territory: 'arrakeen', amount: 1 }), expected === 'yes');
assert.equal(done.actions.length, 5, 'Original declaration, one priority pass, full question, answer and delivery');
if (op === 'and' && expected === 'no') {
assert.ok('op' in selected.expression);
assert.equal(matchesShipment({ claim: selected.expression.terms[0] }, { territory: 'arrakeen', amount: 1 }), true,
'No makes the whole expression false while earlier conditions may remain true');
}
}
});
void test('native nested AND within OR and OR within AND keep their complete statement through actual delivery', () => {
const f = fixture();
for (const op of ['and', 'or'] as const) for (const expected of ['yes', 'no'] as const) {
const selected = nestedScenario(op, expected), done = finish(f, selected);
assert.deepEqual(done.asked.truthtrance!.question, { kind: 'shipment', target: f.target, claim: selected.expression });
bound(done.bound, f, selected.expression, expected); delivered(done.game, f);
}
});
void test('a sixteen-leaf distinct-destination OR and four group levels use the full existing pure-shipment bounds', () => {
const f = fixture(), sixteen = scenario('or', 16, 'yes');
assert.ok('op' in sixteen.expression);
assert.equal(new Set(sixteen.expression.terms.map(term => 'territory' in term ? term.territory : '')).size, 16);
assert.ok(sixteen.expression.terms.every(term => 'minimum' in term && term.minimum === 1));
assert.deepEqual(sixteen.expression.terms[15], { territory: 'arrakeen', minimum: 1 });
assert.deepEqual(shipmentExpressionInputSize(sixteen.expression), { leaves: 16, depth: 1 });
for (const selected of [sixteen, depthScenario(4)]) {
const done = finish(f, selected);
bound(done.bound, f, selected.expression, 'yes'); delivered(done.game, f);
const continued = act(done.game, f.continuationStep.actor, f.continuationStep.action, done.actions);
assert.equal(done.actions.length, 6);
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
assert.equal(continued.shipmentPromises![0].fulfilled, true);
assert.deepEqual(inventory(continued), f.originalCards);
}
});
void test('original priority, target-only answer and one discard resume the same actor’s paid shipment and endMovement', () => {
const f = fixture(), declared = declare(f);
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
reject(declared.game, f.asker, f.askStep.action, /priority/);
let game = passPriority(declared.game, declared.actions);
reject(game, f.target, f.askStep.action, /holder/);
game = act(game, f.asker, f.askStep.action, declared.actions);
reject(game, f.asker, f.answerStep.action, /questioned player/);
reject(game, f.target, { type: 'truthAnswer', answer: 'unknown' });
reject(game, f.target, f.shipmentStep.action);
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
game = answer(game, 'yes', declared.actions);
bound(game, f, f.expression, 'yes');
reject(game, f.target, { type: 'endMovement' });
reject(game, f.target, { ...f.shipmentStep.action, territory: 'carthag', sector: 11 });
game = act(game, f.shipmentStep.actor, f.shipmentStep.action, declared.actions);
game = settle(game, declared.actions); delivered(game, f);
assert.equal(game.active, f.continuationStep.actor);
reject(game, f.target, f.answerStep.action);
const continued = act(game, f.continuationStep.actor, f.continuationStep.action, declared.actions);
assert.equal(declared.actions.length, 6);
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(continued), f.originalCards);
});
void test('OR No rejects its matching original delivery and can explicitly skip the shipment instead', () => {
const f = fixture(), selected = scenario('or', 16, 'yes'), asked = ask(f, selected.expression);
let game = answer(asked.game, 'no');
bound(game, f, selected.expression, 'no');
reject(game, f.target, f.shipmentStep.action);
game = act(game, f.continuationStep.actor, f.continuationStep.action);
assert.equal(player(game, f.target).reserves, 20); assert.deepEqual(player(game, f.target).forces, {});
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all leaves validate before Ask even under decisive OR; constants, seventeenth leaf and fifth depth never consume the card', () => {
const f = fixture(), game = passPriority(declare(f).game);
const a: ShipmentExpression = { territory: 'arrakeen', minimum: 1 };
const invalid: ShipmentExpression[] = [
{ op: 'or', terms: [a, a, { territory: 'arrakeen', minimum: 0 }] },
{ op: 'or', terms: [a, a, { territory: 'not-a-territory', minimum: 1 }] },
...(['yes', 'no', 'unknown'] as const).map(constant => ({ op: 'or' as const, terms: [a, a, { constant }] })),
{ op: 'or', terms: [a, { op: 'and', terms: [a, { constant: 'yes' }] }] },
{ op: 'and', terms: [a] }, { op: 'or', terms: [] },
{ op: 'or', terms: Array.from({ length: 17 }, () => a) },
depthScenario(5).expression,
];
for (const expression of invalid)
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'shipment', target: f.target, claim: expression } });
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
assert.deepEqual(game.discard, f.source.discard);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a genuine incompatible-destination AND offers only No and honors it with a matching individual branch', () => {
const f = fixture(), expression: ShipmentExpression = { op: 'and', terms: [
{ territory: 'arrakeen', minimum: 1 }, { territory: 'carthag', minimum: 1 }, { territory: 'sietch_tabr', minimum: 1 },
] };
const asked = ask(f, expression);
assert.deepEqual(viewGame(asked.game, f.target).truthShipmentAnswers, ['no']);
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
const done = finish(f, { id: 'incompatible-and-no', expression, answer: 'no' });
bound(done.bound, f, expression, 'no'); delivered(done.game, f);
});
void test('all four minimal profiles pass native priority and legally answer and complete full pure shipment expressions', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
const declared = declare(f), priority = viewGame(declared.game, f.target);
priority.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(priority), [{ type: 'truthPass' }]);
const passed = act(declared.game, f.target, botActions(priority)[0]);
assert.equal(passPriority(passed).truthtrance!.stage, 'ask');
for (const selected of [scenario('and', 8, 'yes'), scenario('or', 8, 'no'),
nestedScenario('and', 'no'), nestedScenario('or', 'yes'), scenario('or', 16, 'yes')]) {
const asked = ask(f, selected.expression), view = viewGame(asked.game, f.target);
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(view), actions = botActions(view);
assert.deepEqual(view, before); assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'truthAnswer');
const selectedAnswer = actions[0].answer;
assert.ok(selectedAnswer === 'yes' || selectedAnswer === 'no');
assert.ok(view.truthShipmentAnswers!.includes(selectedAnswer));
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
let game = act(asked.game, f.target, actions[0]);
bound(game, f, selected.expression, selectedAnswer);
const completion = viewGame(game, f.target).shipmentCompletion?.actions;
assert.ok(completion?.length, 'The authoritative existing completion supplies a legal next step');
game = act(game, f.target, completion[0]); game = settle(game);
assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
}
});