diff --git a/components/battle-claim-expression-fields.tsx b/components/battle-claim-expression-fields.tsx
new file mode 100644
index 00000000..8816b3c4
--- /dev/null
+++ b/components/battle-claim-expression-fields.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import type { ReactElement } from 'react';
+import type { GameView } from '@/game/engine';
+import type { PlanClaim } from '@/game/battle-promises';
+import {
+ BATTLE_CLAIM_MAX_DEPTH,
+ BATTLE_CLAIM_MAX_NODES,
+ BATTLE_CLAIM_MAX_TERMS,
+ battleClaimExpressionError,
+ battleClaimExpressionSize,
+} from '@/game/battle-claim-expression';
+import { BattleClaimFields } from './battle-promises';
+import { Button } from './ui/button';
+
+const newClaim = (): PlanClaim => ({ kind: 'dial', compare: 'eq', value: 0 });
+
+/** Controlled structure editor. Invalid leaf input stays available for repair. */
+export function BattleClaimExpressionFields({ value, game, disabled, onChange }: {
+ value: PlanClaim;
+ game: GameView;
+ disabled: boolean;
+ onChange: (claim: PlanClaim) => void;
+}) {
+ const size = battleClaimExpressionSize(value);
+ const commit = (next: PlanClaim) => {
+ if (!disabled && !battleClaimExpressionError(next)) onChange(next);
+ };
+ const render = (
+ node: PlanClaim,
+ path: string,
+ replace: (next: PlanClaim) => PlanClaim,
+ remove?: () => PlanClaim,
+ ): ReactElement => {
+ const group = node.kind === 'and' || node.kind === 'or';
+ const allowed = (next: PlanClaim) => !disabled && !battleClaimExpressionError(replace(next));
+ const change = (next: PlanClaim) => commit(replace(next));
+ const wrap = (kind: 'and' | 'or'): PlanClaim => ({ kind, terms: [node, newClaim()] });
+ return
;
+ };
+ return ;
+}
diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx
index 5aa054c3..a197fb40 100644
--- a/components/battle-promises.tsx
+++ b/components/battle-promises.tsx
@@ -69,7 +69,7 @@ export function BattleClaimFields({
min={0}
max={40}
step={c.kind === 'dial' ? 0.5 : 1}
- value={c.value}
+ value={Number.isNaN(c.value) ? '' : c.value}
onChange={(e) =>
onChange({ ...c, value: Number(e.target.value) })
}
diff --git a/components/shipment-expression-fields.tsx b/components/shipment-expression-fields.tsx
new file mode 100644
index 00000000..bcfbf505
--- /dev/null
+++ b/components/shipment-expression-fields.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import type { ReactElement } from 'react';
+import {
+ SHIPMENT_EXPRESSION_MAX_DEPTH,
+ SHIPMENT_EXPRESSION_MAX_LEAVES,
+ type ShipmentExpression,
+} from '@/game/shipment-promises';
+import { shipmentExpressionInputError, shipmentExpressionInputSize } from '@/game/shipment-expression-input';
+import { ShipmentClauseFields } from './shipment-claim-fields';
+import { Button } from './ui/button';
+
+const newLeaf = (): ShipmentExpression => ({ territory: 'carthag', minimum: 1 });
+
+/** Hook-free controlled tree. Stateful leaf descendants are rendered by React. */
+export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, onChange }: {
+ value: ShipmentExpression;
+ id?: string;
+ disabled: boolean;
+ onChange: (expression: ShipmentExpression) => void;
+}) {
+ const size = shipmentExpressionInputSize(value);
+ const commit = (next: ShipmentExpression) => {
+ if (!disabled && !shipmentExpressionInputError(next)) onChange(next);
+ };
+ const render = (
+ node: ShipmentExpression,
+ path: string,
+ replace: (next: ShipmentExpression) => ShipmentExpression,
+ remove?: () => ShipmentExpression,
+ ): ReactElement => {
+ const group = 'op' in node;
+ const allowed = (next: ShipmentExpression) => !disabled && !shipmentExpressionInputError(replace(next));
+ const change = (next: ShipmentExpression) => commit(replace(next));
+ const wrap = (op: 'and' | 'or'): ShipmentExpression => ({ op, terms: [node, newLeaf()] });
+ return ;
+ };
+ return ;
+}
diff --git a/components/truth-battle-plan-question.tsx b/components/truth-battle-plan-question.tsx
new file mode 100644
index 00000000..fd9aef79
--- /dev/null
+++ b/components/truth-battle-plan-question.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { parsePlanClaim, type PlanClaim } from '@/game/battle-promises';
+import { battleClaimExpressionError } from '@/game/battle-claim-expression';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import { truthQuestionText, type TruthQuestion } from '@/game/truthtrance';
+import { BattleClaimExpressionFields } from './battle-claim-expression-fields';
+import { Button } from './ui/button';
+
+/** Authoring uses public identities; the server determines feasible promises. */
+export function TruthBattlePlanQuestion({ game, question, busy, act, onChange }: {
+ game: GameView;
+ question: Extract;
+ busy: boolean;
+ act: (action: Action) => void;
+ onChange: (claim: PlanClaim) => void;
+}) {
+ const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) &&
+ game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me;
+ const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target) &&
+ !!game.battle && [game.battle.attacker, game.battle.defender].includes(question.target) &&
+ question.territory === game.battle.territory;
+ const inputError = () => {
+ const structureError = battleClaimExpressionError(question.claim);
+ if (structureError) return structureError;
+ try {
+ parsePlanClaim(question.claim, game.allLeaders.map(leader => leader.id));
+ return null;
+ } catch {
+ return 'Resolve the battle-plan fields before asking.';
+ }
+ };
+ const blocked = () => busy || !ownsQuestion() || !hasTarget() || !!inputError();
+ const error = inputError();
+ return <>
+ {hasTarget() ? {
+ if (!busy && ownsQuestion() && hasTarget() && !battleClaimExpressionError(next)) onChange(next);
+ }} /> : Choose another seated combatant in the current battle.
}
+
+ {truthQuestionText(question,
+ id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id,
+ id => game.combatLocations.find(location => location.id === id)?.name ?? id)}
+
+ {error && {error}
}
+
+ >;
+}
diff --git a/components/truth-shipment-question.tsx b/components/truth-shipment-question.tsx
new file mode 100644
index 00000000..8d1a7326
--- /dev/null
+++ b/components/truth-shipment-question.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import {
+ parseShipmentExpression,
+ ShipmentClaimError,
+ shipmentExpressionOf,
+ type ShipmentExpression,
+} from '@/game/shipment-promises';
+import { shipmentExpressionInputError } from '@/game/shipment-expression-input';
+import { truthQuestionText, type TruthQuestion } from '@/game/truthtrance';
+import { ShipmentExpressionFields } from './shipment-expression-fields';
+import { Button } from './ui/button';
+
+/** Public authoring only. Feasibility and answer choices stay authoritative. */
+export function TruthShipmentQuestion({ game, question, busy, act, onChange }: {
+ game: GameView;
+ question: Extract;
+ busy: boolean;
+ act: (action: Action) => void;
+ onChange: (expression: ShipmentExpression) => void;
+}) {
+ const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) &&
+ game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me;
+ const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target);
+ const supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target);
+ const inputError = () => {
+ try {
+ parseShipmentExpression(shipmentExpressionOf(question), { allowConstants: false });
+ return null;
+ } catch (error) {
+ if (error instanceof ShipmentClaimError) return error.message;
+ throw error;
+ }
+ };
+ const blocked = () => busy || !ownsQuestion() || !supported() || !!inputError();
+ const error = inputError();
+ return <>
+ {hasTarget() ? {
+ if (!busy && ownsQuestion() && supported() && !shipmentExpressionInputError(next)) onChange(next);
+ }} /> : Choose another seated player for this question.
}
+ {!supported() && Choose an eligible respondent with an unused shipment this turn and finish pending decisions.
}
+
+ {error ?? truthQuestionText(question,
+ id => game.allLeaders.find(leader => leader.id === id)?.name ?? id,
+ id => game.combatLocations.find(location => location.id === id)?.name ?? id)}
+
+ {error && {error}
}
+
+ >;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 36a1e2df..039818f4 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -2,8 +2,9 @@
import { MovementClaimFields } from './movement-claim-fields';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
-import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields';
-import { BattleClaimFields } from './battle-promises';
+import { TruthShipmentQuestion } from './truth-shipment-question';
+import { TruthBattlePlanQuestion } from './truth-battle-plan-question';
+import type { ShipmentExpression } from '@/game/shipment-promises';
import {
MixedShipmentFields,
mixedShipmentInputError,
@@ -55,11 +56,7 @@ export function Truthtrance({
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall'
>('fact');
const [movement, setMovement] = useState({ territory: 'carthag', minimum: 6 });
- const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
- const [shipmentClauses, setShipmentClauses] = useState([
- { territory: 'carthag', minimum: 6 },
- { territory: 'arrakeen', minimum: 4 },
- ]);
+ const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState({
kind: 'and',
terms: [
@@ -68,15 +65,10 @@ export function Truthtrance({
],
});
const invalidMixed = mixedShipmentInputError(mixed, g, target);
- const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
const shipmentAvailable = g.truthShipmentTargets.includes(target);
- const [planClauses, setPlanClauses] = useState([
- { kind: 'dial', compare: 'gte', value: 1 },
- { kind: 'weapon', name: null },
- ]);
+ const [planClaim, setPlanClaim] = useState({ kind: 'dial', compare: 'gte', value: 1 });
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
const [text, setText] = useState('');
- const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
const [fact, setFact] = useState({ kind: 'hand', name: 'Shield' });
const factId = useId();
const leaderName = (id: string) =>
@@ -97,10 +89,7 @@ export function Truthtrance({
kind,
target,
territory: g.battle?.territory ?? '',
- claim:
- join === 'single'
- ? planClauses[0]
- : { kind: join, terms: planClauses },
+ claim: planClaim,
}
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'movement' ? { kind, target, ...movement }
@@ -108,7 +97,7 @@ export function Truthtrance({
? {
kind,
target,
- ...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
+ claim: shipment,
}
: kind === 'mixedShipment'
? { kind, target, mixed }
@@ -365,9 +354,9 @@ export function Truthtrance({
{kind === 'mixedShipment' ? (
- ) : (
-
+ ) : question.kind === 'shipment' && (
+
)}
Includes Fremen reinforcements and Guild transport from
@@ -390,53 +379,15 @@ export function Truthtrance({
remain unfinished.
)}
-
- {kind === 'mixedShipment'
- ? invalidMixed ?? truthQuestionText(question, leaderName, locationName)
- : invalidShipment
- ? 'Enter a whole minimum of one to twenty forces.'
- : truthQuestionText(question, leaderName, locationName)}
-
+ {kind === 'mixedShipment' &&
+ {invalidMixed ?? truthQuestionText(question, leaderName, locationName)}
+
}
>
) : kind === 'battlePlan' ? (
- <>
-
- {planClauses
- .slice(0, join === 'single' ? 1 : 2)
- .map((c, index) => (
-
- ))}
-
- {truthQuestionText(question, leaderName, locationName)}
-
- {g.battle &&
- ![g.battle.attacker, g.battle.defender].includes(
- target,
- ) && Select one of the combatants for this question.
}
- >
+ question.kind === 'battlePlan' && (
+
+ )
) : kind === 'fact' ? (
question.kind === 'fact' && (
>
)}
- {kind !== 'fact' && button(
+ {!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
- (kind === 'shipment' &&
- (invalidShipment || !shipmentAvailable)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||
- (kind === 'freeform' && !text.trim()) ||
- (kind === 'battlePlan' &&
- (!g.battle ||
- ![g.battle.attacker, g.battle.defender].includes(
- target,
- ))),
+ (kind === 'freeform' && !text.trim()),
)}
>
)}
diff --git a/docs/COMPOUND_SHIPMENT_PROMISES.md b/docs/COMPOUND_SHIPMENT_PROMISES.md
index 363695d2..01b85fc1 100644
--- a/docs/COMPOUND_SHIPMENT_PROMISES.md
+++ b/docs/COMPOUND_SHIPMENT_PROMISES.md
@@ -4,6 +4,52 @@
with shipment-only AND/OR questions. Public mode and publication gates remain
unchanged.
+## Full pure-shipment authoring — 26 October 2026
+
+The pure shipment form now exposes the parser's full shipment-only tree instead
+of two flat conditions. Add siblings, wrap/nest AND/OR groups, switch their
+meaning and remove/collapse without losing other conditions. It can use all
+sixteen shipment leaves and depth four, without spending a leaf on a current
+fact. The separate Mixed builder keeps its own requirement for both kinds.
+Each leaf still describes the same eventual reserve shipment, never an extra
+shipment or permission to ship early.
+
+The existing shipment parser supplies the unchanged shape/bounds and whole-
+input validation. Internal frozen fact constants cannot be authored, including
+under a decisive OR. Numeric-invalid minima remain editable; the whole question
+must be valid before Ask publicly. Legacy single-leaf questions remain accepted,
+while the full form uses the existing `claim` expression envelope. No saved
+field, migration or new action kind is added.
+
+Current ask ownership, busy and seated-target checks apply to actual callbacks;
+`truthShipmentTargets` remains the authoritative opportunity boundary. The
+form uses public destinations and physical minima, without reading private
+holdings or recreating feasibility. The existing server supplies feasible
+answers, prevents voluntary evasion, fulfills the same physical delivery/skip
+and retains the original continuation. AND requires every condition; OR any;
+No makes the whole statement false. All four minimal profiles keep their
+existing legal path, with strategic refinement deferred.
+
+Controls: `components/shipment-expression-fields.tsx` and
+`components/truth-shipment-question.tsx`, integrated in the original panel;
+`game/shipment-expression-input.ts` reuses the existing parser for editing
+bounds. This form extension does not certify broader timing/module combinations,
+full Truthtrance or deployed acceptance.
+
+Combined battle/shipment proof passes **14 files,123/123**, types/lint/build and
+twelve native engine/GameTable SSR/actual edit-and-Ask callback paths. Six pure
+shipment routes use a genuine two-seat Basic20-action prefix, bind whole Yes/No,
+deliver the original one-force Arrakeen shipment at its one-spice cost and end
+the original turn, six post-source actions each. Nine new engine cases cover24
+flat and four nested variants, sixteen distinct destinations/depth four,
+skip/nonmatching alternatives, invalid constants/limits, original continuation
+and four minimal profiles; eleven controlled cases cover the complete form.
+All33 original cards remain. Private proof:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-remaining-authoring-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-remaining-authoring-runtime/`.
+No saved-room/D1/server or recovery operation. Native evidence is Basic;
+SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance.
+
## Mixed current facts and future shipment — 3 October 2026
The structured builder now supports bounded nested AND/OR trees containing
@@ -87,8 +133,8 @@ destinations cannot be fulfilled by a single shipment, so Yes is unavailable.
## Connected prototype
-The question form adds a second destination/count condition and an AND/OR choice.
-The shared engine expression supports grouping within software limits of sixteen leaves and four nested groups; the first visual builder exposes two conditions. Existing flat questions
+The original question form added a second destination/count condition and an AND/OR choice.
+The shared engine expression supports grouping within software limits of sixteen leaves and four nested groups; the 26 October form above now exposes that full pure expression. Existing flat questions
and saved promises retain their meaning. Public question/history text preserves
grouping, while answer feasibility and suggested preparations stay private to
the respondent.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index b8759fe2..738ab11b 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,45 @@ open mode gates.
## Current checkpoint and work
+**Remaining structured Truthtrance authoring — 26 October. Prototyped / Partial.**
+Battle-plan and pure-shipment forms now expose their full existing grouped
+inputs. Add/wrap/nest, switch AND/OR, remove/collapse and whole-field validation
+retain the entire claim. Battle uses the unchanged eight-child/sixteen-node/
+depth-four envelope; pure shipment uses all sixteen shipment leaves/depth four,
+without inserting a current fact. Existing actions, target-private feasibility,
+binding, one discard and legal plan/shipment continuation remain authoritative.
+Invalid battle-number drafts render empty beside their validation feedback.
+
+Two disjoint battle/shipment lanes and root integration pass **14 files,123/123**,
+types/lint/build and **twelve native engine/GameTable SSR/actual edit-and-Ask
+callback paths** on unchanged source. Genuine two-seat Basic Atreides/Emperor
+prefixes use24 actions before plans and20 before shipment, retaining the original
+cards/forces/wallets. Six battle routes bind whole Yes/No, reject a contradictory
+original plan and seal the valid plans. Six shipment routes bind the full claim,
+deliver one actual Arrakeen force for1 spice and end the original turn. Each
+runtime uses six continuation actions; all33 physical cards remain. Seventeen
+new engine cases cover48 flat and eight nested variants, accepted/rejected
+bounds, actual continuation and four minimal profiles. Twenty-two controlled
+cases cover edits, validation, ownership and the actual submit contract.
+
+Frozen v2 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-remaining-authoring-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-remaining-authoring-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
+Sources are Basic; SSR/callbacks do not prove DOM/hydration or browser/mobile/
+deployed acceptance. Full Truthtrance, modes/combinations and final assurance
+remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
+[shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
+
+Next concrete independent rule gap: Advanced/Homeworld Face Dance blocks an
+Emperor winner's army return. Scope exact typed normal→Kaitain and Sardaukar→
+Salusa return through the existing native reserve helpers, separately from
+revival/payment/income. The live guard and both Arrakis/Homeworld return paths
+need integration; removing its message alone misroutes elites. See
+`faceDanceReturnBlock`, `addNativeReserves`, `game/homeworld-native-reserves.ts`
+and [the replacement contract](HOMEWORLD_REPLACEMENT_RULES.md). This next scope
+does not establish Basic completion or authorize a custody/recovery campaign.
+
**Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.**
The form now exposes two-through-eight-term AND/OR groups and nesting, with
add/wrap/remove/collapse controls and a whole-question preview. Shared existing
@@ -43,14 +82,6 @@ SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance;
the native source is Basic. Complete Truthtrance/rules/combinations and final
assurance remain open. [Source and controls](TRUTHTRANCE_CARD_COUNT.md#compound-current-fact-authoring--26-october-2026).
-Next concrete Basic function gap: battle-plan Truthtrance authoring still caps
-the existing AND/OR parser at two flat conditions. Connect its full grouped
-input controls to the existing legal-plan feasibility and sealing path. See
-`game/battle-promises.ts`, `components/battle-promises.tsx`, the battle-plan
-branch of `components/truthtrance.tsx` and the genuine `beforePlans` source in
-`tests/fixture-truth-traitor-decision.ts`. Pure shipment's maximum sixteen-leaf
-form is also pending; ordinary nested shipment already has its Mixed equivalent.
-
**Distrans before the first bid/pass — 26 October. Prototyped / Partial.**
An untouched ordinary lot now allows the private card transfer before its first
bid or pass, with the same dealt pool/index/opener/order/active seat. The span is
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e68075d9..2fe40aa5 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,39 @@
# Dune implementation status
+## 26 October 2026 — Grouped battle-plan and full pure shipment authoring
+
+**Prototyped / Partial.** Deliver the remaining structured Truthtrance form
+caps together. Battle-plan authoring now exposes all existing AND/OR groups,
+eight children, sixteen total nodes and depth four; pure shipment exposes all
+sixteen shipment leaves/depth four without a current-fact term. Add/wrap/nest,
+group meaning and remove/collapse preserve the whole tree. Shared unchanged
+limits and complete parsers block invalid later fields or internal shipment
+constants. Actual callbacks enforce ask ownership, target/battle/opportunity
+and busy state. Existing answer feasibility, commitments, card disposal and
+plan/shipment permissions remain. No saved field, action kind or mode opens.
+
+Frozen **14-file123/123**, types/lint/build and **twelve native engine/GameTable
+SSR/actual edit-and-Ask callback paths** pass. Original two-seat Basic A/E sources
+use24 pre-plan or20 pre-shipment actions; original held projectile, Truthtrance,
+paid shipment and actual battle preparation earn the boundaries. Six plan paths
+bind Yes/No, reject the actual contradictory plan, then seal both legal plans.
+Six shipment paths bind Yes/No and deliver one paid Arrakeen force before ending
+the original turn. Each uses six continuation actions; all33 original cards
+remain. Seventeen native engine cases cover48 flat and eight nested variants,
+accepted/rejected limits, one discard, battle/turn continuation and four minimal
+profiles. Twenty-two controlled cases cover edit/validation/guard/payload paths.
+Feedback repaired the fixture's known Emperor leader indexing and empty rendering
+of an invalid NaN battle-number draft; a first narrow-name filter was corrected
+before the two intended source cases passed.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-remaining-authoring-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-remaining-authoring-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation. Native
+sources are Basic; DOM/hydration, browser/mobile/deployed and broader combination
+acceptance remain due. Full rules and final assurance stay open.
+[Battle contract](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
+[pure shipment contract](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
+
## 26 October 2026 — Full structured current-fact question authoring
**Prototyped / Partial.** Replace the two-flat-fact form cap with controlled
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index ca4a56c1..3b31856a 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,15 @@
# Rule decision index
+## Remaining structured Truthtrance authoring — 26 October 2026
+
+Expose the existing full grouped battle-plan and pure-shipment inputs rather
+than two-condition forms. Existing FAQ AND/OR authority and engine feasibility,
+obligation, timing and custody contracts remain. Plan's eight children/sixteen
+nodes/depth four and shipment's sixteen leaves/depth four are software input
+limits, unchanged by the controls. Pure shipment does not require a current fact
+or accept internal frozen constants. [Battle authoring](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
+[shipment authoring](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
+
## Truthtrance compound current-fact controls — 26 October 2026
Expose existing AND/OR current-fact expressions without the old two-fact form
diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
index f72fefa6..cb45debb 100644
--- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
+++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
@@ -1,5 +1,51 @@
# Truthtrance nonbattle readiness and first implementation slice
+## Grouped battle-plan authoring — 26 October 2026
+
+The existing battle-plan question now has a complete bounded expression form,
+replacing the original two-flat-condition controls. Add or wrap AND/OR groups,
+switch their meaning, add siblings/nesting and remove conditions or groups.
+Singleton groups collapse to their remaining condition without dropping siblings.
+The preview and `truthAsk` action retain the whole claim.
+
+`game/battle-claim-expression.ts` shares the parser's existing limits of eight
+children per group, sixteen total nodes and depth four with the editor. These
+are unchanged software input limits, not printed restrictions on questions.
+The existing `parsePlanClaim` validates every leaf, including later rows;
+invalid numeric, card-name or leader inputs cannot be submitted. Public printed
+identities support authoring; opponent hands, submitted plans and feasible
+answers are not read to infer a question. The server still determines available
+answers and compliant plans.
+
+The current ask owner chooses another combatant in the same live battle.
+Busy, foreign-owner and invalid-target callbacks cannot submit. Sealed-plan,
+reveal, Voice/prescience and preparation rights remain with the existing engine;
+the form creates no new timing, answer, custody or mode permission. A definite
+answer discards its original card once and preserves the whole Yes/No obligation
+through actual plan sealing. No negates the entire claim, not each clause.
+The existing minimal profiles retain their entitled legal-answer path; strategy
+and calibration remain deferred.
+
+Current controls: `components/battle-claim-expression-fields.tsx`,
+`components/truth-battle-plan-question.tsx`, integrated through the original
+Truthtrance panel. [Pure-shipment authoring](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026)
+is delivered in the same batch. The older design/source audit below remains
+historical; this follow-up does not certify full Truthtrance or modes.
+
+Combined battle/shipment proof passes **14 files,123/123**, types/lint/build and
+twelve native engine/GameTable SSR/actual edit-and-Ask callback paths. Six battle
+paths use a genuine two-seat Basic24-action pre-plan source, bind the complete
+Yes/No, reject the original contradictory projectile/empty plan and seal both
+valid plans. Eight new battle engine cases cover24 flat and four nested variants,
+limits, one discard, actual battle completion and four minimal profiles; eleven
+controlled cases cover the form. The invalid numeric draft now renders empty
+beside its existing error. All33 original cards remain. The runtime routes use
+six post-source actions each; the original sealed battle event is retained.
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-remaining-authoring-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-remaining-authoring-runtime/`.
+No saved-room/D1/server or recovery operation. Native evidence is Basic;
+SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance.
+
**Later implementation:** the Basic active-opportunity reserve-shipment slice is now implemented and verified. See [TRUTHTRANCE_SHIPMENT_PROMISES.md](TRUTHTRANCE_SHIPMENT_PROMISES.md) for its exact scope, owned Ghola/Karama/escrow feasibility, Guild-allied Fremen reserve transport, persistence, controls and full-game evidence. The design below remains a historical audit; its statements that no nonbattle promise store exists are superseded by that follow-up. Broader future-action and expansion timing work remains unfinished.
Read-only design audit, 7 September 2026. Only this document is added. Runtime, controls, tests, calibration tools, live rooms and mode gates are unchanged. References identify the inspected implementation by symbol; line numbers can move during coordinator integration.
diff --git a/game/battle-claim-expression.ts b/game/battle-claim-expression.ts
new file mode 100644
index 00000000..4f979e02
--- /dev/null
+++ b/game/battle-claim-expression.ts
@@ -0,0 +1,33 @@
+import type { PlanClaim } from './battle-promises';
+
+// Existing structured battle-question limits, shared with authoring controls.
+export const BATTLE_CLAIM_MAX_NODES = 16;
+export const BATTLE_CLAIM_MAX_DEPTH = 4;
+export const BATTLE_CLAIM_MAX_TERMS = 8;
+export const BATTLE_CLAIM_INPUT_ERROR =
+ 'Choose a valid battle-plan condition (at most 16 clauses and four grouping levels).';
+
+export function battleClaimExpressionSize(claim: PlanClaim): { nodes: number; depth: number } {
+ if (claim.kind !== 'and' && claim.kind !== 'or') return { nodes: 1, depth: 0 };
+ const children = claim.terms.map(battleClaimExpressionSize);
+ return { nodes: 1 + children.reduce((sum, child) => sum + child.nodes, 0),
+ depth: 1 + Math.max(0, ...children.map(child => child.depth)) };
+}
+
+/** Structure only; parsePlanClaim still validates every actual leaf. */
+export function battleClaimExpressionError(claim: PlanClaim): string | null {
+ const size = battleClaimExpressionSize(claim);
+ if (size.nodes > BATTLE_CLAIM_MAX_NODES || size.depth > BATTLE_CLAIM_MAX_DEPTH)
+ return BATTLE_CLAIM_INPUT_ERROR;
+ const groupError = (node: PlanClaim): string | null => {
+ if (node.kind !== 'and' && node.kind !== 'or') return null;
+ if (node.terms.length < 2 || node.terms.length > BATTLE_CLAIM_MAX_TERMS)
+ return BATTLE_CLAIM_INPUT_ERROR;
+ for (const term of node.terms) {
+ const error = groupError(term);
+ if (error) return error;
+ }
+ return null;
+ };
+ return groupError(claim);
+}
diff --git a/game/battle-promises.ts b/game/battle-promises.ts
index f18f3ada..d996dd3c 100644
--- a/game/battle-promises.ts
+++ b/game/battle-promises.ts
@@ -2,6 +2,10 @@ import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import type { Plan } from './engine';
import type { Card } from './cards';
import { CHEAP_HERO_TRAITOR } from './traitors';
+import {
+ BATTLE_CLAIM_MAX_NODES, BATTLE_CLAIM_MAX_DEPTH, BATTLE_CLAIM_MAX_TERMS,
+ BATTLE_CLAIM_INPUT_ERROR,
+} from './battle-claim-expression';
export type PlanClaim =
| { kind: 'dial' | 'support'; compare: 'eq' | 'gte' | 'lte'; value: number }
@@ -21,24 +25,24 @@ export function parsePlanClaim(
value: unknown,
leaders: string[],
depth = 0,
- budget = { remaining: 16 },
+ budget = { remaining: BATTLE_CLAIM_MAX_NODES },
): PlanClaim {
const fail = (): never => {
throw new PlanClaimError(
- 'Choose a valid battle-plan condition (at most 16 clauses and four grouping levels).',
+ BATTLE_CLAIM_INPUT_ERROR,
);
};
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
- depth > 4 ||
+ depth > BATTLE_CLAIM_MAX_DEPTH ||
budget.remaining-- <= 0
)
return fail();
const v = value as Record;
if (v.kind === 'and' || v.kind === 'or') {
- if (!Array.isArray(v.terms) || v.terms.length < 2 || v.terms.length > 8)
+ if (!Array.isArray(v.terms) || v.terms.length < 2 || v.terms.length > BATTLE_CLAIM_MAX_TERMS)
return fail();
return {
kind: v.kind,
diff --git a/game/reference.ts b/game/reference.ts
index 68e6fbdf..e34e33d3 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3220,6 +3220,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.',
steps: [
'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic and Advanced rules with selected native faction decks allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Optional configurations keep their separate boundaries.',
+ 'The pure shipment builder supports up to sixteen shipment conditions, nested AND/OR groups and four grouping levels. Add, wrap, change group meaning or remove a condition; a singleton group collapses without losing its remaining statement. These app input limits do not add printed rules. Every condition describes the same eventual reserve shipment; a current fact is unnecessary.',
'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.',
'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.',
'Accepted whole answers are public and constrain the event together. A new answer reopens your Charity or Revival readiness when needed. Claim or revive before marking Ready if that is your last legal route; bids, gifts and paid revivals cannot spend it away. Honor a required shipment before ground movement or finishing. An unresolved foreign bid and its income responses finish before release. A completed opposing effect that makes compliance impossible releases it publicly.',
@@ -3241,7 +3242,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Player controls',
status: 'Implemented',
detail:
- 'Single shipment questions and complete bounded nested mixed authoring, grouped public preview, target-private feasible answers, original-question promise text and optional private next steps.',
+ 'Full pure shipment AND/OR authoring now exposes sixteen shipment leaves and nested groups without adding a current fact. Add/wrap/remove/collapse, whole-input validation and exact public submission retain target-private feasibility and original-question promise text. The separate Mixed builder remains.',
},
{
area: 'AI',
@@ -3265,6 +3266,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/shipment-promises-bots.test.ts',
'tests/shipment-promises-recovery.test.ts',
'tests/compound-shipment-promises.test.ts',
+ 'game/shipment-expression-input.ts', 'components/shipment-expression-fields.tsx', 'components/truth-shipment-question.tsx',
+ 'tests/truth-shipment-expression-controls.test.ts', 'tests/truth-shipment-expression-engine.test.ts', 'tests/fixture-truth-shipment-expression.ts',
'tests/compound-shipment-bots.test.ts',
'tests/compound-shipment-controls.test.tsx',
'tests/compound-shipment-recovery.test.ts',
@@ -3293,6 +3296,32 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
+ {
+ id: 'truthtrance-plan-expression',
+ title: 'Truthtrance: grouped battle-plan questions',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Build a whole AND/OR battle-plan claim with multiple conditions and nested groups.',
+ steps: [
+ 'Choose Bind a battle-plan choice during a live battle, then another combatant. Edit dial, support, weapon/defense slots, leader or hero, and Kwisatz conditions. Group with AND or OR, add siblings or nested groups, and review the complete public claim.',
+ 'AND requires every branch; OR at least one. No requires the whole claim to be false, not every condition separately. Removing a condition preserves its siblings, and a singleton group collapses to its remaining condition.',
+ 'The builder shares the existing app limits: eight children per group, sixteen total items including groups and four grouping levels. These are input limits. Invalid fields anywhere block Ask publicly, without inspecting the opponent’s private cards or plan.',
+ 'The server supplies feasible answers and compliant preparations/plans. A definite answer discards the original Truthtrance once and constrains the actual plan while possible. Existing Voice, prescience, sealing, reveal and preparation rules remain; the question does not grant a new plan or timing permission.',
+ ],
+ example: 'Yes to dial exactly zero AND an empty weapon slot AND an empty defense slot requires all three. No permits a legal plan that makes at least one of them false.',
+ related: ['card-truthtrance', 'battle', 'truthtrance-fact-expression', 'truthtrance-shipment'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Existing parser, whole-claim feasibility, binding and sealing remain authoritative, with their unchanged input limits shared by the editor.' },
+ { area: 'Player controls', status: 'Partial', detail: 'Full bounded add/wrap/remove/collapse/group switching, whole-leaf validation, readable preview and exact guarded public submission are integrated.' },
+ { area: 'AI', status: 'Partial', detail: 'All four minimal profiles keep their entitled feasible-answer and compliant-plan paths. Strategic question selection and calibration remain deferred.' },
+ { area: 'Documentation', status: 'Partial', detail: 'Explains whole Yes/No meaning, preserved timing/plan permissions and software limits separately from printed rules.' },
+ { area: 'Verification', status: 'Partial', detail: 'Controlled authoring and genuine native battle question/answer/plan paths cover the form. Full combinations, browser/mobile/deployed and final assurance remain due.', evidence: [
+ 'game/battle-promises.ts', 'game/battle-claim-expression.ts', 'components/battle-claim-expression-fields.tsx', 'components/truth-battle-plan-question.tsx',
+ 'tests/truth-battle-plan-controls.test.ts', 'tests/fixture-truth-plan-expression.ts', 'tests/truth-plan-expression-engine.test.ts', 'docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md',
+ ] },
+ ],
+ },
{
id: 'card-truthtrance',
title: 'Truthtrance',
@@ -3310,7 +3339,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
- 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
+ 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
'Holding both copies lets you declare one or both. Each question resolves separately, and each card is discarded only after its definite answer. Full timing and card-face audits remain pending.',
@@ -3323,6 +3352,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-card-count',
'truthtrance-hand-inventory',
'truthtrance-fact-expression',
+ 'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'richese-no-field',
diff --git a/game/shipment-expression-input.ts b/game/shipment-expression-input.ts
new file mode 100644
index 00000000..495faaf5
--- /dev/null
+++ b/game/shipment-expression-input.ts
@@ -0,0 +1,36 @@
+import {
+ parseShipmentExpression,
+ ShipmentClaimError,
+ type ShipmentExpression,
+} from './shipment-promises';
+
+/** Shipment limits count leaves, rather than counting the containing groups. */
+export function shipmentExpressionInputSize(value: ShipmentExpression): { leaves: number; depth: number } {
+ if (!('op' in value)) return { leaves: 1, depth: 0 };
+ const children = value.terms.map(shipmentExpressionInputSize);
+ return {
+ leaves: children.reduce((sum, child) => sum + child.leaves, 0),
+ depth: 1 + Math.max(0, ...children.map(child => child.depth)),
+ };
+}
+
+/** Use the existing parser's structure and bounds while unfinished leaf fields
+ * remain editable. Frozen current facts are never valid pure-shipment input. */
+export function shipmentExpressionInputError(value: ShipmentExpression): string | null {
+ const structure = (node: unknown): unknown => {
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
+ if ('op' in node) return {
+ ...node,
+ ...('terms' in node && Array.isArray(node.terms) ? { terms: node.terms.map(structure) } : {}),
+ };
+ if ('constant' in node) return node;
+ return { ...node, territory: 'arrakeen', minimum: 1 };
+ };
+ try {
+ parseShipmentExpression(structure(value), { allowConstants: false });
+ return null;
+ } catch (error) {
+ if (error instanceof ShipmentClaimError) return error.message;
+ throw error;
+ }
+}
diff --git a/tests/fixture-truth-plan-expression.ts b/tests/fixture-truth-plan-expression.ts
new file mode 100644
index 00000000..8ac6179a
--- /dev/null
+++ b/tests/fixture-truth-plan-expression.ts
@@ -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 = {
+ 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;
+
+/** 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 };
+}
diff --git a/tests/fixture-truth-shipment-expression.ts b/tests/fixture-truth-shipment-expression.ts
new file mode 100644
index 00000000..bbf8362c
--- /dev/null
+++ b/tests/fixture-truth-shipment-expression.ts
@@ -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 = {
+ 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;
+
+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 };
+}
diff --git a/tests/truth-battle-plan-controls.test.ts b/tests/truth-battle-plan-controls.test.ts
new file mode 100644
index 00000000..1eec3ab6
--- /dev/null
+++ b/tests/truth-battle-plan-controls.test.ts
@@ -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;
+ 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;
+const questionFor = (claim: PlanClaim, target = 't', territory = 'arrakeen'): Question => ({ kind: 'battlePlan', target, territory, claim });
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(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).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(), /