diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx
index a197fb40..de448059 100644
--- a/components/battle-promises.tsx
+++ b/components/battle-promises.tsx
@@ -123,19 +123,18 @@ export function BattleClaimFields({
>
);
}
-export function BattlePromises({
- game: g,
- fill,
- act,
- busy,
-}: {
+export function BattlePromises(props: {
game: GameView;
fill: (plan: Plan) => void;
act: (action: Action) => void;
busy: boolean;
}) {
+ const { game: g, fill, act } = props;
const b = g.battle;
- if (!b || (!b.truthPromises.length && !b.compliantPreparation)) return null;
+ if (!b) return null;
+ const mixedRows = (g.mixedBattlePromises ?? []).filter(promise => promise.player === g.me && promise.turn === g.turn &&
+ promise.battle === b.event && promise.territory === b.territory && !promise.fulfilled && !promise.released);
+ if (!b.truthPromises.length && !b.compliantPreparation && (!b.compliantPlan || !mixedRows.length)) return null;
const me = g.players.find((p) => p.id === g.me)!;
const name = (id: string | null) =>
id === null
@@ -144,6 +143,39 @@ export function BattlePromises({
g.allLeaders.find((l) => l.id === id)?.name ??
id);
const plan = b.compliantPlan;
+ const preparation = b.compliantPreparation;
+ const nextPreparation = preparation?.actions[0];
+ const actor = g.me, turn = g.turn, event = b.event, territory = b.territory;
+ const attacker = b.attacker, defender = b.defender;
+ const mixed = mixedRows
+ .map(promise => ({ promise, event: promise.event, asker: promise.asker, answer: promise.answer,
+ mixed: promise.mixed, signature: JSON.stringify(promise.mixed) }));
+ const mixedAssistance = mixed.length > 0;
+ const planSignature = JSON.stringify(plan), preparationSignature = JSON.stringify(preparation);
+ // Native completion owns feasibility. For mixed assistance, a saved callback
+ // also has to belong to this exact live question and current private guide.
+ const canUseMixedAssistance = () => !mixedAssistance || (
+ !props.busy && props.game === g && g.status === 'playing' && g.phase === 6 && g.me === actor && g.turn === turn &&
+ g.battle === b && typeof event === 'string' && b.event === event && b.territory === territory &&
+ b.attacker === attacker && b.defender === defender && [attacker, defender].includes(actor) &&
+ !b.revealed && !b.submitted.includes(actor) && mixed.length > 0 &&
+ mixed.every(context => g.mixedBattlePromises.includes(context.promise) && context.promise.event === context.event &&
+ context.promise.asker === context.asker && context.promise.answer === context.answer &&
+ context.promise.mixed === context.mixed && JSON.stringify(context.promise.mixed) === context.signature &&
+ context.promise.player === actor &&
+ context.promise.turn === turn && context.promise.battle === event && context.promise.territory === territory &&
+ !context.promise.fulfilled && !context.promise.released) &&
+ g.mixedBattlePromises.filter(promise => promise.player === actor && promise.turn === turn &&
+ promise.battle === event && promise.territory === territory && !promise.fulfilled && !promise.released).length === mixed.length &&
+ !g.truthtrance && !g.response && !g.decision && !g.phaseOpening && !g.automaticContinuationPending &&
+ !g.roomControl?.paused && !g.roomControl?.closed && !!me &&
+ g.players.find(player => player.id === actor) === me && !me.autopilot
+ );
+ const canPrepare = () => !props.busy && !g.truthtrance && !g.response && !g.decision && !g.phaseOpening &&
+ canUseMixedAssistance() && (!mixedAssistance || b.compliantPreparation === preparation &&
+ b.compliantPreparation?.actions[0] === nextPreparation && JSON.stringify(preparation) === preparationSignature && props.act === act);
+ const canFill = () => !props.busy && canUseMixedAssistance() && (!mixedAssistance ||
+ b.compliantPlan === plan && !b.compliantPreparation && JSON.stringify(plan) === planSignature && props.fill === fill);
return (
- {b.compliantPreparation.actions[0] && (
+ {b.compliantPreparation.actions[0] && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
act(b.compliantPreparation!.actions[0])}
+ disabled={!canPrepare()}
+ onClick={() => { if (canPrepare() && nextPreparation) act(nextPreparation); }}
>
Complete next preparation
)}
)}
- {plan && (
+ {plan && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
Review a compliant plan · private
@@ -240,7 +266,7 @@ export function BattlePromises({
These choices satisfy your current commitments. Review and change
the other elements before sealing your plan.
- fill(plan)}>
+ { if (canFill()) fill(plan); }}>
Fill these plan choices
diff --git a/components/game-table.tsx b/components/game-table.tsx
index f2e1b46f..6f233b63 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1,4 +1,5 @@
'use client';
+import { MixedBattlePromises } from './mixed-battle-promises';
import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises';
import { MovementExpressionPromises } from './movement-expression-promises';
@@ -2058,6 +2059,7 @@ export function GameTable({
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
+ {g.status === 'playing' && }
{g.guildBetrayalReaction ? (
) : g.richeseBetrayalReaction ? (
diff --git a/components/mixed-action-fields.tsx b/components/mixed-action-fields.tsx
index abd4ae21..835cc4ec 100644
--- a/components/mixed-action-fields.tsx
+++ b/components/mixed-action-fields.tsx
@@ -2,7 +2,7 @@
import type { ReactElement, ReactNode } from 'react';
import type { GameView } from '@/game/engine';
-import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
+import { mixedActionExpressionSize, parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
import type { TruthFact } from '@/game/truthtrance';
import { truthFactExpressionError } from '@/game/truth-fact-expression';
import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields';
@@ -17,6 +17,8 @@ export type MixedActionConfig = {
fallbackError: string;
maxLeaves: number;
maxDepth: number;
+ maxNodes?: number;
+ maxTerms?: number;
newAction: () => A;
parseAction: (value: unknown, game: GameView, target: string) => A;
renderAction: (props: { value: A; id: string; game: GameView; target: string;
@@ -25,21 +27,14 @@ export type MixedActionConfig = {
const newFact = (): MixedActionExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
-function factSize(fact: TruthFact): { leaves: number; depth: number } {
- if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
- const sizes = fact.terms.map(factSize);
- return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
-}
-function sizeOf (node: MixedActionExpression ): { leaves: number; depth: number } {
- if (node.kind === 'fact') return factSize(node.fact);
- if (node.kind === 'action') return { leaves: 1, depth: 0 };
- const sizes = node.terms.map(sizeOf);
- return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
-}
function structuralError (node: MixedActionExpression , config: MixedActionConfig ): string | null {
- const size = sizeOf(node);
+ const size = mixedActionExpressionSize(node);
if (size.leaves > config.maxLeaves || size.depth > config.maxDepth)
return 'Use at most sixteen conditions and four levels of grouping.';
+ if (config.maxNodes !== undefined && size.nodes > config.maxNodes)
+ return `Use at most ${config.maxNodes} total clauses, including groups.`;
+ if (config.maxTerms !== undefined && size.maxTerms > config.maxTerms)
+ return `Use at most ${config.maxTerms} conditions in each AND/OR group.`;
if (node.kind === 'fact') return truthFactExpressionError(node.fact);
if (node.kind === 'action') return null;
if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.';
@@ -60,7 +55,9 @@ export function mixedActionInputError (value: unknown, game: GameView, target:
if (error) throw new Error(error);
if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.');
return fact;
- }, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves });
+ }, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves,
+ ...(config.maxNodes === undefined ? {} : { maxNodes: config.maxNodes }),
+ ...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }) });
return null;
} catch (error) {
return error instanceof Error ? error.message : config.fallbackError;
@@ -79,7 +76,7 @@ export function MixedActionFields ({ value, game, target, disabled, onChange,
}) {
const newAction = (): MixedActionExpression => ({ kind: 'action', action: config.newAction() });
const attrs = (suffix: string, value: string) => ({ [`data-${config.prefix}-${suffix}`]: value });
- const size = sizeOf(value), error = mixedActionInputError(value, game, target, config);
+ const size = mixedActionExpressionSize(value), error = mixedActionInputError(value, game, target, config);
const commit = (next: MixedActionExpression ) => {
if (!disabled && !structuralError(next, config)) onChange(next);
};
@@ -171,9 +168,11 @@ export function MixedActionFields ({ value, game, target, disabled, onChange,
AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition.
Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.
{config.actionHelp}
- App input limits: {config.maxLeaves} conditions and {config.maxDepth} levels of grouping.
+
App input limits: {config.maxNodes === undefined ? `${config.maxLeaves} conditions` : `${config.maxNodes} total clauses, including groups`}
+ {config.maxTerms !== undefined && `, ${config.maxTerms} conditions per group`} and {config.maxDepth} levels of grouping.
These are input limits, not additional printed rules.
- {size.leaves} / {config.maxLeaves} conditions · {size.depth} / {config.maxDepth} levels of grouping
+ {config.maxNodes === undefined ? `${size.leaves} / ${config.maxLeaves} conditions`
+ : `${size.nodes} / ${config.maxNodes} total clauses`} · {size.depth} / {config.maxDepth} levels of grouping
{error && {error}
}
{render(value, '1', next => next)}
;
diff --git a/components/mixed-battle-fields.tsx b/components/mixed-battle-fields.tsx
new file mode 100644
index 00000000..d54ec2f1
--- /dev/null
+++ b/components/mixed-battle-fields.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import type { GameView } from '@/game/engine';
+import type { MixedActionExpression } from '@/game/mixed-action-question';
+import {
+ MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS,
+ parseBattleActionClaim, type MixedBattleActionClaim,
+} from '@/game/mixed-battle-question';
+import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
+import { Input } from './ui/input';
+
+const dial = (): MixedBattleActionClaim => ({ kind: 'dial', compare: 'eq', value: 0 });
+
+/** The original plan-element vocabulary is public. Authoring a question never
+ * inspects either player's cards, available leaders, forces or ability to pay. */
+const config: MixedActionConfig = {
+ prefix: 'mixed-battle',
+ legend: 'Mixed current-fact / actual battle-plan claim',
+ actionLabel: 'battle-plan condition',
+ actionTiming: 'Actual battle plan · this battle',
+ actionHelp: <>Battle conditions concern the respondent’s actual plan in this battle, including a plan already fixed, sealed or revealed.
+ Current facts are snapshotted at the answer. An unknown current fact uses the conservative plan commitment that guarantees the whole answer;
+ if no such answer is possible, the answer is I don’t know. No alternate route is forced.
+ Native Voice, Prescience, card, leader, force and payment rules still apply.>,
+ fallbackError: 'Choose a current fact and an actual battle-plan condition.',
+ maxLeaves: MIXED_BATTLE_MAX_LEAVES,
+ maxDepth: MIXED_BATTLE_MAX_DEPTH,
+ maxNodes: MIXED_BATTLE_MAX_NODES,
+ maxTerms: MIXED_BATTLE_MAX_TERMS,
+ newAction: dial,
+ parseAction: (value, game) => parseBattleActionClaim(value, game.allLeaders.map(leader => leader.id)),
+ renderAction: ({ value, id, game, disabled, onChange }) => <>
+ Plan element
+ {
+ const kind = event.currentTarget.value;
+ if (kind === 'dial' || kind === 'support') onChange({ kind, compare: 'eq', value: 0 });
+ else if (kind === 'weapon' || kind === 'defense') onChange({ kind, name: null });
+ else if (kind === 'leader') onChange({ kind, leader: game.allLeaders[0]?.id ?? null });
+ else if (kind === 'kwisatz') onChange({ kind, use: true });
+ }}>
+ Dial
+ Leader or hero
+ Weapon slot
+ Defense slot
+ Spice support
+ Kwisatz Haderach
+
+ {(value.kind === 'dial' || value.kind === 'support') && <>
+ Comparison
+ {
+ const compare = event.currentTarget.value;
+ if (compare === 'eq' || compare === 'gte' || compare === 'lte') onChange({ ...value, compare });
+ }}>
+ Exactly
+ At least
+ At most
+
+ Amount
+ 40 ||
+ !Number.isInteger(value.value * (value.kind === 'dial' ? 2 : 1))}
+ onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} />
+ >}
+ {(value.kind === 'weapon' || value.kind === 'defense') && <>
+ Card name
+ onChange({ ...value, name: event.currentTarget.value || null })}>
+ Empty slot
+ {TRUTH_CARD_NAMES.map(name => {name} )}
+
+ >}
+ {value.kind === 'leader' && <>
+ Leader
+ leader.id === value.leader)}
+ onChange={event => onChange({ ...value, leader: event.currentTarget.value || null })}>
+ No leader or hero
+ {game.allLeaders.map(leader => {leader.name} )}
+ Cheap Hero / Heroine
+
+ >}
+ {value.kind === 'kwisatz' && <>
+ Kwisatz use
+ onChange({ ...value, use: event.currentTarget.value === 'yes' })}>
+ Included
+ Not included
+
+ >}
+ >,
+};
+
+export function mixedBattleInputError(value: unknown, game: GameView, target: string): string | null {
+ return mixedActionInputError(value, game, target, config);
+}
+
+export function MixedBattleFields(props: {
+ value: MixedActionExpression;
+ game: GameView;
+ target: string;
+ disabled: boolean;
+ onChange: (expression: MixedActionExpression) => void;
+}) {
+ return MixedActionFields({ ...props, config });
+}
diff --git a/components/mixed-battle-promises.tsx b/components/mixed-battle-promises.tsx
new file mode 100644
index 00000000..0788b046
--- /dev/null
+++ b/components/mixed-battle-promises.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import type { GameView } from '@/game/engine';
+import { truthQuestionText } from '@/game/truthtrance';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+
+/** The original question and whole answer remain visible after this battle.
+ * Assistance belongs to the native battle panel; facts and residuals stay private. */
+export function MixedBattlePromises({ game }: { game: GameView }) {
+ const own = game.mixedBattlePromises.filter(promise => promise.player === game.me);
+ if (!own.length) return null;
+ const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
+ : game.allLeaders.find(leader => leader.id === id)?.name ?? id;
+ return
+ Your mixed battle promises
+ {own.map(promise =>
+ Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedBattlePlan',
+ target: promise.player, battle: promise.battle, territory: promise.territory, mixed: promise.mixed }, leaderName,
+ id => game.combatLocations.find(location => location.id === id)?.name ?? id,
+ id => game.players.find(player => player.id === id)?.name ?? id)}
+ {promise.released ? ' Released: this whole commitment is no longer possible.'
+ : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this battle.'}
+ )}
+ Battle conditions concern your actual plan in the named battle, including an already fixed, sealed or revealed plan.
+ Current facts are snapshotted at the answer. They create no promise to keep cards, spice or forces afterward.
+ Only the aggregate answer is shared. Individual fact answers and any reduced plan commitment remain private.
+ AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
+ An unknown current fact uses the conservative plan commitment that guarantees the whole answer;
+ if no such answer is possible, the answer is I don’t know. No alternate route is forced.
+ An open commitment completes when both plans are revealed; sealing your own plan does not finish it.
+ Native Voice, Prescience, card, leader, force and payment rules still apply.
+ {' '}Mixed battle rules .
+ ;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 2b2ebb4d..8e068001 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -1,5 +1,8 @@
'use client';
+import { MixedBattleFields, mixedBattleInputError } from './mixed-battle-fields';
+import type { MixedBattleActionClaim } from '@/game/mixed-battle-question';
+
import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields';
import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields';
import { MixedBidFields, mixedBidInputError } from './mixed-bid-fields';
@@ -83,7 +86,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
- 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance'
+ 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -150,6 +153,11 @@ export function Truthtrance({
{ kind: 'action', action: { minimum: 4 } },
] });
const mixedBidError = mixedBidInputError(mixedBid, g, target);
+ const [mixedBattle, setMixedBattle] = useState>({ kind: 'and', terms: [
+ { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
+ { kind: 'action', action: { kind: 'dial', compare: 'eq', value: 0 } },
+ ] });
+ const mixedBattleError = mixedBattleInputError(mixedBattle, g, target);
const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState({
kind: 'and',
@@ -185,6 +193,7 @@ export function Truthtrance({
territory: g.battle?.territory ?? '',
claim: planClaim,
}
+ : kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
@@ -308,6 +317,13 @@ export function Truthtrance({
))}
>
+ ) : w.question!.kind === 'mixedBattlePlan' ? (
+ <>
+ Answer the complete current-fact and battle-plan question. Current facts freeze now; any remaining plan conditions use your native legal plans and preparation. No negates the whole expression. A binding open plan settles only when the plans are actually revealed; a plan already submitted is fixed. Voice, Prescience, card eligibility and normal costs still apply.
+ {(g.truthMixedBattleAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'mixedBid' ? (
<>
Answer the whole mixed question. Current facts freeze now; any remaining bid conditions concern future accepted declarations in this ordinary lot. No keeps the whole expression false. Passing, funding, Karama and the original winning payment still follow the auction rules. An unknown fact requires a commitment that guarantees the whole answer; otherwise choose I don’t know.
@@ -538,6 +554,7 @@ export function Truthtrance({
Combine current facts and this-turn shipment
Write a game question
+ {g.battle && Combine current facts and this battle plan }
{g.battle && (
Bind a battle-plan choice
@@ -545,7 +562,14 @@ export function Truthtrance({
)}
- {kind === 'allianceExpression' ? (
+ {kind === 'mixedBattlePlan' ? (
+ <>
+
+ {!g.truthMixedBattleTargets.includes(target) && Choose another combatant in the current battle.
}
+ {mixedBattleError ?? truthQuestionText(question, leaderName, locationName, name)}
+ >
+ ) : kind === 'allianceExpression' ? (
<>
@@ -761,6 +785,7 @@ export function Truthtrance({
(kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) ||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
+ (kind === 'mixedBattlePlan' && (!!mixedBattleError || !g.truthMixedBattleTargets.includes(target))) ||
(kind === 'allianceExpression' && (!!allianceExpressionError || !g.truthAllianceExpressionTargets.includes(target))) ||
(kind === 'mixedAlliance' && (!!mixedAllianceError || !g.truthMixedAllianceTargets.includes(target))) ||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c8161dca..3c410a6e 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,42 @@ open mode gates.
## Current checkpoint and work
+**Truthtrance / current facts and current battle plans — 26 October. Prototyped / Partial.**
+Full mixed AND/OR questions freeze current facts and bind only a needed native
+PlanClaim residual. Native dial/support/cards/leader/KH, prior promises, Voice/
+Prescience/preparation/funding remain. Constants and already fixed submitted/
+revealed plans are terminal; open aggregates stay pending through solver/Fill/
+own seal, completing only at actual reveal before synchronous cleanup. Own
+raw history survives the battle without exposing residuals to any public pure
+ledger. Shared editor native16-node/8-child/depth4 limits leave other families'
+defaults unchanged. Exact native assistance, Ask/private answers and four minimal
+profiles connect. Legacy identity initializes only in actual Ask, preserving all
+physical choices; no passive-view migration occurs.
+
+Root integration with disjoint mapping/helper/control/native lanes passes
+**18 files,173/173**, types/lint/build and **nine native paths**,27 GameTable SSR
+renders/five actual Fill callbacks plus one editor callback followed by native
+Ask. Original two-seat Basic base33 beforePlans24 prefixes add8 for actual
+seals/reveal/cleanup, edited true OR/no binding, false AND/real projectile,
+unknown Yes/projectile or No/unarmed, and already sealed/revealed fixed answers.
+A24-prefix adds9 for actual bribe3 E9→6; reveal fulfills then original killed-
+leader1 income ends E7. Original beforePreparation23 adds10 for actual ordinary
+Prescience/weapon answer/sealing. All33 original cards remain physical. Four
+profiles actually answer and seal the private native fallback.
+
+Six strict helper/three shared-budget,17 runtime/funding and nine controlled cases
+supplement existing plan/preparation/fact/other mixed controls. Ordinary private
+preparation without Truthtrance records stays usable. Narrow integration and
+v1 type repairs precede v2's funding expectation repair: original Dr. Yueh's
+actual death earns strength1, preserving native rewards. Complete v3 passes.
+Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-battle-proof-v3/`;
+journeys/HTML: adjacent `20261026-truth-mixed-battle-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Mixed Traitor/other families, correlated unknowns, wider Advanced/card/Voice/
+Ghola/preparation compositions and final rules/browser/deployed/assurance remain.
+[Authority, native plan interval/budgets and evidence](TRUTHTRANCE_MIXED_BATTLE.md).
+
**Truthtrance / whole and mixed final Nexus alliances — 26 October. Prototyped / Partial.**
Full AND/OR partner trees and mixed current facts now retain every compatible
native consent route. Finite final outcomes reuse the original reciprocal
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 3e75d699..553331e1 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,44 @@
# Dune implementation status
+## 26 October 2026 — Current facts with current battle-plan conditions
+
+**Prototyped / Partial.**
+Full mixed AND/OR questions freeze current facts and bind only a needed native
+PlanClaim residual. Native dial/support/cards/leader/KH, prior promises, Voice/
+Prescience/preparation/funding remain. Constants and already fixed submitted/
+revealed plans are terminal; open aggregates stay pending through solver/Fill/
+own seal, completing only at actual reveal before synchronous cleanup. Own
+raw history survives the battle without exposing residuals to any public pure
+ledger. Shared editor native16-node/8-child/depth4 limits leave other families'
+defaults unchanged. Exact native assistance, Ask/private answers and four minimal
+profiles connect. Legacy identity initializes only in actual Ask, preserving all
+physical choices; no passive-view migration occurs.
+
+Root integration with disjoint mapping/helper/control/native lanes passes
+**18 files,173/173**, types/lint/build and **nine native paths**,27 GameTable SSR
+renders/five actual Fill callbacks plus one editor callback followed by native
+Ask. Original two-seat Basic base33 beforePlans24 prefixes add8 for actual
+seals/reveal/cleanup, edited true OR/no binding, false AND/real projectile,
+unknown Yes/projectile or No/unarmed, and already sealed/revealed fixed answers.
+A24-prefix adds9 for actual bribe3 E9→6; reveal fulfills then original killed-
+leader1 income ends E7. Original beforePreparation23 adds10 for actual ordinary
+Prescience/weapon answer/sealing. All33 original cards remain physical. Four
+profiles actually answer and seal the private native fallback.
+
+Six strict helper/three shared-budget,17 runtime/funding and nine controlled cases
+supplement existing plan/preparation/fact/other mixed controls. Ordinary private
+preparation without Truthtrance records stays usable. Narrow integration and
+v1 type repairs precede v2's funding expectation repair: original Dr. Yueh's
+actual death earns strength1, preserving native rewards. Complete v3 passes.
+Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-battle-proof-v3/`;
+journeys/HTML: adjacent `20261026-truth-mixed-battle-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Mixed Traitor/other families, correlated unknowns, wider Advanced/card/Voice/
+Ghola/preparation compositions and final rules/browser/deployed/assurance remain.
+[Authority, native plan interval/budgets and evidence](TRUTHTRANCE_MIXED_BATTLE.md).
+
+
## 26 October 2026 — Whole and mixed final Nexus alliance partners
**Prototyped / Partial.**
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 2f5c9cfc..d7c8817a 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,22 @@
# Rule decision index
+## Current facts combined with current battle plans — 26 October 2026
+
+Compose recorded publisher FAQp8 AND/OR/current-turn authority and supplied
+Advanced physical39 compliance/release/unknown with existing native battle-plan
+questions. Facts freeze before future preparation/payment/card changes. Unknown
+is false for definite Yes and true for No; a necessary residual enters the
+original BattlePromise/plan solver, preserving Voice/Prescience/inspections and
+funding. Constants and already fixed plans are terminal; open aggregates wait
+for actual reveal, never solver previews or just their own sealing. Raw mixed
+clauses/aggregate hide frozen values and residuals from all public pure rows.
+Native16-clause/8-child/depth4 input limits span the complete mixed tree;
+other mixed families keep their defaults. An older eventless battle receives
+only additive source identity during actual Ask, never passive reading.
+Snapshot/witness/release is provisional online composition, not a dedicated
+publisher solver or correlated-unknown inference. No mode/publication change.
+[Contract and evidence](TRUTHTRANCE_MIXED_BATTLE.md).
+
## Whole and mixed final Nexus alliances — 26 October 2026
Compose recorded publisher FAQp8 AND/OR/current-turn authority and supplied
diff --git a/docs/TRUTHTRANCE_MIXED_BATTLE.md b/docs/TRUTHTRANCE_MIXED_BATTLE.md
new file mode 100644
index 00000000..a1674292
--- /dev/null
+++ b/docs/TRUTHTRANCE_MIXED_BATTLE.md
@@ -0,0 +1,154 @@
+# Truthtrance current facts and current battle plans
+
+## Mixed aggregate prototype — 26 October 2026
+
+**Prototyped / Partial.** The packet is
+`{kind:'mixedBattlePlan',target,battle,territory,mixed}`. Generic fact/action/AND/OR
+clauses use scalar native PlanClaim leaves: dial, support, weapon, defense,
+leader/hero and Kwisatz. No action embeds its own group. All groups share one
+raw logical budget. The current battle/territory and another actual combatant
+are mandatory; original pure questions and game-start gates remain unchanged.
+
+## Authority and meaning
+
+Reuse recorded publisher November2020 FAQp8 AND/OR/current-turn authority,
+supplied Advanced physical39/printed37 compliance/release/unknown and existing
+[native battle-plan authoring](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026).
+The shared snapshot/unknown and online owned-witness interpretation remain
+provisional under standing14October priority. No dedicated publisher solver,
+correlated unknown inference or complete current-turn action language is claimed.
+
+All current facts freeze at the actual answer, before future payments,
+preparations and card changes. A real later bribe can change spice without
+changing the original fact. No negates the entire mixed expression. Unknown
+uses false for definite Yes and true for No, then the native plan obligation
+must guarantee the aggregate. If neither guaranteed completion is known,
+unknown/save/change-question remains available.
+
+True fact OR plan can settle Yes immediately; false fact AND plan No immediately.
+Constants need no native binding. Otherwise the remaining tree enters the
+existing BattlePromise ledger. Native numeric/half-dial, support, empty slots,
+card names, leader/Cheap Hero and Kwisatz conditions retain original meanings.
+The claim concerns selected plans, not whether a weapon effect later succeeds.
+
+The original pure question allows an already submitted or revealed respondent.
+Those choices are fixed; mixed answers against them are terminal immediately
+and add no new residual. A newly binding open aggregate stays pending during
+solver/preparation/Fill previews and after only its own seal. It fulfills at
+the sole actual native global plan reveal, before counterdecisions or synchronous
+battle cleanup. Later native residual release or an ended/replaced unrevealed
+source releases the pending aggregate. Facts are never reevaluated at reveal.
+
+## Shared parser and original solver
+
+`game/mixed-battle-question.ts` strictly checks one native leaf with exact keys
+through `parsePlanClaim`; it cannot hide an action group or source metadata.
+`game/mixed-action-question.ts` adds optional maxNodes/maxTerms and one shared
+logical size helper. The full raw battle tree counts16 clauses including all
+outer and nested fact groups,8 children per group and depth4. Fact wrappers count
+their actual logical subtree once. Other mixed families retain their original
+16-leaf/depth4 limits and broader group arity when no new cap is selected.
+These are software input limits, not extra printed game restrictions.
+
+Every fact is snapshotted before compiler folding. Only an action residual
+uses native `battleTruthAnswers`/`bindBattleTruth`, `findReachableBattlePlan` and
+`validatePlan`. Voice, ordinary/full Prescience, inspections, actual owned card/
+Ghola/Karama/income preparation, prior promises and financial rules stay there.
+No second battle or payment solver is added. A new optional BattlePromise event
+links only mixed residuals; old pure record shapes stay.
+
+Raw `mixedBattleQuestions` retains the original question, public battle source,
+answer and terminal status. Every viewer's public battle.truthPromises filters
+mixed event IDs, even after reveal; native completion and authoritative plan
+validation retain the full private ledger. Own mixed projection exposes raw
+question/aggregate flags without binding metadata, compiled claims or frozen
+fact answers. Persistent status survives battle cleanup. Minimal bots prioritize
+the existing private compliant fallback for a live mixed question; they never
+reconstruct hidden clauses or gain strategic/private information.
+
+New native battles already have a public UUID. Older eventless current battles
+accept a null source only while that exact source is still missing; a successful
+explicit Ask atomically assigns one identity and stores the canonical question.
+Passive projection does not initialize it. Failed actions remain detached; no
+clock, purse, card, force, plan or existing answer is reset or converted.
+New mixed discard signatures include raw metadata and the already signed native
+battle/residual; old capture branches/signatures remain unchanged.
+
+## Human controls
+
+The full shared editor supports add/wrap/nest/switch/remove/collapse, nested
+facts and repairable native scalar drafts with unique IDs. Public leader/card
+choices do not inspect private hands or affordability. Raw preview/Ask and
+respondent-only answer choices connect through Truthtrance and the table.
+
+The own aggregate panel is status-only. Existing BattlePromises supplies one
+native private preparation/Fill surface, including mixed-only immediate plans.
+Fill changes a reviewable draft and never seals automatically. Mixed callbacks
+retain actor/turn/battle/participants/raw-question/guide identity and quiet/busy/
+room guards without reading residuals. Already sealed/revealed/terminal mixed
+contexts offer no Fill. Ordinary private preparation without any Truthtrance
+records keeps its existing behavior. Other legal native plans remain available.
+
+## Evidence and remaining gates
+
+Helpers: `tests/mixed-battle-question.test.ts`, `tests/mixed-action-question.test.ts`.
+Original source/journeys: `tests/fixture-truth-mixed-battle.ts`,
+`tests/truth-mixed-battle-runtime.test.ts`, `tests/truth-mixed-battle-funding.test.ts`.
+Controlled editing/assistance: `tests/truth-mixed-battle-controls.test.tsx`.
+Six strict mixed-battle helper and three new shared-budget cases supplement the
+existing generic compiler. Fifteen native/controlled runtime,two original funding and
+nine controlled cases cover the new path and ordinary private-preparation
+compatibility. The frozen **18-file,173/173** union, types/lint, production build
+and nine native paths pass on the same unchanged source. Actual GameTable SSR
+renders27 times; five actual Fill callbacks and one editor callback run before
+native Ask/sealing. Four minimal profiles actually answer and seal their native
+compliant fallback. Fill/solver/own seal never fulfill an open aggregate; actual
+reveal does, with status persisting through cleanup.
+
+| Original Basic base33 source | Prefix actions | Further actions | Actual result |
+| --- | ---: | ---: | --- |
+| Native held projectile AND plan | 24 | 8 | Actual Fill then own seal remains pending; other seal/reveal completes |
+| Frozen spice and real bribe | 24 | 9 | E9→6 debit; reveal completes, native killed-leader1 award ends E7 |
+| Edited true OR | 24 | 8 | Actual AND→OR editor/Ask; no binding, later unarmed plan legal |
+| False fact AND No | 24 | 8 | Immediate No, no binding, later actual projectile legal |
+| Unknown OR Yes | 24 | 8 | Unknown forecast requires actual projectile plan and reveal |
+| Unknown AND No | 24 | 8 | Native compliant unarmed plan, own seal pending, actual reveal completes |
+| Already sealed fixed | 24 | 8 | Truthful fixed plan terminal immediately; no extra residual |
+| Already revealed fixed | 24 | 8 | Same original pure scope retained during actual counterdecisions |
+| Ordinary Prescience preparation | 23 | 10 | Real weapon inspection rejects contradictory answer and keeps original projectile |
+
+Other native cases validate all six scalar plan kinds, both nested whole-No
+alternatives, exact owner/source/territory/wire packets, filtering residuals from
+every seat and a controlled eventless metadata clone. Original Atreides owns one
+physical Truth; all33 original cards remain physical. Existing mixed movement/
+payment/revival/bid/alliance controls retain their original defaults and IDs.
+A new controlled regression preserves private native preparation when neither
+pure nor mixed Truthtrance records exist.
+
+An early narrow check catches a helper-name integration error. V1 type feedback
+repairs scalar-union narrowing and controlled fixture/action types. V2 passes
+quick but catches one funding expectation that omitted the existing one-spice
+award for killing original Dr. Yueh. The test now proves his actual native death
+and strength1; game reward mechanics are unchanged. The new question's phrasing
+is corrected. The complete v3 batch passes. Verified source tree:
+`a79a23b20761d962e2248097d87d47e8d061e80cea253600ce3551d7e734affb`
+(3203 files). Final evidence prose is a separate three-document overlay.
+Proof/report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-battle-proof-v3/`;
+journeys/HTML: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-battle-runtime/`.
+SSR/Fill/editor callbacks are local engine/control evidence, not DOM,
+hydration, browser/mobile or deployed acceptance.
+
+The source inherits the actual Basic Atreides/Emperor base33 beforePlans prefix
+from `fixture-truth-plan-expression.ts`; optional preparation uses its earned
+beforePreparation prefix. Atreides owns one original Truth, Emperor its original
+projectile and actually paid reserve. No second Truth, Ghola or Advanced history
+is invented. Only original unopened deck/lot/Spice choices and scalar lotteries
+are selected. No played hand/purse/clock/plan/answer/promise is installed.
+The legacy test omits only event metadata in a clearly controlled detached
+clone; controlled controls keep their own labels.
+
+Mixed Traitor decisions, other action families, correlated unknowns, wider
+Advanced/faction/card/Voice/Ghola/preparation and cross-action composition remain
+required, followed by final rules/recovery/privacy/custody/browser/mobile/
+deployed acceptance. No complete mode is certified. No saved-game/database/
+server/reset/restart/deployment operation is performed by this batch.
diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
index 335092d3..250e6a78 100644
--- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
+++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
@@ -1,5 +1,10 @@
# Truthtrance nonbattle readiness and first implementation slice
+The [mixed current battle-plan follow-up](TRUTHTRANCE_MIXED_BATTLE.md) combines
+answer-time facts with original native plan conditions. Full raw logical
+budgets and actual reveal settlement preserve current battle mechanics; this
+does not add arbitrary future card-play or cross-action promise enforcement.
+
The [whole/mixed final-alliance follow-up](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md)
adds full AND/OR partner conditions and answer-time facts at the current ordinary
Nexus close. Actual native consent, final membership and financial preparation
diff --git a/game/battle-promises.ts b/game/battle-promises.ts
index d996dd3c..026ff63c 100644
--- a/game/battle-promises.ts
+++ b/game/battle-promises.ts
@@ -14,6 +14,8 @@ export type PlanClaim =
| { kind: 'kwisatz'; use: boolean }
| { kind: 'and' | 'or'; terms: PlanClaim[] };
export type BattlePromise = {
+ /** Optional link for new mixed questions; older pure records retain their shape. */
+ event?: string;
player: string;
asker: string;
claim: PlanClaim;
diff --git a/game/bots.ts b/game/bots.ts
index ea8a1899..35160efa 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1143,7 +1143,12 @@ function plans(g: GameView): Action[] {
);
if (b.compliantPlan && commitments.every(
(element) => fixed(element.field, b.compliantPlan![element.field]),
- )) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
+ )) {
+ const action: Action = { type: 'battlePlan', ...b.compliantPlan };
+ if ((g.mixedBattlePromises ?? []).some(promise => promise.player === me.id && promise.turn === g.turn &&
+ promise.battle === b.event && !promise.fulfilled && !promise.released)) ranked.unshift(action);
+ else ranked.push(action);
+ }
return ecazOccupyPolicyActions(g, ranked);
}
@@ -1320,6 +1325,10 @@ function policyActions(g: GameView): Action[] {
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
+ if (w.question.kind === 'mixedBattlePlan') {
+ const answer = g.truthMixedBattleAnswers?.[0];
+ return answer ? [{ type: 'truthAnswer', answer }] : [];
+ }
if (w.question.kind === 'mixedBid') {
const answer = g.truthMixedBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
diff --git a/game/engine.ts b/game/engine.ts
index 4f3fdbff..e573c393 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,4 @@
+import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises';
@@ -2160,6 +2161,10 @@ export type Game = {
};
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
+ mixedBattleQuestions?: (Omit & {
+ event: string; battle: string; turn: number; player: string; asker: string; answer: boolean;
+ binding: boolean; fulfilled: boolean; released: boolean;
+ })[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
/** Raw mixed question linked to one private native current-lot obligation. */
@@ -6932,6 +6937,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
+ ...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
allianceExpressionPromises: g.allianceExpressionPromises, alliancePromiseSequence: g.alliancePromiseSequence,
mixedAllianceQuestions: g.mixedAllianceQuestions,
@@ -27559,10 +27565,68 @@ function bindBattleTruth(
answer,
});
}
+function compileMixedBattle(g: Game, p: Player, claim: MixedBattleClaim, answer: boolean) {
+ const compiled = compileMixedActionExpression(claim.mixed,
+ fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
+ if (compiled.kind === 'constant') return { constant: compiled.value };
+ const plan = (node: CompiledMixedAction): PlanClaim => {
+ if (node.kind === 'action') return { ...node.action };
+ requireRule(node.kind !== 'constant', 'Retain the privately compiled mixed battle-plan obligation.');
+ return { kind: node.kind, terms: node.terms.map(plan) };
+ };
+ return { residual: plan(compiled) };
+}
+function mixedBattleTruthAnswers(g: Game, p: Player, claim: MixedBattleClaim): TruthAnswer[] {
+ const b = g.battle;
+ if (g.phase !== 6 || !b || claim.battle !== (b.event ?? null) || claim.territory !== b.territory ||
+ ![b.attacker, b.defender].includes(p.id)) return [];
+ const answers: TruthAnswer[] = [];
+ for (const answer of [true, false]) {
+ const compiled = compileMixedBattle(g, p, claim, answer);
+ if (compiled.constant !== undefined ? compiled.constant === answer :
+ battleTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
+ }
+ return answers.length ? answers : ['unknown'];
+}
+function bindMixedBattleTruth(g: Game, p: Player, claim: MixedBattleClaim, answer: boolean, asker: string) {
+ const b = g.battle;
+ requireRule(b && typeof b.event === 'string' && b.event === claim.battle &&
+ mixedBattleTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
+ 'Choose a mixed battle-plan answer compatible with this actual battle and earlier commitments.');
+ const compiled = compileMixedBattle(g, p, claim, answer), event = crypto.randomUUID();
+ const before = b.truthPromises?.length ?? 0;
+ if (compiled.residual) bindBattleTruth(g, p, compiled.residual, answer, asker);
+ const bound = b.truthPromises?.[before];
+ if (bound) bound.event = event;
+ (g.mixedBattleQuestions ??= []).push({ event, battle: b.event, territory: claim.territory, turn: g.turn,
+ mixed: structuredClone(claim.mixed), player: p.id, asker, answer,
+ binding: !!bound, fulfilled: !bound, released: false });
+}
+function reconcileMixedBattleQuestions(g: Game) {
+ const b = g.battle;
+ for (const question of g.mixedBattleQuestions ?? []) {
+ if (!question.binding || question.fulfilled || question.released) continue;
+ if (!b || b.event !== question.battle) { question.released = true; continue; }
+ const bound = b.truthPromises?.find(promise => promise.event === question.event);
+ if (bound?.released) question.released = true;
+ else if (b.revealed && bound) question.fulfilled = true;
+ }
+}
+function isMixedBattleEvent(g: Game, event: string | undefined): boolean {
+ return typeof event === 'string' && !!g.mixedBattleQuestions?.some(question => question.event === event);
+}
+function projectedMixedBattlePromises(g: Game, player: string) {
+ return (g.mixedBattleQuestions ?? []).filter(question => question.player === player).map(question => ({
+ event: question.event, battle: question.battle, territory: question.territory, turn: question.turn,
+ player: question.player, asker: question.asker, mixed: structuredClone(question.mixed), answer: question.answer,
+ fulfilled: question.fulfilled, released: question.released,
+ }));
+}
function reconcileBattlePromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
+ reconcileMixedBattleQuestions(g);
if (g.pendingKull || g.pendingRicheseBetrayal) return;
const b = g.battle;
if (
@@ -27597,6 +27661,7 @@ function reconcileBattlePromises(
);
}
}
+ reconcileMixedBattleQuestions(g);
}
function feasiblePrescience(
g: Game,
@@ -36778,6 +36843,8 @@ function applyActionInner(
bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
bindTraitor: bindTraitorTruth,
+ mixedBattleAnswers: mixedBattleTruthAnswers,
+ bindMixedBattle: bindMixedBattleTruth,
battleAnswers: battleTruthAnswers,
bindBattle: bindBattleTruth,
cardBlock: transferCardBlock,
@@ -40195,6 +40262,7 @@ function applyActionInner(
revealPlayerNoField(g, owner, 'battle');
}
b.revealed = true;
+ reconcileMixedBattleQuestions(g);
for (const plan of Object.values(b.plans)) {
const l = g.players
.flatMap((p) => p.leaders)
@@ -40605,6 +40673,10 @@ export function viewGame(state: Game, id: string) {
)
? findShipmentCompletion(g, me)
: null,
+ truthMixedBattleTargets: g.phase === 6 && g.battle ? [g.battle.attacker, g.battle.defender] : [],
+ truthMixedBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+ g.truthtrance.question.kind === 'mixedBattlePlan' ? mixedBattleTruthAnswers(g, me, g.truthtrance.question) : null,
+ mixedBattlePromises: projectedMixedBattlePromises(g, id),
truthBattleAnswers:
g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id &&
@@ -41701,7 +41773,7 @@ export function viewGame(state: Game, id: string) {
ownForces: [b.attacker, b.defender].includes(id)
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
: null,
- truthPromises: b.truthPromises ?? [],
+ truthPromises: (b.truthPromises ?? []).filter(promise => !isMixedBattleEvent(g, promise.event)),
compliantPlan:
completion &&
!completion.actions.length &&
diff --git a/game/mixed-action-question.ts b/game/mixed-action-question.ts
index 626fdb5e..90d90bac 100644
--- a/game/mixed-action-question.ts
+++ b/game/mixed-action-question.ts
@@ -12,9 +12,36 @@ export type CompiledMixedAction =
| { kind: 'action'; action: A }
| { kind: 'and' | 'or'; terms: CompiledMixedAction [] };
-export type MixedActionExpressionLimits = { maxDepth?: number; maxLeaves?: number };
+export type MixedActionExpressionLimits = {
+ maxDepth?: number;
+ maxLeaves?: number;
+ maxNodes?: number;
+ maxTerms?: number;
+};
export class MixedActionQuestionError extends Error {}
+type ExpressionSize = { leaves: number; depth: number; nodes: number; maxTerms: number };
+const leafSize = (): ExpressionSize => ({ leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
+function groupSize(children: ExpressionSize[]): ExpressionSize {
+ return {
+ leaves: children.reduce((sum, child) => sum + child.leaves, 0),
+ depth: 1 + Math.max(0, ...children.map(child => child.depth)),
+ nodes: 1 + children.reduce((sum, child) => sum + child.nodes, 0),
+ maxTerms: Math.max(children.length, ...children.map(child => child.maxTerms)),
+ };
+}
+function factSize(fact: TruthFact): ExpressionSize {
+ return fact.kind === 'and' || fact.kind === 'or'
+ ? groupSize(fact.terms.map(factSize)) : leafSize();
+}
+
+/** A fact wrapper contributes its actual logical subtree, not a second node. */
+export function mixedActionExpressionSize (mixed: MixedActionExpression ): ExpressionSize {
+ if (mixed.kind === 'fact') return factSize(mixed.fact);
+ if (mixed.kind === 'action') return leafSize();
+ return groupSize(mixed.terms.map(mixedActionExpressionSize));
+}
+
function check(value: unknown, message: string): asserts value {
if (!value) throw new MixedActionQuestionError(message);
}
@@ -36,19 +63,31 @@ export function parseMixedActionExpression (
const maxDepth = limits.maxDepth ?? 4;
const maxLeaves = limits.maxLeaves ?? 16;
check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 &&
- Number.isSafeInteger(maxLeaves) && maxLeaves >= 1,
+ Number.isSafeInteger(maxLeaves) && maxLeaves >= 1 &&
+ (limits.maxNodes === undefined || Number.isSafeInteger(limits.maxNodes) && limits.maxNodes >= 1) &&
+ (limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2),
'Choose valid logical depth and clause limits.');
let leaves = 0;
+ let nodes = 0;
let hasFact = false;
let hasAction = false;
const count = (depth: number, leaf: boolean): void => {
check(depth <= maxDepth && (!leaf || ++leaves <= maxLeaves),
`Truthtrance supports at most ${maxLeaves} logical clauses and ${maxDepth} levels of grouping.`);
+ check(limits.maxNodes === undefined || ++nodes <= limits.maxNodes,
+ `Truthtrance supports at most ${limits.maxNodes} logical nodes, including groups.`);
+ };
+ const countTerms = (terms: readonly unknown[]): void => {
+ check(limits.maxTerms === undefined || terms.length <= limits.maxTerms,
+ `Truthtrance supports at most ${limits.maxTerms} terms in each AND/OR group.`);
};
const countFact = (fact: TruthFact, depth: number): void => {
const group = fact.kind === 'and' || fact.kind === 'or';
count(depth, !group);
- if (group) for (const term of fact.terms) countFact(term, depth + 1);
+ if (group) {
+ countTerms(fact.terms);
+ for (const term of fact.terms) countFact(term, depth + 1);
+ }
};
const parse = (input: unknown, depth: number): MixedActionExpression => {
const node = record(input);
@@ -72,6 +111,7 @@ export function parseMixedActionExpression (
(node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) &&
node.terms.length >= 2 && node.terms.length <= maxLeaves,
'Join two or more current facts or future actions with AND or OR.');
+ countTerms(node.terms);
return { kind: node.kind, terms: node.terms.map(term => parse(term, depth + 1)) };
};
const mixed = parse(value, 0);
diff --git a/game/mixed-battle-question.ts b/game/mixed-battle-question.ts
new file mode 100644
index 00000000..c3749130
--- /dev/null
+++ b/game/mixed-battle-question.ts
@@ -0,0 +1,38 @@
+import {
+ BATTLE_CLAIM_MAX_DEPTH, BATTLE_CLAIM_MAX_NODES, BATTLE_CLAIM_MAX_TERMS,
+} from './battle-claim-expression';
+import { parsePlanClaim, PlanClaimError, type PlanClaim } from './battle-promises';
+import type { MixedActionExpression } from './mixed-action-question';
+
+export const MIXED_BATTLE_MAX_NODES = BATTLE_CLAIM_MAX_NODES;
+export const MIXED_BATTLE_MAX_TERMS = BATTLE_CLAIM_MAX_TERMS;
+export const MIXED_BATTLE_MAX_DEPTH = BATTLE_CLAIM_MAX_DEPTH;
+export const MIXED_BATTLE_MAX_LEAVES = BATTLE_CLAIM_MAX_NODES;
+
+export type MixedBattleActionClaim = Exclude;
+export type MixedBattleClaim = {
+ battle: string | null;
+ territory: string;
+ mixed: MixedActionExpression;
+};
+
+/** One native plan leaf only; mixed groups share the outer logical budget. */
+export function parseBattleActionClaim(
+ value: unknown,
+ leaders: readonly string[],
+): MixedBattleActionClaim {
+ const fail = (): never => {
+ throw new PlanClaimError('Supply one original battle-plan condition without metadata or groups.');
+ };
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return fail();
+ const node = value as Record;
+ const keys = node.kind === 'dial' || node.kind === 'support' ? 'compare,kind,value'
+ : node.kind === 'weapon' || node.kind === 'defense' ? 'kind,name'
+ : node.kind === 'leader' ? 'kind,leader'
+ : node.kind === 'kwisatz' ? 'kind,use' : null;
+ if (keys === null || Object.keys(node).sort().join(',') !== keys) return fail();
+ const action = parsePlanClaim(node, [...leaders]);
+ if (action.kind === 'dial' || action.kind === 'support' || action.kind === 'weapon' ||
+ action.kind === 'defense' || action.kind === 'leader' || action.kind === 'kwisatz') return action;
+ return fail();
+}
diff --git a/game/reference.ts b/game/reference.ts
index a425571d..b801810a 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3699,6 +3699,32 @@ export const RULE_TOPICS: RuleTopic[] = [
] },
],
},
+ {
+ id: 'truthtrance-mixed-battle',
+ title: 'Truthtrance: current facts and this battle plan',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Combine answer-time facts with native current-battle plan conditions while keeping legal preparations, fixed choices and private aggregate meaning.',
+ steps: [
+ 'Choose Combine current facts and this battle plan, another combatant, and full AND/OR groups containing both fact and plan conditions. Dial, spice support, weapon, defense, leader/hero and Kwisatz use keep their native meanings.',
+ 'Current facts freeze at the actual answer. Holding a card or a particular spice amount then does not promise keeping it afterward. A real later payment or played card never creates a new factual answer.',
+ 'Yes makes the complete expression true; No negates the whole expression. A definite Yes cannot rely on an unknown fact being true, and No cannot rely on it being false. If neither guaranteed completion is currently available, the unanswered card can be saved or asked differently.',
+ 'A fact may already settle the aggregate, needing no plan commitment. Otherwise the remaining conditions use the original legal battle-plan search, earlier promises, Voice, Prescience, inspections, owned preparations and normal funding. Alternatives remain available; no fixed route is chosen by the answer.',
+ 'An open obligation stays pending through previews and your own sealing. It fulfills at the actual native plan reveal before battle cleanup. Already submitted or revealed plans are fixed, so a truthful answer about that plan is immediately complete. A later native impossibility releases a binding answer.',
+ 'Native controls can fill a compliant draft or complete the next owned preparation; Fill does not seal automatically. The original Seal action still checks the whole authoritative obligation. Raw question/aggregate status stays visible after the battle while reduced clauses and frozen facts stay private.',
+ 'One raw logical budget counts fact and plan groups together: sixteen total clauses, at most eight children per group, and four grouping levels. Other mixed question families retain their existing limits. These are input limits rather than printed restrictions.',
+ 'The current battle and territory are fixed by the question. Existing older battles lacking an identity receive one only in an actual accepted Ask transaction. Four minimal profiles use native answer and compliant plan/preparation feedback; full strategy and wider composition acceptance remain unfinished.',
+ ],
+ related: ['card-truthtrance', phaseRuleId(6), 'truthtrance-mixed-revival', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Shared fact snapshot compiles only a needed native plan residual. Real reveal settles open aggregates before synchronous cleanup; fixed-plan/constant answers need no future binding.', evidence: ['game/mixed-battle-question.ts', 'game/mixed-action-question.ts', 'game/battle-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Shared full editor with native raw budget, exact preview/Ask, private answers, persistent raw aggregate and guarded native preparation/Fill controls are connected.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-battle-fields.tsx', 'components/mixed-battle-promises.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-battle-controls.test.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and prioritize the private native compliant fallback when mixed constraints are hidden. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-battle-runtime.test.ts'] },
+ { area: 'Documentation', status: 'Implemented', detail: 'Snapshot/native plan interval, input budgets, reveal settlement, older battle identity and remaining source/composition limits are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BATTLE.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Pure parser/budget, original Basic battle/payment/Prescience and controlled native assistance cases; wider Advanced/faction/card preparation and final deployed acceptance remain open.', evidence: ['tests/mixed-battle-question.test.ts', 'tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-battle.ts', 'tests/truth-mixed-battle-runtime.test.ts', 'tests/truth-mixed-battle-funding.test.ts', 'tests/truth-mixed-battle-controls.test.tsx'] },
+ ],
+ },
{
id: 'card-truthtrance',
title: 'Truthtrance',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 06cbf803..d81767a6 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -1,3 +1,5 @@
+import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
+ MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
import { parseAllianceExpressionClaim, allianceExpressionText, type AllianceExpressionClaim } from './alliance-expression-promises';
import { parseAllianceActionClaim, type MixedAllianceClaim } from './mixed-alliance-question';
import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question';
@@ -75,6 +77,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression };
export type TruthQuestion =
+ | ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim)
| ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim)
| ({ kind: 'mixedAlliance'; target: string } & MixedAllianceClaim)
| ({ kind: 'mixedBid'; target: string } & MixedBidClaim)
@@ -428,6 +431,22 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
throw error;
}
}
+ if (v.kind === 'mixedBattlePlan') {
+ check(Object.keys(v).sort().join(',') === 'battle,kind,mixed,target,territory' &&
+ g.phase === 6 && g.battle && [g.battle.attacker, g.battle.defender].includes(v.target) &&
+ v.territory === g.battle.territory && (typeof v.battle === 'string' && v.battle.length > 0 || v.battle === null) &&
+ v.battle === (g.battle.event ?? null), 'Choose the exact current battle and another seated combatant.');
+ const respondent = g.players.find(player => player.id === v.target)!;
+ const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent),
+ value => parseBattleActionClaim(value, battleLeaderIdentities(g).map(leader => leader.id)),
+ { maxLeaves: MIXED_BATTLE_MAX_LEAVES, maxDepth: MIXED_BATTLE_MAX_DEPTH,
+ maxNodes: MIXED_BATTLE_MAX_NODES, maxTerms: MIXED_BATTLE_MAX_TERMS });
+ // Older active battles lacked an event. Initialize only in this explicit
+ // Ask transaction, preserving passive views and every physical choice.
+ g.battle.event ??= crypto.randomUUID();
+ return { kind: 'mixedBattlePlan', target: v.target as string, battle: g.battle.event,
+ territory: g.battle.territory, mixed };
+ }
if (v.kind === 'battlePlan') {
check(
g.phase === 6 &&
@@ -638,6 +657,8 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
+ if (q.kind === 'mixedBattlePlan') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
+ action => `${planClaimText(action, leaderName)} in this battle`)}?`;
if (q.kind === 'mixedAlliance') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)}`)}?`;
if (q.kind === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
@@ -704,6 +725,8 @@ export function resolveTruthAction(
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
+ mixedBattleAnswers?: (g: Game, p: Player, claim: MixedBattleClaim) => TruthAnswer[];
+ bindMixedBattle?: (g: Game, p: Player, claim: MixedBattleClaim, answer: boolean, asker: string) => void;
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
bindBattle: (
g: Game,
@@ -820,6 +843,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
+ if (w.question.kind === 'mixedBattlePlan')
+ check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
+ 'Choose a whole mixed battle-plan question compatible with this battle.');
if (w.question.kind === 'mixedRevival')
check(effects.mixedRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a mixed current-fact and ordinary-return question compatible with the native opportunity.');
@@ -988,6 +1014,11 @@ export function resolveTruthAction(
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
if (a.answer !== 'unknown') effects.bindTraitor(g, p, a.answer === 'yes', current.player);
}
+ if (q.kind === 'mixedBattlePlan') {
+ check(effects.mixedBattleAnswers && effects.bindMixedBattle && effects.mixedBattleAnswers(g, p, q).includes(a.answer),
+ 'Choose a mixed battle-plan answer compatible with the complete fact/plan question.');
+ if (a.answer !== 'unknown') effects.bindMixedBattle(g, p, q, a.answer === 'yes', current.player);
+ }
if (q.kind === 'battlePlan') {
check(
effects.battleAnswers(g, p, q.claim).includes(a.answer),
diff --git a/tests/fixture-truth-mixed-battle.ts b/tests/fixture-truth-mixed-battle.ts
new file mode 100644
index 00000000..af55f03a
--- /dev/null
+++ b/tests/fixture-truth-mixed-battle.ts
@@ -0,0 +1,143 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
+import type { TruthAnswer, TruthFact } from '../game/truthtrance';
+import {
+ createTruthPlanExpressionFixture, actTruthPlanExpression,
+ passTruthPlanExpressionPriority, truthPlanExpressionClean,
+ truthPlanExpressionInventory, truthPlanExpressionPlayer,
+ type TruthPlanExpressionStep,
+} from './fixture-truth-plan-expression';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type TruthMixedBattleExpression = MixedActionExpression;
+export type TruthMixedBattleStep = TruthPlanExpressionStep;
+export const actTruthMixedBattle = actTruthPlanExpression;
+export const truthMixedBattleClean = truthPlanExpressionClean;
+export const truthMixedBattlePlayer = truthPlanExpressionPlayer;
+export const truthMixedBattleInventory = truthPlanExpressionInventory;
+export const truthMixedBattleFact = (fact: TruthFact): TruthMixedBattleExpression => ({ kind: 'fact', fact });
+export const truthMixedBattleAction = (action: MixedBattleActionClaim): TruthMixedBattleExpression => ({ kind: 'action', action });
+export const truthMixedBattleGroup = (kind: 'and' | 'or', ...terms: TruthMixedBattleExpression[]): TruthMixedBattleExpression =>
+ ({ kind, terms });
+
+/** Thin original Basic base33 adapter. Select an earned source snapshot and its
+ * exact action prefix, never install a played hand, wallet, battle, plan,
+ * commitment, answer, frozen fact or residual. The source owns one Truth. */
+export function createTruthMixedBattleFixture(options: {
+ mixed?: TruthMixedBattleExpression; window?: 'plans' | 'preparation';
+} = {}) {
+ const base = createTruthPlanExpressionFixture(), window = options.window ?? 'plans';
+ const battleAt = base.actions.findLastIndex(step => step.action.type === 'chooseBattle');
+ assert.ok(battleAt >= 0);
+ const game = structuredClone(window === 'preparation' ? base.beforePreparation : base.game);
+ const actions = structuredClone(window === 'preparation' ? base.actions.slice(0, battleAt + 1) : base.actions);
+ assert.ok(game.battle?.event); const event = game.battle.event;
+ const absent = base.setup.deck.find(card => card.kind === 'shield'); assert.ok(absent);
+ const projectileFact: TruthFact = { kind: 'hand', name: base.projectile.name };
+ const absentFact: TruthFact = { kind: 'hand', name: absent.name };
+ const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedBattlePlayer(game, base.target).spice };
+ const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
+ const weaponClaim: MixedBattleActionClaim = { kind: 'weapon', name: base.projectile.name };
+ const mixed = structuredClone(options.mixed ?? truthMixedBattleGroup('and', truthMixedBattleFact(projectileFact),
+ truthMixedBattleAction(weaponClaim)));
+ const question = { kind: 'mixedBattlePlan' as const, target: base.target, battle: event,
+ territory: base.territory, mixed };
+ const questionAction = { type: 'truthAsk', question } satisfies Action;
+ return { ...base, base, planSource: base, game, source: structuredClone(game), window, event, battle: event,
+ mixed, question, questionAction, askStep: { actor: base.asker, action: questionAction },
+ weaponClaim, projectileFact, heldFact: projectileFact, absentFact, spiceFact, forecastFact,
+ sourceActions: actions, actions, steps: actions, sourceActionCount: actions.length,
+ sourceCounts: { inherited: base.sourceActionCount, beforePlans: base.sourceActionCount,
+ beforePreparation: battleAt + 1, inheritedReveal: base.base.sourceActionCount },
+ sourceDisclosure: `Original public Basic Atreides/Emperor base33 source, ${actions.length} actual actions, at earned ${window === 'plans' ? 'beforePlans' : 'beforePreparation'}. Only inherited unopened starting Treachery/ordinary Spice positions and scalar lotteries are selected. Atreides actually holds one original Truthtrance, Emperor its original projectile and one actually paid reserve at Arrakeen10. This adapter changes only the raw question; all declarations, answers, preparation, payments, seals and votes remain actual caller controls.`,
+ staging: [...base.staging.slice(0, 2),
+ `Use the inherited actual phase, paid shipment and battle-selection prefix through its earned ${window === 'plans' ? 'beforePlans snapshot, including the original preparation decline' : 'beforePreparation snapshot, before its original preparation decline'}. Omit the later original plan seals; no private plan is installed.`,
+ 'The thin adapter supplies only an original current-fact/future-plan question with the native battle event and territory. It adds no played state. The original source owns one Truthtrance; no second physical copy or Ghola is supplied.'],
+ };
+}
+export type TruthMixedBattleFixture = ReturnType;
+
+/** Stop at private seals or public Traitor votes: those remain caller-owned. */
+export function nextTruthMixedBattle(game: Game): TruthMixedBattleStep | null {
+ if (game.truthtrance) return null;
+ if (game.battle?.revealed && truthMixedBattleClean(game) && viewGame(game, game.host).battle!.traitorVoters
+ .some(actor => game.battle!.traitorCalls[actor] === undefined)) return null;
+ return nextStrongholdFactionsNativeStep(game);
+}
+export const nextTruthMixedBattleNativeStep = nextTruthMixedBattle;
+export function advanceTruthMixedBattle(game: Game, until: (state: Game) => boolean,
+ actions: TruthMixedBattleStep[] = []): Game {
+ for (let count = 0; count < 800; count++) {
+ if (until(game)) return game;
+ const next = nextTruthMixedBattle(game);
+ assert.ok(next, 'Choose the original Truthtrance, private plan or Traitor vote before continuing');
+ game = actTruthMixedBattle(game, next.actor, next.action, actions);
+ }
+ throw Error('The original mixed battle source did not reach its finite boundary');
+}
+export function settleTruthMixedBattle(game: Game, actions: TruthMixedBattleStep[] = []): Game {
+ return advanceTruthMixedBattle(game, truthMixedBattleClean, actions);
+}
+
+export function declareTruthMixedBattle(f: TruthMixedBattleFixture, source = f.game) {
+ const before = structuredClone(source), actions: TruthMixedBattleStep[] = [];
+ let game = actTruthMixedBattle(before, f.asker, { type: 'card', card: f.truth }, actions);
+ const opened = structuredClone(game);
+ game = passTruthPlanExpressionPriority(game, actions);
+ return { game, before, opened, beforeQuestion: structuredClone(game), actions };
+}
+export const createTruthMixedBattleAskSource = declareTruthMixedBattle;
+export function askTruthMixedBattle(game: Game, asker: string, target: string, mixed: TruthMixedBattleExpression,
+ actions: TruthMixedBattleStep[] = []): Game {
+ assert.ok(game.battle);
+ return actTruthMixedBattle(game, asker, { type: 'truthAsk', question: {
+ kind: 'mixedBattlePlan', target, battle: game.battle.event ?? null,
+ territory: game.battle.territory, mixed: structuredClone(mixed),
+ } }, actions);
+}
+export function answerTruthMixedBattle(game: Game, target: string, answer: TruthAnswer,
+ actions: TruthMixedBattleStep[] = []): Game {
+ assert.ok(viewGame(game, target).truthMixedBattleAnswers?.includes(answer));
+ return actTruthMixedBattle(game, target, { type: 'truthAnswer', answer }, actions);
+}
+export function bindTruthMixedBattle(f: TruthMixedBattleFixture, answer: 'yes' | 'no' = 'yes',
+ mixed = f.mixed, source = f.game) {
+ const declared = declareTruthMixedBattle(f, source), actions = declared.actions;
+ const asked = askTruthMixedBattle(declared.game, f.asker, f.target, mixed, actions);
+ const game = answerTruthMixedBattle(asked, f.target, answer, actions);
+ return { game, declared, asked, actions, actionCount: actions.length };
+}
+
+/** Seal the two actual original plans; solver previews never call this. */
+export function revealTruthMixedBattle(game: Game, f: TruthMixedBattleFixture,
+ plan: Action = f.projectilePlan, actions: TruthMixedBattleStep[] = []) {
+ game = actTruthMixedBattle(game, f.target, plan, actions);
+ const afterTargetPlan = structuredClone(game);
+ game = actTruthMixedBattle(game, f.asker, f.ownPlan, actions);
+ assert.ok(game.battle?.revealed);
+ return { game, afterTargetPlan, actions };
+}
+export const sealTruthMixedBattle = revealTruthMixedBattle;
+
+/** Actually decline original public votes and finish original cleanup. */
+export function finishTruthMixedBattle(game: Game, f: TruthMixedBattleFixture,
+ actions: TruthMixedBattleStep[] = []): Game {
+ for (let count = 0; count < 100; count++) {
+ if (!game.battle || game.battle.event !== f.event) return game;
+ assert.ok(game.battle.revealed, 'Seal both original private plans before battle cleanup');
+ const next = nextStrongholdFactionsNativeStep(game);
+ assert.ok(next, 'Finish the current native battle control');
+ game = actTruthMixedBattle(game, next.actor, next.action, actions);
+ }
+ throw Error('The original battle cleanup did not finish');
+}
+
+/** Read only the respondent's original raw aggregate and terminal status. */
+export function truthMixedBattlePromise(game: Game,
+ target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
+ const row = viewGame(game, target).mixedBattlePromises.find(row => row.player === target);
+ assert.ok(row, 'The respondent retains the original mixed question and aggregate status');
+ return row;
+}
diff --git a/tests/mixed-action-question.test.ts b/tests/mixed-action-question.test.ts
index a6549486..acf67df5 100644
--- a/tests/mixed-action-question.test.ts
+++ b/tests/mixed-action-question.test.ts
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
compileMixedActionExpression,
+ mixedActionExpressionSize,
mixedActionExpressionText,
MixedActionQuestionError,
parseMixedActionExpression,
@@ -146,6 +147,53 @@ void test('one four-level budget includes nested current-fact AND/OR and support
assert.throws(() => decode(simple, limits), MixedActionQuestionError);
});
+void test('shared logical size counts actual fact groups once and measures every group arity', () => {
+ const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms: [
+ { kind: 'spice', compare: 'gte', value: 5 },
+ { kind: 'and', terms: [
+ { kind: 'spice', compare: 'gte', value: 10 },
+ { kind: 'spice', compare: 'gte', value: 15 },
+ { kind: 'spice', compare: 'gte', value: 20 },
+ ] },
+ ] } };
+ assert.deepEqual(mixedActionExpressionSize(fact()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
+ assert.deepEqual(mixedActionExpressionSize(action()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
+ assert.deepEqual(mixedActionExpressionSize(embedded), { leaves: 4, depth: 2, nodes: 6, maxTerms: 3 });
+ assert.deepEqual(mixedActionExpressionSize(group('and', embedded, action())),
+ { leaves: 5, depth: 3, nodes: 8, maxTerms: 3 });
+});
+
+void test('optional node limits share one full budget across action leaves and embedded fact groups', () => {
+ const embedded = (length: number): Expression => ({ kind: 'fact', fact: {
+ kind: 'or', terms: Array.from({ length }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
+ } });
+ const atLimit = group('and', embedded(7), ...Array.from({ length: 7 }, () => action()));
+ assert.deepEqual(mixedActionExpressionSize(atLimit), { leaves: 14, depth: 2, nodes: 16, maxTerms: 8 });
+ assert.deepEqual(decode(atLimit, { maxNodes: 16 }), atLimit);
+ const overLimit = group('and', ...Array.from({ length: 7 }, () => action()), embedded(8));
+ assert.doesNotThrow(() => decode(overLimit));
+ assert.throws(() => decode(overLimit, { maxNodes: 16 }), MixedActionQuestionError);
+ assert.throws(() => decode(group('and', fact(), action()), { maxNodes: 2 }), MixedActionQuestionError);
+ for (const maxNodes of [0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
+ assert.throws(() => decode(atLimit, { maxNodes }), MixedActionQuestionError);
+});
+
+void test('optional group limits include late fact subgroups while absent limits preserve wider legacy groups', () => {
+ const wideOuter = group('or', fact(), ...Array.from({ length: 8 }, () => action()));
+ const wideFact: Expression = { kind: 'fact', fact: { kind: 'and', terms:
+ Array.from({ length: 9 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
+ } };
+ const wideEmbedded = group('or', fact(), action(), wideFact);
+ for (const original of [wideOuter, wideEmbedded]) {
+ assert.deepEqual(decode(original), original);
+ assert.throws(() => decode(original, { maxTerms: 8 }), MixedActionQuestionError);
+ }
+ const eight = group('and', fact(), ...Array.from({ length: 7 }, () => action()));
+ assert.deepEqual(decode(eight, { maxTerms: 8 }), eight);
+ for (const maxTerms of [0, 1, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
+ assert.throws(() => decode(eight, { maxTerms }), MixedActionQuestionError);
+});
+
void test('known facts fold whole AND/OR questions independently of the selected Yes or No answer', () => {
for (const answer of [false, true]) {
assert.deepEqual(compile(group('and', fact(), action()), 'yes', answer), compiledAction());
diff --git a/tests/mixed-battle-question.test.ts b/tests/mixed-battle-question.test.ts
new file mode 100644
index 00000000..ab34406c
--- /dev/null
+++ b/tests/mixed-battle-question.test.ts
@@ -0,0 +1,126 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { PlanClaimError } from '../game/battle-promises';
+import {
+ MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, MIXED_BATTLE_MAX_NODES,
+ MIXED_BATTLE_MAX_TERMS, parseBattleActionClaim, type MixedBattleActionClaim,
+ type MixedBattleClaim,
+} from '../game/mixed-battle-question';
+import {
+ mixedActionExpressionSize, MixedActionQuestionError, parseMixedActionExpression,
+ type MixedActionExpression,
+} from '../game/mixed-action-question';
+import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+import type { TruthFact } from '../game/truthtrance';
+
+type Expression = MixedActionExpression;
+const leaders = ['atreides-paul', 'emperor-burseg'] as const;
+const limits = {
+ maxNodes: MIXED_BATTLE_MAX_NODES,
+ maxTerms: MIXED_BATTLE_MAX_TERMS,
+ maxDepth: MIXED_BATTLE_MAX_DEPTH,
+ maxLeaves: MIXED_BATTLE_MAX_LEAVES,
+};
+const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
+const action = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'gte', value: 2.5 }): Expression =>
+ ({ kind: 'action', action: claim });
+const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
+const parse = (input: unknown): Expression => parseMixedActionExpression(input,
+ value => structuredClone(value) as TruthFact, value => parseBattleActionClaim(value, leaders), limits);
+
+void test('mixed battle leaves retain exact native types and current-battle metadata stays on the question', () => {
+ const leaves: MixedBattleActionClaim[] = [
+ { kind: 'dial', compare: 'gte', value: 2.5 },
+ { kind: 'support', compare: 'lte', value: 3 },
+ { kind: 'weapon', name: 'Gom Jabbar' },
+ { kind: 'defense', name: 'Shield' },
+ { kind: 'leader', leader: leaders[0] },
+ { kind: 'kwisatz', use: true },
+ ];
+ for (const leaf of leaves) {
+ const parsed = parseBattleActionClaim(leaf, leaders);
+ assert.deepEqual(parsed, leaf);
+ assert.notEqual(parsed, leaf);
+ }
+ const question: MixedBattleClaim = {
+ battle: 'battle-event-7', territory: 'arrakeen', mixed: group('and', fact(), ...leaves.map(action)),
+ };
+ assert.deepEqual(parse(question.mixed), question.mixed);
+ assert.deepEqual(leaders, ['atreides-paul', 'emperor-burseg']);
+});
+
+void test('mixed battle parsing rejects groups, wrappers, metadata, missing fields and unknown keys as action leaves', () => {
+ const invalid: unknown[] = [null, [], {}, { kind: 'unknown' },
+ { kind: 'and', terms: [{ kind: 'dial', compare: 'gte', value: 2 }, { kind: 'kwisatz', use: false }] },
+ { kind: 'or', terms: [] }, { kind: 'constant', value: true },
+ { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } },
+ action(), { kind: 'dial', value: 2 }, { kind: 'support', compare: 'gte' },
+ { kind: 'weapon' }, { kind: 'defense' }, { kind: 'leader' }, { kind: 'kwisatz' },
+ { kind: 'dial', compare: 'gte', value: 2, battle: 'battle-event-7' },
+ { kind: 'support', compare: 'eq', value: 2, territory: 'arrakeen' },
+ { kind: 'weapon', name: 'Gom Jabbar', target: 'player-e' },
+ { kind: 'defense', name: 'Shield', answer: true },
+ { kind: 'leader', leader: leaders[0], turn: 1 }, { kind: 'kwisatz', use: true, frozen: true },
+ ];
+ for (const value of invalid) assert.throws(() => parseBattleActionClaim(value, leaders), PlanClaimError);
+});
+
+void test('mixed battle leaves reuse native half-dial, whole support, named-card, leader and Kwisatz bounds', () => {
+ for (const kind of ['dial', 'support'] as const) for (const compare of ['eq', 'gte', 'lte'] as const)
+ for (const value of kind === 'dial' ? [0, 0.5, 39.5, 40] : [0, 1, 39, 40])
+ assert.deepEqual(parseBattleActionClaim({ kind, compare, value }, leaders), { kind, compare, value });
+ for (const kind of ['weapon', 'defense'] as const)
+ assert.deepEqual(parseBattleActionClaim({ kind, name: null }, leaders), { kind, name: null });
+ for (const leader of [null, leaders[1], CHEAP_HERO_TRAITOR])
+ assert.deepEqual(parseBattleActionClaim({ kind: 'leader', leader }, leaders), { kind: 'leader', leader });
+ assert.deepEqual(parseBattleActionClaim({ kind: 'kwisatz', use: false }, leaders), { kind: 'kwisatz', use: false });
+ const invalid: unknown[] = [
+ ...[-0.5, 0.25, 40.5, NaN, Infinity, '2'].map(value => ({ kind: 'dial', compare: 'eq', value })),
+ ...[-1, 0.5, 41, NaN, Infinity, '2'].map(value => ({ kind: 'support', compare: 'gte', value })),
+ { kind: 'dial', compare: 'gt', value: 2 }, { kind: 'weapon', name: 'Unprinted Weapon' },
+ { kind: 'defense', name: 1 }, { kind: 'leader', leader: 'unpublished-leader' },
+ { kind: 'leader', leader: 1 }, { kind: 'kwisatz', use: 'yes' },
+ ];
+ for (const value of invalid) assert.throws(() => parseBattleActionClaim(value, leaders), PlanClaimError);
+});
+
+void test('a decisive earlier fact cannot hide an invalid late native battle leaf', () => {
+ for (const kind of ['and', 'or'] as const) for (const invalid of [
+ { kind: 'dial', compare: 'gte', value: 0.25 },
+ { kind: 'weapon', name: 'Gom Jabbar', battle: 'forged-event' },
+ { kind: 'and', terms: [{ kind: 'leader', leader: null }, { kind: 'kwisatz', use: false }] },
+ ]) {
+ const input = group(kind, fact(), group('or', action(), { kind: 'action', action: invalid } as Expression));
+ assert.throws(() => parse(input), PlanClaimError);
+ }
+});
+
+void test('mixed battle shares the native sixteen-node budget across facts, actions and all logical groups', () => {
+ assert.deepEqual(limits, { maxNodes: 16, maxTerms: 8, maxDepth: 4, maxLeaves: 16 });
+ const embedded = (length: number): Expression => ({ kind: 'fact', fact: { kind: 'or', terms:
+ Array.from({ length }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
+ } });
+ const atLimit = group('and', embedded(7), ...Array.from({ length: 7 }, () => action()));
+ assert.deepEqual(mixedActionExpressionSize(atLimit), { nodes: 16, leaves: 14, depth: 2, maxTerms: 8 });
+ assert.deepEqual(parse(atLimit), atLimit);
+ const overLimit = group('or', ...Array.from({ length: 7 }, () => action()), embedded(8));
+ assert.equal(mixedActionExpressionSize(overLimit).leaves, 15);
+ assert.throws(() => parse(overLimit), MixedActionQuestionError);
+ let atDepthLimit = group('and', fact(), action());
+ for (let depth = 1; depth < 4; depth++) atDepthLimit = group('or', action(), atDepthLimit);
+ assert.deepEqual(parse(atDepthLimit), atDepthLimit);
+ assert.throws(() => parse(group('and', action(), atDepthLimit)), MixedActionQuestionError);
+});
+
+void test('mixed battle applies the native eight-term limit to outer groups and every embedded fact group', () => {
+ const eight = group('and', fact(), ...Array.from({ length: 7 }, () => action()));
+ assert.deepEqual(parse(eight), eight);
+ const wideFact: Expression = { kind: 'fact', fact: { kind: 'or', terms:
+ Array.from({ length: 9 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
+ } };
+ for (const input of [group('or', fact(), ...Array.from({ length: 8 }, () => action())),
+ group('or', fact(), action(), wideFact)]) {
+ assert(mixedActionExpressionSize(input).nodes < 16);
+ assert.throws(() => parse(input), MixedActionQuestionError);
+ }
+});
diff --git a/tests/truth-mixed-battle-controls.test.tsx b/tests/truth-mixed-battle-controls.test.tsx
new file mode 100644
index 00000000..f36613cf
--- /dev/null
+++ b/tests/truth-mixed-battle-controls.test.tsx
@@ -0,0 +1,293 @@
+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 { BattlePromises } from '../components/battle-promises';
+import { MixedBattleFields, mixedBattleInputError } from '../components/mixed-battle-fields';
+import { MixedBattlePromises } from '../components/mixed-battle-promises';
+import { TruthFactFields } from '../components/mixed-shipment-fields';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
+import { mixedActionExpressionSize, type MixedActionExpression } from '../game/mixed-action-question';
+import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
+import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+import type { TruthFact } from '../game/truthtrance';
+import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
+
+type Expression = MixedActionExpression;
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
+const action = (value: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 0 }): Expression => ({ kind: 'action', action: value });
+const dial = (value = 0): Expression => action({ kind: 'dial', compare: 'eq', value });
+const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [dial(3), action({ kind: 'leader', leader: 'named-leader' })] }] });
+const plan = (): Plan => ({ dial: 3, support: 0, leader: 'named-leader', weapon: null, defense: null, kwisatz: false });
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function find(tree: ReactNode, key: string, value: string): Element {
+ const found = elements(tree).find(element => element.props[key] === value);
+ assert.ok(found, `${key}=${value}`); return found;
+}
+const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-battle-control', key);
+const click = (button: Element) => (button.props.onClick as () => void)();
+function select(tree: ReactNode, path: string, field: string, value: string) {
+ const input = find(tree, 'id', `truth-mixed-battle-${path}-${field}`);
+ (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
+}
+function amount(tree: ReactNode, path: string, valueAsNumber: number) {
+ const input = find(tree, 'id', `truth-mixed-battle-${path}-amount`);
+ (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
+}
+function editFact(tree: ReactNode, path: string, next: TruthFact) {
+ const scope = find(tree, 'data-mixed-battle-fact-path', path);
+ const fields = elements(scope).find(element => element.type === TruthFactFields);
+ assert.ok(fields, `Current fact fields ${path}`);
+ (fields.props.onChange as (fact: TruthFact) => void)(next);
+}
+
+/** Controlled projections prove fields, private display and callback guards.
+ * Source-earned questions, plan legality and reveal lifecycle have native tests. */
+function projected(): GameView {
+ const source = createGame('TRUTHMIXEDBATTLECONTROLS', newPlayer('p', 'Respondent', 'emperor'), false);
+ joinGame(source, newPlayer('a', 'Asker', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.turn = 2; game.phase = 6;
+ game.allLeaders = [
+ { id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 },
+ { id: 'foreign-leader', name: 'Foreign Commander', faction: 'atreides', strength: 5 },
+ ];
+ game.mixedBattlePromises = [{ event: 'controlled-mixed-battle-question', player: 'p', asker: 'a', turn: 2,
+ battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(), answer: true, fulfilled: false, released: false }];
+ game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed: false,
+ submitted: [], plans: {}, truthPromises: [], compliantPlan: plan(), compliantPreparation: null,
+ } as unknown as NonNullable;
+ return game;
+}
+function editor(initial: Expression, disabled = false, game = projected(), target = 'p') {
+ let value = initial, changes = 0;
+ return {
+ get value() { return value; }, get changes() { return changes; },
+ tree: () => MixedBattleFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
+ html: () => renderToStaticMarkup(createElement(MixedBattleFields, { value, game, target, disabled, onChange() {} })),
+ error: () => mixedBattleInputError(value, game, target),
+ };
+}
+function assistance(game = projected(), busy = false) {
+ const filled: Plan[] = [], sent: Action[] = [];
+ const props = { game, busy, fill(value: Plan) { filled.push(value); }, act(value: Action) { sent.push(value); } };
+ return { props, filled, sent, tree: () => BattlePromises(props),
+ html: () => renderToStaticMarkup(createElement(BattlePromises, props)) };
+}
+function nativeButton(tree: ReactNode, text: string) {
+ const button = elements(tree).find(element => typeof element.props.onClick === 'function' && element.props.children === text);
+ assert.ok(button, text); return button;
+}
+const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBattlePromises, { game }));
+
+void test('controlled mixed battle editor shares the raw sixteen-node and eight-term budgets across fact and plan groups', () => {
+ const full: Expression = { kind: 'and', terms: [fact({ kind: 'or', terms: Array.from({ length: 8 }, (): TruthFact => ({ kind: 'hand', name: 'Shield' })) }),
+ { kind: 'or', terms: Array.from({ length: 5 }, (_, index) => dial(index)) }] };
+ const e = editor(full);
+ assert.deepEqual(mixedActionExpressionSize(e.value), { leaves: 13, nodes: 16, depth: 2, maxTerms: 8 });
+ assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 total clauses/);
+ assert.match(e.html(), /8 conditions per group/);
+ for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'and-1.2.1', 'fact-or-1.1.1', 'fact-add-1.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, full);
+ amount(e.tree(), '1.2.5', 7.5); assert.equal(e.error(), null);
+ const ids = [...e.html().matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
+ assert.equal(new Set(ids).size, ids.length);
+ const wide = editor({ kind: 'and', terms: [fact(), ...Array.from({ length: 7 }, () => dial())] });
+ assert.equal(wide.error(), null); assert.equal(mixedActionExpressionSize(wide.value).nodes, 9);
+ for (const key of ['add-fact-1', 'add-action-1', 'nested-or-1']) {
+ assert.equal(control(wide.tree(), key).props.disabled, true); click(control(wide.tree(), key));
+ }
+ assert.equal(wide.changes, 0);
+ assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), ...Array.from({ length: 8 }, () => dial())] }, projected(), 'p'));
+ assert.ok(mixedBattleInputError({ kind: 'and', terms: [full, dial()] }, projected(), 'p'));
+});
+
+void test('controlled mixed and embedded fact edits preserve branches, collapse removals and stop a fifth grouping level', () => {
+ const e = editor({ kind: 'and', terms: [fact(), dial(3)] });
+ click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'spice', compare: 'gte', value: 7 });
+ select(e.tree(), '1.1', 'fact-meaning', 'and');
+ click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
+ select(e.tree(), '1.2.1', 'kind', 'weapon'); select(e.tree(), '1.2.1', 'card', 'Shield');
+ assert.equal(e.error(), null);
+ click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'fact-remove-1.1.2'));
+ assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'weapon', name: 'Shield' })] });
+ select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
+ select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
+ let nested = fact();
+ for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, dial()] };
+ const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
+ for (const key of ['or-1', 'fact-and-1.1.1.1.1', 'nested-and-1.1.1.1']) {
+ assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
+ }
+ assert.equal(deep.changes, 0);
+ editFact(deep.tree(), '1.1.1.1.1', { kind: 'hand', name: 'Poison Blade' }); assert.equal(deep.changes, 1);
+ assert.ok(mixedBattleInputError({ kind: 'and', terms: [nested, dial()] }, projected(), 'p'));
+});
+
+void test('controlled scalar battle drafts remain repairable and reject metadata, grouped actions and invalid native values', () => {
+ for (const invalid of [NaN, -1, 0.25, 40.5, Infinity]) {
+ const e = editor({ kind: 'and', terms: [fact(), dial()] });
+ amount(e.tree(), '1.2', invalid); assert.ok(e.error()); assert.match(e.html(), /role="alert"/);
+ click(control(e.tree(), 'or-1.2'));
+ assert.deepEqual(e.value, { kind: 'and', terms: [fact(), { kind: 'or', terms: [dial(invalid), fact()] }] });
+ amount(e.tree(), '1.2.1', 0.5); assert.equal(e.error(), null);
+ select(e.tree(), '1.2.1', 'kind', 'support'); amount(e.tree(), '1.2.1', 0.5); assert.ok(e.error());
+ amount(e.tree(), '1.2.1', 1); assert.equal(e.error(), null);
+ }
+ for (const invalid of [{ kind: 'and', terms: [{ kind: 'dial', compare: 'eq', value: 1 }] },
+ { kind: 'dial', compare: 'eq', value: 1, battle: 'other' }, { kind: 'leader', leader: 'unknown' },
+ { kind: 'weapon', name: 'Unknown card' }, { kind: 'kwisatz', use: 'yes' }])
+ assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), { kind: 'action', action: invalid }] }, projected(), 'p'));
+ const frozen = editor({ kind: 'and', terms: [fact(), dial()] }, true), before = structuredClone(frozen.value);
+ for (const key of ['and-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
+ amount(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'kind', 'leader');
+ editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
+ assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
+});
+
+void test('controlled plan choices use public names and leaders without consulting either hand, affordability or a fixed plan', () => {
+ const game = projected();
+ for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks', 'elites', 'kwisatz'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ for (const key of ['battle', 'deck', 'discard', 'truthMixedBattleAnswers']) Object.defineProperty(game, key, {
+ get() { throw Error(`No ${key} inspection`); }, configurable: true,
+ });
+ const e = editor({ kind: 'and', terms: [fact(), action({ kind: 'leader', leader: 'foreign-leader' })] }, false, game);
+ assert.equal(e.error(), null); const html = e.html();
+ assert.match(html, /Named Commander/); assert.match(html, /Foreign Commander/); assert.match(html, /Cheap Hero \/ Heroine/);
+ assert.match(html, /No leader or hero/); assert.match(html, /actual plan in this battle/);
+ assert.match(html, /already fixed, sealed or revealed/); assert.match(html, /No alternate route is forced/);
+ select(e.tree(), '1.2', 'leader', CHEAP_HERO_TRAITOR); assert.equal(e.error(), null);
+ select(e.tree(), '1.2', 'leader', ''); assert.equal(e.error(), null);
+ select(e.tree(), '1.2', 'kind', 'weapon');
+ const card = find(e.tree(), 'id', 'truth-mixed-battle-1.2-card');
+ assert.deepEqual(elements(card).filter(element => element.type === 'option').map(element => element.props.children), ['Empty slot', ...TRUTH_CARD_NAMES]);
+ select(e.tree(), '1.2', 'card', 'Shield'); assert.equal(e.error(), null);
+ select(e.tree(), '1.2', 'kind', 'kwisatz'); select(e.tree(), '1.2', 'kwisatz', 'no');
+ assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'kwisatz', use: false })] });
+});
+
+void test('controlled mixed battle history shows only own original aggregate answers and remains after the battle', () => {
+ const game = projected();
+ game.mixedBattlePromises.push(
+ { ...game.mixedBattlePromises[0], event: 'no-question', answer: false },
+ { ...game.mixedBattlePromises[0], event: 'complete-question', fulfilled: true },
+ { ...game.mixedBattlePromises[0], event: 'released-question', released: true },
+ { ...game.mixedBattlePromises[0], event: 'foreign-question', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), dial(40)] } },
+ );
+ for (const promise of game.mixedBattlePromises) for (const key of ['binding', 'frozen', 'residual', 'factAnswers', 'compiled'])
+ Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ game.battle = null;
+ const html = markup(game);
+ assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
+ assert.match(html, /you hold Shield/); assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Named Commander/);
+ assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
+ assert.match(html, /Only the aggregate answer is shared/); assert.match(html, /No negates the whole expression/);
+ assert.match(html, /An open commitment completes when both plans are revealed/);
+ assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /No alternate route is forced/);
+ assert.doesNotMatch(html, /Poison Blade|controlled-native-battle|controlled-mixed-battle-question|Fill these plan choices|Complete next preparation/);
+ assert.equal(MixedBattlePromises({ game: { ...game, me: 'missing' } }), null);
+});
+
+void test('controlled mixed-only immediate completion uses the single native Fill control and keeps pure behavior', () => {
+ const guide = assistance(), original = guide.props.game.battle!.compliantPlan!;
+ const button = nativeButton(guide.tree(), 'Fill these plan choices'); assert.equal(button.props.disabled, false);
+ click(button); assert.equal(guide.filled[0], original); assert.equal(guide.sent.length, 0);
+ assert.match(guide.html(), /Review a compliant plan/); assert.doesNotMatch(guide.html(), /you hold Shield/);
+ guide.props.game.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'p' };
+ click(button); assert.equal(guide.filled.length, 2);
+ const pure = assistance(); pure.props.game.mixedBattlePromises = [];
+ pure.props.game.battle!.truthPromises = [{ player: 'p', asker: 'a', answer: true, claim: { kind: 'dial', compare: 'gte', value: 3 } }];
+ click(nativeButton(pure.tree(), 'Fill these plan choices'));
+ assert.equal(pure.filled[0], pure.props.game.battle!.compliantPlan); assert.match(pure.html(), /Truthtrance commitments/);
+ const noGuide = projected(); noGuide.battle!.compliantPlan = null;
+ assert.equal(BattlePromises({ game: noGuide, busy: false, fill() {}, act() {} }), null);
+ for (const state of ['sealed', 'revealed', 'fulfilled'] as const) {
+ const current = assistance();
+ if (state === 'sealed') current.props.game.battle!.submitted.push('p');
+ else if (state === 'revealed') current.props.game.battle!.revealed = true;
+ else current.props.game.mixedBattlePromises[0].fulfilled = true;
+ assert.doesNotMatch(current.html(), /Fill these plan choices/);
+ }
+});
+
+void test('controlled saved Fill callbacks reject stale owner, source, battle, turn, status, guide and quiet-window state', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.me = 'a'; }, game => { game.turn++; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
+ game => { game.battle!.event = 'later-battle'; }, game => { game.battle!.territory = 'carthag'; },
+ game => { game.battle = { ...game.battle! }; }, game => { game.battle!.revealed = true; },
+ game => { game.battle!.submitted.push('p'); }, game => { game.mixedBattlePromises[0].fulfilled = true; },
+ game => { game.mixedBattlePromises[0].released = true; }, game => { game.mixedBattlePromises[0].player = 'a'; },
+ game => { game.mixedBattlePromises[0].event = 'later-question'; }, game => { game.mixedBattlePromises[0].answer = false; },
+ game => { game.mixedBattlePromises[0].asker = 'p'; }, game => { game.mixedBattlePromises[0].mixed = claim(); },
+ game => { game.mixedBattlePromises.push({ ...game.mixedBattlePromises[0], event: 'another-question' }); },
+ game => { game.mixedBattlePromises[0].battle = 'later-battle'; }, game => { game.mixedBattlePromises = []; },
+ game => { game.battle!.compliantPlan = plan(); }, game => { game.battle!.compliantPlan!.dial = 4; },
+ game => { game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true }; },
+ game => { game.players.find(player => player.id === 'p')!.autopilot = 'Easy'; },
+ game => { game.truthtrance = { stage: 'ask', queue: [], passed: [], question: null }; },
+ game => { game.response = {} as NonNullable; },
+ game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ game => { game.phaseOpening = {} as NonNullable; },
+ game => { game.automaticContinuationPending = true; },
+ game => { game.roomControl = { paused: true } as NonNullable; },
+ game => { game.roomControl = { closed: true } as NonNullable; },
+ ];
+ for (const change of changes) {
+ const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices');
+ change(guide.props.game); click(button); assert.equal(guide.filled.length, 0, change.toString());
+ }
+ for (const change of [
+ (guide: ReturnType) => { guide.props.busy = true; },
+ (guide: ReturnType) => { guide.props.game = projected(); },
+ (guide: ReturnType) => { guide.props.fill = () => assert.fail('Do not use a replaced Fill callback'); },
+ ]) {
+ const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices');
+ change(guide); click(button); assert.equal(guide.filled.length, 0);
+ }
+});
+
+void test('controlled native preparation forwards the exact first card action and guards current preparation without auto-sealing', () => {
+ function preparationGuide() {
+ const guide = assistance(), battle = guide.props.game.battle!;
+ battle.compliantPlan = null;
+ battle.compliantPreparation = { plan: plan(), actions: [{ type: 'card', card: 'controlled-ghola', leader: 'named-leader' }], waitingForIncome: false };
+ return guide;
+ }
+ const guide = preparationGuide(), original = guide.props.game.battle!.compliantPreparation!.actions[0];
+ const button = nativeButton(guide.tree(), 'Complete next preparation'); assert.equal(button.props.disabled, false);
+ click(button); assert.equal(guide.sent[0], original); assert.equal(guide.filled.length, 0);
+ assert.doesNotMatch(guide.html(), /Fill these plan choices/); assert.match(guide.html(), /Play Ghola to revive Named Commander/);
+ for (const change of [
+ (game: GameView) => { game.battle!.compliantPreparation = { ...game.battle!.compliantPreparation! }; },
+ (game: GameView) => { game.battle!.compliantPreparation!.actions[0] = { ...original }; },
+ (game: GameView) => { game.battle!.compliantPreparation!.actions[0].card = 'other-card'; },
+ (game: GameView) => { game.battle!.submitted.push('p'); },
+ (game: GameView) => { game.mixedBattlePromises[0].fulfilled = true; },
+ ]) {
+ const fresh = preparationGuide(), saved = nativeButton(fresh.tree(), 'Complete next preparation');
+ change(fresh.props.game); click(saved); assert.equal(fresh.sent.length, 0);
+ }
+ const waiting = preparationGuide(); waiting.props.game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true };
+ assert.match(waiting.html(), /Revival income must resolve/);
+ assert.doesNotMatch(waiting.html(), /Complete next preparation|Fill these plan choices/);
+});
+
+void test('controlled original private preparation remains usable without pure or mixed Truthtrance records', () => {
+ const game = projected(); game.mixedBattlePromises = [];
+ const battle = game.battle!; battle.truthPromises = []; battle.compliantPlan = null;
+ const next: Action = { type: 'card', card: 'controlled-original-ghola', leader: 'named-leader' };
+ battle.compliantPreparation = { plan: plan(), actions: [next], waitingForIncome: false };
+ const guide = assistance(game), button = nativeButton(guide.tree(), 'Complete next preparation');
+ assert.equal(button.props.disabled, false); click(button);
+ assert.equal(guide.sent[0], next); assert.equal(guide.filled.length, 0);
+ assert.match(guide.html(), /Preparation · private/); assert.doesNotMatch(guide.html(), /Fill these plan choices/);
+});
diff --git a/tests/truth-mixed-battle-funding.test.ts b/tests/truth-mixed-battle-funding.test.ts
new file mode 100644
index 00000000..4d0fbee0
--- /dev/null
+++ b/tests/truth-mixed-battle-funding.test.ts
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { truthFactAnswer } from '../game/truthtrance';
+import {
+ actTruthMixedBattle as act, bindTruthMixedBattle as bind,
+ createTruthMixedBattleFixture as fixture, finishTruthMixedBattle as finish,
+ revealTruthMixedBattle as reveal, truthMixedBattleAction as action,
+ truthMixedBattleFact as fact, truthMixedBattleGroup as group,
+ truthMixedBattleInventory as inventory, truthMixedBattlePlayer as player,
+ truthMixedBattlePromise as promise,
+} from './fixture-truth-mixed-battle';
+
+void test('native mixed battle snapshots original spice before an actual bribe debit and still requires its promised projectile', () => {
+ const f = fixture(), mixed = group('and', fact(f.spiceFact), action(f.weaponClaim));
+ const originalSpice = player(f.game, f.target).spice, originalBribes = player(f.game, f.asker).bribes;
+ const defeated = player(f.game, f.asker).leaders.find(leader => leader.id === f.ownPlan.leader);
+ assert.ok(defeated); assert.equal(defeated.strength, 1);
+ assert.ok(originalSpice >= 3); assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
+ let game = bind(f, 'yes', mixed).game; assert.equal(promise(game).fulfilled, false);
+ game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
+ assert.equal(player(game, f.target).spice, originalSpice - 3); assert.equal(player(game, f.asker).bribes, originalBribes + 3);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, false);
+ const before = structuredClone(game); assert.throws(() => act(game, f.target, f.targetPlan)); assert.deepEqual(game, before);
+ game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.deepEqual(promise(game).mixed, mixed); assert.equal(game.battle!.plans[f.target].weapon, f.ordinary);
+ game = finish(game, f); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.asker).leaders.find(leader => leader.id === defeated.id)!.dead, true);
+ assert.equal(player(game, f.target).spice, originalSpice - 3 + defeated.strength);
+ assert.equal(player(game, f.asker).bribes, originalBribes + 3);
+ assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native constant mixed battle Yes stays terminal after a real bribe changes its snapshotted spice fact', () => {
+ const f = fixture(), mixed = group('or', fact(f.spiceFact), action(f.weaponClaim));
+ const originalSpice = player(f.game, f.target).spice;
+ let game = bind(f, 'yes', mixed).game; assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.battle!.truthPromises?.length ?? 0, 0);
+ game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
+ assert.equal(player(game, f.target).spice, originalSpice - 3);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
+ game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(promise(finish(game, f)).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
diff --git a/tests/truth-mixed-battle-runtime.test.ts b/tests/truth-mixed-battle-runtime.test.ts
new file mode 100644
index 00000000..1406257c
--- /dev/null
+++ b/tests/truth-mixed-battle-runtime.test.ts
@@ -0,0 +1,255 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
+import {
+ actTruthMixedBattle as act, advanceTruthMixedBattle as advance,
+ answerTruthMixedBattle as answer, askTruthMixedBattle as ask,
+ bindTruthMixedBattle as bind, createTruthMixedBattleFixture as fixture,
+ declareTruthMixedBattle as declare, finishTruthMixedBattle as finish,
+ revealTruthMixedBattle as reveal, settleTruthMixedBattle as settle,
+ truthMixedBattleAction as action, truthMixedBattleClean as clean,
+ truthMixedBattleFact as fact, truthMixedBattleGroup as group,
+ truthMixedBattleInventory as inventory, truthMixedBattlePlayer as player,
+ truthMixedBattlePromise as promise,
+} from './fixture-truth-mixed-battle';
+
+function rejected(game: Game, actor: string, candidate: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, candidate)); assert.deepEqual(game, before);
+}
+function truthOnce(game: Game, truth: string) {
+ assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+}
+
+void test('native mixed battle uses the exact original base33 paid-shipment prefix and all six native plan conditions', () => {
+ const f = fixture(); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(seat => seat.hand.length === 0));
+ assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
+ assert.equal(f.sourceActionCount, f.planSource.sourceActionCount); assert.equal(f.sourceActionCount, f.actions.length);
+ assert.equal(f.sourceActionCount, f.planSource.base.sourceActionCount - 2);
+ assert.deepEqual(f.game, f.planSource.base.beforePlans); assert.equal(f.game.battle!.event, f.event);
+ 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.deepEqual(player(f.game, f.asker).hand.filter(card => card.effect === 'truthtrance').map(card => card.id), [f.truth]);
+ assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1);
+ assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1); assert.equal(player(f.game, f.target).reserves, 19);
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.heldFact), 'yes');
+ const mixed = group('and', fact(f.heldFact), action({ kind: 'dial', compare: 'eq', value: 0 }),
+ action({ kind: 'support', compare: 'eq', value: 0 }), action(f.weaponClaim),
+ action({ kind: 'defense', name: null }), action({ kind: 'leader', leader: f.base.base.leaders.emperor }),
+ action({ kind: 'kwisatz', use: false }));
+ const before = structuredClone(f.game), bound = bind(f, 'yes', mixed); let game = bound.game;
+ assert.deepEqual(f.game, before); assert.equal(bound.actionCount, 4); assert.ok(clean(game));
+ assert.deepEqual(promise(game).mixed, mixed); assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, f.targetPlan); game = reveal(game, f).game;
+ assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(promise(game).fulfilled, true);
+ assert.equal(promise(game).released, false); truthOnce(game, f.truth);
+ game = finish(game, f); assert.equal(game.battle, null); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native true current projectile OR future weapon answers Yes without binding a later unarmed plan', () => {
+ const f = fixture(), mixed = group('or', fact(f.heldFact), action(f.weaponClaim));
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['yes']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
+ let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.battle!.truthPromises?.length ?? 0, 0); assert.equal(viewGame(game, f.target).battle!.compliantPlan, null);
+ game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
+ assert.equal(promise(finish(game, f)).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native false current Shield AND future weapon answers No without forbidding the later real projectile', () => {
+ const f = fixture(), mixed = group('and', fact(f.absentFact), action(f.weaponClaim));
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.absentFact), 'no');
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['no']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
+ let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.battle!.truthPromises?.length ?? 0, 0); game = reveal(game, f).game;
+ assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+});
+
+void test('native false current Shield OR future weapon Yes still requires the original projectile plan', () => {
+ const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.absentFact), action(f.weaponClaim))).game;
+ rejected(game, f.target, f.targetPlan); assert.equal(promise(game).fulfilled, false);
+ game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+});
+
+void test('native true current projectile AND future weapon No forbids only the promised whole result', () => {
+ const f = fixture(); let game = bind(f, 'no').game;
+ rejected(game, f.target, f.projectilePlan); assert.equal(promise(game).fulfilled, false);
+ game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).answer, false);
+ assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+});
+
+for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast requires a conservative whole mixed battle ${answerValue}`, () => {
+ const f = fixture(), p = player(f.game, f.target); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
+ assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
+ const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), action(f.weaponClaim));
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, [answerValue]);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
+ let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, answerValue === 'yes' ? f.targetPlan : f.projectilePlan);
+ game = reveal(game, f, answerValue === 'yes' ? f.projectilePlan : f.targetPlan).game;
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+});
+
+void test('native nested whole No preserves both actual alternative plan completions', () => {
+ const f = fixture(), mixed = group('and', fact(f.heldFact), group('or',
+ group('and', action({ kind: 'dial', compare: 'eq', value: 1 }), action(f.weaponClaim)),
+ group('and', action({ kind: 'dial', compare: 'eq', value: 0 }), action({ kind: 'weapon', name: null }))));
+ const alternatives: Action[] = [{ ...f.projectilePlan, dial: 0 }, { ...f.targetPlan, dial: 1 }];
+ for (const manual of alternatives) {
+ let game = bind(f, 'no', mixed).game;
+ rejected(game, f.target, f.targetPlan); rejected(game, f.target, { ...f.projectilePlan, dial: 1 });
+ game = reveal(game, f, manual).game; assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.battle!.plans[f.target].dial, manual.dial); assert.equal(game.battle!.plans[f.target].weapon, manual.weapon);
+ assert.deepEqual(promise(game).mixed, mixed); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+ }
+});
+
+void test('native solver and Fill previews and the respondents own seal leave a mixed aggregate pending until actual reveal', () => {
+ const f = fixture(), bound = bind(f), source = structuredClone(bound.game); let game = bound.game;
+ for (let count = 0; count < 3; count++) {
+ const own = viewGame(game, f.target), other = viewGame(game, f.asker);
+ assert.ok(own.battle!.compliantPlan); assert.equal(other.battle!.compliantPlan, null);
+ assert.equal(promise(game).fulfilled, false);
+ const filled: Action = { type: 'battlePlan', ...own.battle!.compliantPlan };
+ assert.equal(filled.weapon, f.ordinary, 'The private native completion still contains the hidden residual');
+ }
+ assert.deepEqual(game, source, 'Projection and candidate search never fulfill or otherwise mutate the authoritative game');
+ game = act(game, f.target, f.projectilePlan); assert.equal(game.battle!.revealed, false);
+ assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).released, false);
+ game = act(game, f.asker, f.ownPlan); assert.equal(game.battle!.revealed, true); assert.equal(promise(game).fulfilled, true);
+ game = finish(game, f); assert.equal(game.battle, null); assert.equal(promise(game).fulfilled, true);
+ assert.equal(promise(game).released, false); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native already sealed and already revealed respondents keep their fixed answer without new residual binding', () => {
+ const f = fixture();
+ for (const revealed of [false, true]) {
+ const actions: { actor: string; action: Action }[] = [];
+ let source = act(f.game, f.target, f.projectilePlan, actions);
+ if (revealed) source = act(source, f.asker, f.ownPlan, actions);
+ const declared = declare(f, source); assert.ok(viewGame(declared.game, f.asker).truthMixedBattleTargets.includes(f.target));
+ const asked = ask(declared.game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['yes']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' }); let game = answer(asked, f.target, 'yes');
+ assert.equal(promise(game).fulfilled, true); assert.equal(game.battle!.truthPromises?.length ?? 0, 0);
+ assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(game.battle!.revealed, revealed);
+ if (!revealed) game = act(game, f.asker, f.ownPlan);
+ assert.equal(promise(finish(game, f)).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+ }
+});
+
+void test('native mixed battle Ask retains exact event territory owner answer and original JSON wire boundaries', () => {
+ const f = fixture(), declared = declare(f), game = declared.game;
+ assert.ok(viewGame(game, f.asker).truthMixedBattleTargets.includes(f.target));
+ rejected(game, f.target, f.questionAction);
+ for (const replacement of [{ battle: 'other-battle' }, { battle: null }, { territory: 'carthag' },
+ { target: 'foreign-seat' }, { binding: { kind: 'constant', value: true } }])
+ rejected(game, f.asker, { type: 'truthAsk', question: { ...f.question, ...replacement } });
+ const wire: Action = JSON.parse(JSON.stringify(f.questionAction));
+ const asked = act(game, f.asker, wire);
+ assert.deepEqual(asked.truthtrance!.question, f.question);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['yes', 'no']);
+ assert.equal(viewGame(asked, f.asker).truthMixedBattleAnswers, null); assert.equal(viewGame(asked, f.target).truthBattleAnswers, null);
+ rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'unknown' });
+ const done = act(asked, f.target, JSON.parse(JSON.stringify({ type: 'truthAnswer', answer: 'yes' })) as Action);
+ rejected(done, f.target, { type: 'truthAnswer', answer: 'yes' }); truthOnce(done, f.truth);
+ let ended = reveal(f.game, f).game; ended = settle(finish(ended, f)); ended = declare(f, ended).game;
+ assert.deepEqual(viewGame(ended, f.asker).truthMixedBattleTargets, []); rejected(ended, f.asker, wire);
+ assert.deepEqual(inventory(done), f.originalCards);
+});
+
+void test('native mixed battle projection retains only the owned raw aggregate and hides residuals from every seat', () => {
+ const f = fixture(), game = bind(f).game;
+ for (const actor of [f.target, f.asker]) {
+ const view = viewGame(game, actor); assert.ok(!('mixedBattleQuestions' in view));
+ assert.deepEqual(view.battle!.truthPromises, []); assert.equal(view.truthMixedBattleAnswers, null);
+ assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
+ if (actor !== f.target) assert.deepEqual(view.mixedBattlePromises, []);
+ else {
+ const row = promise(game);
+ assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'battle', 'event', 'fulfilled', 'mixed', 'player', 'released', 'territory', 'turn']);
+ assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, 1);
+ assert.equal(row.battle, f.event); assert.equal(row.territory, f.territory); assert.equal(row.answer, true);
+ assert.deepEqual(row.mixed, f.mixed); assert.equal(row.fulfilled, false); assert.equal(row.released, false);
+ assert.ok(view.battle!.compliantPlan); assert.equal(view.battle!.compliantPlan.weapon, f.ordinary);
+ }
+ }
+ assert.equal(game.mixedBattleQuestions?.length, 1); assert.equal(game.battle!.truthPromises?.length, 1);
+ assert.equal(game.battle!.truthPromises![0].event, promise(game).event);
+ const revealed = reveal(game, f).game;
+ for (const actor of [f.target, f.asker]) assert.deepEqual(viewGame(revealed, actor).battle!.truthPromises, []);
+});
+
+void test('native earlier mixed Yes constrains the original ordinary Prescience answer before actual sealing', () => {
+ const f = fixture({ window: 'preparation' });
+ assert.deepEqual(f.game, f.planSource.beforePreparation); assert.equal(f.actions.at(-1)!.action.type, 'chooseBattle');
+ assert.equal(f.sourceActionCount, f.sourceCounts.beforePreparation); assert.equal(f.game.battle!.preparation?.kind, 'prescience');
+ let game = bind(f).game; assert.equal(promise(game).fulfilled, false);
+ game = act(game, f.asker, { type: 'prescience', field: 'weapon' }); game = settle(game);
+ assert.equal(game.battle!.preparation?.kind, 'prescienceAnswer');
+ rejected(game, f.target, { type: 'prescienceAnswer', value: null });
+ game = act(game, f.target, { type: 'prescienceAnswer', value: f.ordinary }); game = settle(game);
+ assert.equal(game.battle!.prescience?.value, f.ordinary); assert.equal(promise(game).fulfilled, false);
+ game = advance(game, state => clean(state) && !state.battle?.preparation);
+ game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+});
+
+void test('controlled legacy eventless metadata stays unchanged on read and gains one identity only during the real mixed Ask', () => {
+ const f = fixture(), legacy = structuredClone(f.game);
+ // Controlled compatibility case: omit only event metadata, never claim this
+ // historical eventless representation was emitted by the original source.
+ delete legacy.battle!.event;
+ const before = structuredClone(legacy);
+ for (const actor of [f.target, f.asker]) assert.equal(viewGame(legacy, actor).battle!.event, null);
+ assert.deepEqual(legacy, before);
+ const declared = declare(f, legacy); assert.equal(declared.game.battle!.event, undefined);
+ rejected(declared.game, f.asker, f.questionAction);
+ const eventless: Action = { type: 'truthAsk', question: { ...f.question, battle: null } };
+ const asked = act(declared.game, f.asker, JSON.parse(JSON.stringify(eventless)) as Action);
+ const assigned = asked.battle!.event; assert.equal(typeof assigned, 'string'); assert.ok(assigned);
+ assert.deepEqual(asked.truthtrance!.question, { ...f.question, battle: assigned });
+ assert.equal(viewGame(asked, f.target).battle!.event, assigned);
+ let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).battle, assigned);
+ game = reveal(game, f).game; assert.equal(game.battle!.event, assigned); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('all four minimal profiles answer a native mixed battle and reach a legal seal through the private native fallback', () => {
+ const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(asked); const answering = viewGame(game, f.target);
+ answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const answerAction = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
+ assert.ok(answering.truthMixedBattleAnswers?.includes(answerAction.answer as 'yes' | 'no' | 'unknown'));
+ game = act(game, f.target, answerAction); const planning = viewGame(game, f.target);
+ planning.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ assert.deepEqual(planning.battle!.truthPromises, []); assert.ok(planning.battle!.compliantPlan);
+ const choices = botActions(planning).filter(candidate => candidate.type === 'battlePlan');
+ assert.ok(choices.some(candidate => candidate.weapon === planning.battle!.compliantPlan!.weapon), difficulty);
+ let accepted = false;
+ for (const candidate of choices) {
+ const before = structuredClone(game);
+ try { game = act(game, f.target, candidate); accepted = true; break; }
+ catch { assert.deepEqual(game, before); }
+ }
+ assert.equal(accepted, true, `${difficulty} reaches an accepted original battle plan`);
+ assert.equal(game.battle!.plans[f.target].weapon, answerAction.answer === 'yes' ? f.ordinary : null);
+ assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, f.ownPlan);
+ assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
+ }
+});