({ kind: 'hand', name: 'Shield' });
@@ -242,6 +244,7 @@ export function Truthtrance({
target,
fact,
}
+ : kind === 'futureBattleLeader' ? {kind, target, turn: g.turn, leader: futureBattleLeader}
: kind === 'battlePlan'
? {
kind,
@@ -521,6 +524,13 @@ export function Truthtrance({
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
+ ) : w.question!.kind === 'futureBattleLeader' ? (
+ <>
+ Your answer applies only if you fight a later battle this turn.
+ {(g.truthFutureBattleLeaderAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, {type: 'truthAnswer', answer})}
+
)}
+ >
) : w.question!.kind === 'battlePlan' ? (
<>
@@ -658,6 +668,7 @@ export function Truthtrance({
Combine current facts and this-turn shipment
Write a game question
+ {g.truthFutureBattleLeaderTargets.length > 0 && Ask about a leader in a later battle }
{g.battle && Combine current facts and this battle plan }
{g.battle && (
@@ -891,6 +902,11 @@ export function Truthtrance({
{invalidMixed ?? truthQuestionText(question, leaderName, locationName, name)}
}
>
+ ) : kind === 'futureBattleLeader' ? (
+ act({type: 'truthAsk', question: {kind: 'futureBattleLeader', target: player, turn: g.turn, leader}})} />
) : kind === 'battlePlan' ? (
question.kind === 'battlePlan' && (
>
)}
- {!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
+ {!['fact', 'battlePlan', 'futureBattleLeader', 'shipment'].includes(kind) && button(
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) ||
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 23aea532..20bead02 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,16 @@ open mode gates.
## Current checkpoint and work
+**Conditional future-battle Truthtrance — 28 October. Prototyped / Partial.**
+A quiet Basic classic game can ask whether a named native leader will be used
+in the respondent's first later battle this turn, if there is one. The answer
+waits for the actual battle, constrains its plan and expires without forcing a
+fight. Controls include a persistent own reminder; all four AI profiles can
+answer and follow the native plan. Six related offline files pass 49 cases and
+the application builds. The conditional timing is provisional; combinations,
+expansion factions, Advanced and deployed acceptance remain open.
+[Scope](TRUTHTRANCE_TURN_ACTIONS.md#conditional-next-battle-leader-question--28-october-2026).
+
**Kull during Basic Guild shipment and Revival responses — 28 October.
Prototyped / Partial.** Native CHOAM/Richese Kull interruptions can suspend
eligible Karama responses while their original shipment or Revival remains
@@ -46,12 +56,16 @@ parents, optional modules and deployed acceptance remain open.
**In progress: Gitea migration — 28 October.** The local `origin` now tracks
`https://git.juiced.computer/admin/Dune`. A repository-scoped deploy key pushes
-over verified SSH on the local network; `main` reached `91ce096`. A dedicated
+over verified SSH on the local network; `main` reached `71009d7`. A dedicated
repository-scoped TrueNAS runner is active with its own Docker daemon and a
package-write Actions secret is configured for Gitea's registry. The first
-Gitea-native build and image publication are in progress and require observed
-success before the migration is certified. Existing NAS deployment and its
-persistent storage remain untouched.
+Gitea-native job built the application but its isolated HTTP check failed:
+job and test-container loopback addresses are in separate nested Docker
+network namespaces. With user approval the dedicated runner now shares its
+own network namespace with its jobs, while retaining its separate Docker
+daemon; the runner reconnected. A fresh end-to-end build and registry
+publication remain unverified. Existing NAS deployment and its persistent
+storage remain untouched.
**Semuta before Basic Moritani ally retention — 28 October. Prototyped /
Partial.** In the bare Basic Semuta preview, mandatory loser cards form an
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 004030d7..75d32681 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,13 @@
# Rule decision index
+## Conditional future-battle leader question — 28 October 2026
+
+The printed current-turn question can provisionally refer to a named native
+leader in the respondent's first later battle, conditional on one occurring.
+An actual battle binds the existing plan rules; no battle ends the opportunity
+without forcing one. This digital timing interpretation and its limited Basic
+scope are described in [the feature contract](TRUTHTRANCE_TURN_ACTIONS.md#conditional-next-battle-leader-question--28-october-2026).
+
## Kull and Semuta in Basic battle preparation — 28 October 2026
The printed Kull power interrupts another player's Karama activation. In the
diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md
index 52fd2a51..924b791a 100644
--- a/docs/TRUTHTRANCE_TURN_ACTIONS.md
+++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md
@@ -1,5 +1,26 @@
# Truthtrance combined current-turn actions
+## Conditional next-battle leader question — 28 October 2026
+
+**Prototyped / Partial.** In a quiet Basic Shipment and Movement window with
+classic factions and no optional modules, a player can ask whether another
+player will use a named native leader in that player's first later battle this
+turn, if one occurs. A definite answer creates a waiting receipt; it does not
+require a shipment, movement or battle. At the first actual battle involving
+that player the receipt binds its real battle event and uses the native plan
+validator and guidance. If no battle occurs, the receipt expires when Battle
+ends. An independently impossible answer is released by the existing battle
+promise reconciliation. Another structured action answer for that respondent
+is guarded while the conditional answer is active; broader promise composition,
+expansion factions, optional modules and Advanced are not supported here.
+
+The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
+permits game-related current-turn action questions and requires honoring an
+answer while possible. The first-battle binding and no-opportunity expiry are
+provisional online interpretations, not specific publisher rulings. The
+[native runtime tests](../tests/truth-future-battle-leader-runtime.test.ts)
+exercise both answers and expiry without inventing a battle.
+
## Printed flight and pre-leader cards — 27 October 2026
**Prototyped / Partial.** `cardPlay/ornithopter` counts the first accepted
diff --git a/game/bots.ts b/game/bots.ts
index a41b8846..218aa3fe 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1460,6 +1460,10 @@ function policyActions(g: GameView): Action[] {
'unknown');
return [{ type: 'truthAnswer', answer }];
}
+ if (w.question.kind === 'futureBattleLeader') {
+ const choices = g.truthFutureBattleLeaderAnswers ?? ['unknown'];
+ return [{ type: 'truthAnswer', answer: choices[0] }];
+ }
if (w.question.kind === 'battlePlan') {
const choices = g.truthBattleAnswers ?? ['unknown'];
const claim = w.question.claim;
diff --git a/game/engine.ts b/game/engine.ts
index 53265169..ea01d95d 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,5 +1,7 @@
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
+import { createFutureBattleLeader, bindFutureBattleLeader, settleFutureBattleLeader, releaseFutureBattleLeader,
+ expireFutureBattleLeader, type FutureBattleLeaderReceipt } from './future-battle-leader';
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
FactionDraftError, type FactionDraft } from './faction-draft';
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
@@ -682,6 +684,7 @@ import {
type TruthQueueEntry,
type TruthRecord,
type TruthAnswer,
+ type TruthQuestion,
type MixedMovementClaim,
} from './truthtrance';
import type { MixedShipmentExpression } from './mixed-shipment-question';
@@ -2319,6 +2322,7 @@ export type Game = {
};
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
+ futureBattleLeaderPromises?: FutureBattleLeaderReceipt[];
travelPromises?: TravelPromise[];
turnActionPromises?: TurnActionPromise[];
/** Actual accepted printed-role uses; old games need no baseline retrofit. */
@@ -7550,6 +7554,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
...(['travel', 'turnActions'].includes(next.record.question.kind) ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(g.turnActionPromises ? { turnActionPromises: g.turnActionPromises, bribePromises: g.bribePromises,
bribeExpressionPromises: g.bribeExpressionPromises, bribePromiseSequence: g.bribePromiseSequence } : {}),
+ ...(next.record.question.kind === 'futureBattleLeader' ? {futureBattleLeaderPromises: g.futureBattleLeaderPromises} : {}),
...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}),
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
@@ -8622,6 +8627,13 @@ function treacheryDiscardIntegrity(g: Game) {
}
const q = record.question,
promise = c.promise;
+ if (q.kind === 'futureBattleLeader') {
+ const binding = g.futureBattleLeaderPromises?.at(-1);
+ requireRule(binding && binding.turn === g.turn && binding.asker === record.asker &&
+ binding.player === q.target && binding.leader === q.leader &&
+ binding.answer === (record.answer === 'yes') && binding.status === 'waiting' && !binding.battle,
+ 'The completed Truthtrance lost its conditional battle answer.');
+ }
const expectedShipment = q.kind === 'shipment' || q.kind === 'mixedShipment';
const expectedBattle =
q.kind === 'battlePlan' &&
@@ -31674,6 +31686,61 @@ function battleTruthAnswers(
answers.push(answer ? 'yes' : 'no');
return answers.length ? answers : ['unknown'];
}
+type FutureBattleLeaderQuestion = Extract;
+function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeaderQuestion): TruthAnswer[] {
+ const otherPromises: {player: string; turn?: number; fulfilled?: boolean; released?: boolean}[] = [
+ ...(g.travelPromises ?? []), ...(g.shipmentPromises ?? []), ...(g.movementPromises ?? []),
+ ...(g.movementExpressionPromises ?? []), ...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? []),
+ ...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? []),
+ ...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
+ ...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
+ ];
+ if (q.turn !== g.turn || q.target !== p.id || g.phase !== 5 ||
+ (g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
+ (row.status === 'waiting' || row.status === 'bound')) ||
+ otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
+ liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length) return ['unknown'];
+ const available = p.leaders.filter(leader => nativeAvailable(leader) && !leader.usedAt && leader.faction === p.faction);
+ const result: TruthAnswer[] = [];
+ if (available.some(leader => leader.id === q.leader)) result.push('yes');
+ if (available.some(leader => leader.id !== q.leader) || p.hand.some(card => card.kind === 'hero')) result.push('no');
+ return result.length ? result : ['unknown'];
+}
+function bindFutureBattleLeaderTruth(g: Game, p: Player, q: FutureBattleLeaderQuestion, answer: boolean, asker: string) {
+ requireRule(futureBattleLeaderTruthAnswers(g, p, q).includes(answer ? 'yes' : 'no'),
+ 'Choose an answer supported by your own available leaders.');
+ (g.futureBattleLeaderPromises ??= []).push(createFutureBattleLeader(
+ { turn: g.turn, asker, player: p.id, leader: q.leader, answer }, leaders(p.faction).map(leader => leader.id)));
+}
+function bindFutureBattleLeaderQuestions(g: Game) {
+ const battle = g.battle;
+ if (!battle?.event || !g.futureBattleLeaderPromises) return;
+ g.futureBattleLeaderPromises = g.futureBattleLeaderPromises.map(receipt => {
+ const bound = bindFutureBattleLeader(receipt, {turn: g.turn, event: battle.event!, attacker: battle.attacker,
+ defender: battle.defender, native: true, plansOpen: !battle.revealed});
+ if (bound !== receipt) {
+ const claim: PlanClaim = {kind: 'leader', leader: bound.leader};
+ bindBattleTruth(g, getPlayer(g, bound.player), claim, bound.answer, bound.asker);
+ }
+ return bound;
+ });
+}
+function reconcileFutureBattleLeaderQuestions(g: Game) {
+ const battle = g.battle;
+ if (!g.futureBattleLeaderPromises) return;
+ g.futureBattleLeaderPromises = g.futureBattleLeaderPromises.map(receipt => {
+ if (receipt.status === 'waiting' && (g.turn > receipt.turn ||
+ receipt.turn === g.turn && (g.phase > 6 || g.status !== 'playing')))
+ return expireFutureBattleLeader(receipt, {turn: receipt.turn, battlePhaseEnded: true});
+ if (receipt.status !== 'bound' || !battle || battle.event !== receipt.battle) return receipt;
+ const bound = battle.truthPromises?.find(row => row.player === receipt.player && row.asker === receipt.asker &&
+ row.answer === receipt.answer && row.claim.kind === 'leader' && row.claim.leader === receipt.leader);
+ if (bound?.released) return releaseFutureBattleLeader(receipt, {turn: receipt.turn, battle: receipt.battle});
+ if (battle.revealed && bound) return settleFutureBattleLeader(receipt, {turn: receipt.turn,
+ battle: receipt.battle!, player: receipt.player, revealed: true, leader: battle.plans[receipt.player].leader});
+ return receipt;
+ });
+}
function bindBattleTruth(
g: Game,
p: Player,
@@ -40911,6 +40978,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
finishLeaderSkillCustody(g, state);
leaderSkillsIntegrity(g);
reconcileBattlePromises(g, { actor: id, action });
+ reconcileFutureBattleLeaderQuestions(g);
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
reconcilePhaseBids(g, { actor: id, action });
@@ -41575,6 +41643,8 @@ function applyActionInner(
bindMixedBattle: bindMixedBattleTruth,
battleAnswers: battleTruthAnswers,
bindBattle: bindBattleTruth,
+ futureBattleLeaderAnswers: futureBattleLeaderTruthAnswers,
+ bindFutureBattleLeader: bindFutureBattleLeaderTruth,
cardBlock: transferCardBlock,
})
)
@@ -45038,6 +45108,7 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
+ bindFutureBattleLeaderQuestions(g);
if (combined) {
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz!.id, ally: ally!.id, lead: null, canceled: false };
const opponent = [choice.attacker, choice.defender].find(id => id !== ecaz!.id && id !== ally!.id)!;
@@ -45200,6 +45271,7 @@ function applyActionInner(
b.revealed = true;
recordTurnActionRevealedPlans(g);
reconcileMixedBattleQuestions(g);
+ reconcileFutureBattleLeaderQuestions(g);
for (const plan of Object.values(b.plans)) {
const l = g.players
.flatMap((p) => p.leaders)
@@ -45717,6 +45789,17 @@ export function viewGame(state: Game, id: string, now?: number) {
g.truthtrance.question.kind === 'battlePlan'
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
: null,
+ truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
+ g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
+ ? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
+ truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && g.phase === 5 &&
+ !g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
+ !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
+ !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard
+ ? g.players.filter(player => player.id !== id && !(g.futureBattleLeaderPromises ?? []).some(row =>
+ row.turn === g.turn && row.player === player.id && (row.status === 'waiting' || row.status === 'bound')))
+ .map(player => player.id) : [],
+ futureBattleLeaderPromises: (g.futureBattleLeaderPromises ?? []).filter(row => row.player === id),
bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
karamaAuctionCards: quotedAuctionKaramaCards(g, me),
karamaShipmentCards: quotedShipmentKaramaCards(g, me),
diff --git a/game/future-battle-leader.ts b/game/future-battle-leader.ts
new file mode 100644
index 00000000..2641426e
--- /dev/null
+++ b/game/future-battle-leader.ts
@@ -0,0 +1,127 @@
+/** A conditional commitment for the respondent's first later native battle.
+ * Waiting does not require a battle to occur. The engine owns plan legality,
+ * answer feasibility and the decision that an obligation became impossible. */
+export type FutureBattleLeaderReceipt = {
+ turn: number;
+ asker: string;
+ player: string;
+ leader: string;
+ answer: boolean;
+ battle?: string;
+ status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released';
+};
+
+export type FutureBattleLeaderSource = {
+ turn: number;
+ event: string;
+ attacker: string;
+ defender: string;
+ native: boolean;
+ plansOpen: boolean;
+};
+
+export class FutureBattleLeaderError extends Error {}
+
+function check(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new FutureBattleLeaderError(message);
+}
+
+function validId(value: unknown): value is string {
+ return typeof value === 'string' && value.length <= 256 && value.trim() === value && value.length > 0;
+}
+
+function validTurn(value: unknown): value is number {
+ return Number.isSafeInteger(value) && (value as number) > 0;
+}
+
+function validateReceipt(receipt: FutureBattleLeaderReceipt): void {
+ check(!!receipt && typeof receipt === 'object' && !Array.isArray(receipt), 'Invalid future battle receipt.');
+ check(validTurn(receipt.turn) && validId(receipt.asker) && validId(receipt.player) &&
+ receipt.asker !== receipt.player && validId(receipt.leader) && typeof receipt.answer === 'boolean',
+ 'Invalid future battle question.');
+ check(['waiting', 'bound', 'settled', 'expired', 'released'].includes(receipt.status),
+ 'Invalid future battle status.');
+ check(receipt.battle === undefined || validId(receipt.battle), 'Invalid future battle identity.');
+ check(receipt.status !== 'waiting' && receipt.status !== 'expired' || receipt.battle === undefined,
+ 'A waiting or expired question cannot name a battle.');
+ check(receipt.status !== 'bound' && receipt.status !== 'settled' || receipt.battle !== undefined,
+ 'A bound or settled question must name its battle.');
+}
+
+/** Caller supplies the respondent's public, native leader identities; no hand is inspected. */
+export function createFutureBattleLeader(
+ question: Pick,
+ nativeLeaders: readonly string[],
+): FutureBattleLeaderReceipt {
+ check(!!question && typeof question === 'object', 'Invalid future battle question.');
+ const receipt: FutureBattleLeaderReceipt = { turn: question.turn, asker: question.asker,
+ player: question.player, leader: question.leader, answer: question.answer, status: 'waiting' };
+ validateReceipt(receipt);
+ check(Array.isArray(nativeLeaders) && nativeLeaders.every(validId) &&
+ new Set(nativeLeaders).size === nativeLeaders.length && nativeLeaders.includes(receipt.leader),
+ 'Choose one of the respondent’s native leader identities.');
+ return receipt;
+}
+
+/** Observe a real battle; unrelated battles and nonmatching turns leave it waiting. */
+export function bindFutureBattleLeader(
+ receipt: FutureBattleLeaderReceipt,
+ source: FutureBattleLeaderSource,
+): FutureBattleLeaderReceipt {
+ validateReceipt(receipt);
+ check(!!source && typeof source === 'object' && validTurn(source.turn) && validId(source.event) &&
+ validId(source.attacker) && validId(source.defender) && source.attacker !== source.defender &&
+ typeof source.native === 'boolean' && typeof source.plansOpen === 'boolean',
+ 'Invalid observed battle.');
+ if (receipt.status !== 'waiting' || receipt.turn !== source.turn || !source.native ||
+ (receipt.player !== source.attacker && receipt.player !== source.defender))
+ return receipt;
+ check(source.plansOpen, 'The respondent’s first qualifying battle is already past plan preparation.');
+ return { ...receipt, battle: source.event, status: 'bound' };
+}
+
+/** Only the actual revealed respondent plan can settle the linked receipt. */
+export function settleFutureBattleLeader(
+ receipt: FutureBattleLeaderReceipt,
+ observed: { turn: number; battle: string; player: string; revealed: boolean; leader: string | null },
+): FutureBattleLeaderReceipt {
+ validateReceipt(receipt);
+ check(!!observed && typeof observed === 'object' && validTurn(observed.turn) && validId(observed.battle) &&
+ validId(observed.player) && typeof observed.revealed === 'boolean' &&
+ (observed.leader === null || validId(observed.leader)), 'Invalid revealed battle plan.');
+ check(receipt.turn === observed.turn && receipt.battle === observed.battle && receipt.player === observed.player,
+ 'The revealed plan does not belong to this question.');
+ check((receipt.status === 'bound' || receipt.status === 'settled') && observed.revealed,
+ 'Only an actual revealed bound plan can settle this question.');
+ check((observed.leader === receipt.leader) === receipt.answer,
+ 'The revealed plan contradicts the active future battle answer.');
+ if (receipt.status === 'settled') return receipt;
+ return { ...receipt, status: 'settled' };
+}
+
+/** The caller must independently establish impossibility before invoking this. */
+export function releaseFutureBattleLeader(
+ receipt: FutureBattleLeaderReceipt,
+ source: { turn: number; battle?: string },
+): FutureBattleLeaderReceipt {
+ validateReceipt(receipt);
+ check(!!source && typeof source === 'object' && validTurn(source.turn) &&
+ (source.battle === undefined || validId(source.battle)) && source.turn === receipt.turn &&
+ source.battle === receipt.battle, 'The release does not belong to this question.');
+ if (receipt.status === 'released') return receipt;
+ check(receipt.status === 'waiting' || receipt.status === 'bound', 'This question cannot be released.');
+ return { ...receipt, status: 'released' };
+}
+
+/** Only unbound questions expire when their original Battle phase has ended. */
+export function expireFutureBattleLeader(
+ receipt: FutureBattleLeaderReceipt,
+ source: { turn: number; battlePhaseEnded: boolean },
+): FutureBattleLeaderReceipt {
+ validateReceipt(receipt);
+ check(!!source && typeof source === 'object' && validTurn(source.turn) &&
+ typeof source.battlePhaseEnded === 'boolean' && source.turn === receipt.turn,
+ 'The phase end does not belong to this question.');
+ if (!source.battlePhaseEnded || receipt.status !== 'waiting') return receipt;
+ return { ...receipt, status: 'expired' };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 42c9fc85..da7abaac 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3305,6 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
+ 'In a quiet Basic Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
@@ -3341,7 +3342,7 @@ export const RULE_TOPICS: RuleTopic[] = [
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola/Box/Distrans/Sapho, accepted Ornithopter flights, native Residual Poison and completed Semuta exchanges, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide, with a separate conditional future-battle leader editor and reminder.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truth-future-battle-leader.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-future-battle-leader-controls.test.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed roles, exact fresh Semuta controls and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-flight-poison.ts', 'tests/truth-flight-poison-runtime.test.ts', 'tests/fixture-truth-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', 'tests/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 92481489..1b7d014e 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -117,6 +117,7 @@ export type TruthQuestion =
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
+ | { kind: 'futureBattleLeader'; target: string; turn: number; leader: string }
| { kind: 'fact'; target: string; fact: TruthFact }
| {
kind: 'freeform';
@@ -269,6 +270,9 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
+ if (v.kind !== 'fact' && v.kind !== 'freeform' && v.kind !== 'futureBattleLeader' && g.futureBattleLeaderPromises?.some(row =>
+ row.player === v.target && row.turn === g.turn && (row.status === 'waiting' || row.status === 'bound')))
+ throw new TruthError('Finish this player’s conditional battle answer before binding another action question.');
if (v.kind === 'charity') {
check(Object.keys(v).sort().join(',') === 'kind,target,turn' && v.turn === g.turn &&
g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
@@ -593,6 +597,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
return { kind: 'mixedBattlePlan', target: v.target as string, battle: g.battle.event,
territory: g.battle.territory, mixed };
}
+ if (v.kind === 'futureBattleLeader') {
+ const target = g.players.find(player => player.id === v.target)!;
+ check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
+ v.turn === g.turn && typeof v.leader === 'string' &&
+ leaders(target.faction).some(leader => leader.id === v.leader) &&
+ g.status === 'playing' && !g.advanced && g.phase === 5 && !g.expansions.length &&
+ !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
+ !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
+ !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
+ 'Choose a native leader and a quiet Basic turn before Battle.');
+ return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
+ }
if (v.kind === 'battlePlan') {
check(
g.phase === 6 &&
@@ -670,7 +686,8 @@ export function validateSavedTruthtrance(g: Game) {
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
);
}
- if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' || window?.question?.kind === 'traitorCall') {
+ if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
+ window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
'The saved fact question must retain its asking player and answer stage.');
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
@@ -794,6 +811,8 @@ export function truthQuestionText(
return `Will you ${shipmentClaimText(q)} this turn?`;
if (q.kind === 'battlePlan')
return `In this battle in ${locationName(q.territory)}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
+ if (q.kind === 'futureBattleLeader')
+ return `If you fight a battle later this turn, will you use ${leaderName(q.leader)} in your first such battle?`;
const clause = (f: TruthFact): string => {
if (isKnowledgeFact(f)) return knowledgeFactText(f);
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
@@ -941,6 +960,8 @@ export function resolveTruthAction(
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[];
+ futureBattleLeaderAnswers: (g: Game, p: Player, question: Extract) => TruthAnswer[];
+ bindFutureBattleLeader: (g: Game, p: Player, question: Extract, answer: boolean, asker: string) => void;
bindBattle: (
g: Game,
p: Player,
@@ -1315,6 +1336,11 @@ export function resolveTruthAction(
current.player,
);
}
+ if (q.kind === 'futureBattleLeader') {
+ check(effects.futureBattleLeaderAnswers(g, p, q).includes(a.answer),
+ 'Choose an answer compatible with your available native leaders and earlier commitments.');
+ if (a.answer !== 'unknown') effects.bindFutureBattleLeader(g, p, q, a.answer === 'yes', current.player);
+ }
// Freeform semantics cannot be certified by a deterministic engine. AI does not invent an answer.
if (q.kind === 'freeform' && p.bot)
check(
diff --git a/tests/fixture-truth-future-battle-leader.ts b/tests/fixture-truth-future-battle-leader.ts
new file mode 100644
index 00000000..1110edf4
--- /dev/null
+++ b/tests/fixture-truth-future-battle-leader.ts
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ actTruthTraitorDecision, advanceTruthTraitorDecision, createTruthTraitorDecisionFixture,
+ truthTraitorDecisionClean, truthTraitorDecisionInventory, truthTraitorDecisionPlayer,
+ type TruthTraitorDecisionStep,
+} from './fixture-truth-traitor-decision';
+
+export const actFutureBattleLeader = actTruthTraitorDecision;
+export const advanceFutureBattleLeader = advanceTruthTraitorDecision;
+export const futureBattleLeaderClean = truthTraitorDecisionClean;
+export const futureBattleLeaderInventory = truthTraitorDecisionInventory;
+export const futureBattleLeaderPlayer = truthTraitorDecisionPlayer;
+export type FutureBattleLeaderStep = TruthTraitorDecisionStep;
+export type FutureBattleLeaderRecord = {
+ turn: number; asker: string; player: string; leader: string; answer: boolean;
+ battle?: string; status: 'waiting' | 'bound' | 'settled' | 'expired' | 'released';
+};
+export function futureBattleLeaderRecords(game: Game): FutureBattleLeaderRecord[] {
+ return (game as Game & { futureBattleLeaderPromises?: FutureBattleLeaderRecord[] }).futureBattleLeaderPromises ?? [];
+}
+
+/** Reuse the source's genuinely earned Basic Movement boundary. Only its
+ * original unopened card/spice order and existing scalar lotteries were
+ * selected; no battle, private plan or answer is installed. */
+export function createFutureBattleLeaderFixture() {
+ const base = createTruthTraitorDecisionFixture();
+ const game = structuredClone(base.beforeShipment);
+ assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, base.target);
+ assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
+ assert.ok(!game.battle && futureBattleLeaderClean(game));
+ assert.ok(futureBattleLeaderPlayer(game, base.asker).hand.some(card => card.id === base.truth));
+ const leader = base.leaders.emperor;
+ const otherLeader = futureBattleLeaderPlayer(game, base.target).leaders.find(item => item.id !== leader && !item.dead && !item.usedAt);
+ assert.ok(otherLeader);
+ assert.deepEqual(futureBattleLeaderInventory(game), base.originalCards);
+ const question = { kind: 'futureBattleLeader', target: base.target, turn: game.turn, leader };
+ const ask = { type: 'truthAsk', question } as unknown as Action;
+ return { base, game, source: structuredClone(game), question, ask, leader, otherLeader: otherLeader.id,
+ asker: base.asker, target: base.target, truth: base.truth, originalCards: base.originalCards,
+ targetPlan: base.targetPlan, ownPlan: base.ownPlan, territory: base.territory,
+ shipmentStep: base.shipmentStep };
+}
+export type FutureBattleLeaderFixture = ReturnType;
+
+export function declareFutureBattleLeader(fixture: FutureBattleLeaderFixture): Game {
+ let game = actFutureBattleLeader(fixture.game, fixture.asker, { type: 'card', card: fixture.truth });
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player);
+ game = actFutureBattleLeader(game, player.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ return actFutureBattleLeader(game, fixture.asker, fixture.ask);
+}
+export function answerFutureBattleLeader(game: Game, fixture: FutureBattleLeaderFixture, answer: 'yes' | 'no'): Game {
+ return actFutureBattleLeader(game, fixture.target, { type: 'truthAnswer', answer });
+}
+
+/** Use the source's actual paid shipment, then the native choice and
+ * preparation controls; the caller alone seals plans and traitor votes. */
+export function reachFutureBattle(game: Game, fixture: FutureBattleLeaderFixture): Game {
+ game = actFutureBattleLeader(game, fixture.shipmentStep.actor, fixture.shipmentStep.action);
+ game = advanceFutureBattleLeader(game, futureBattleLeaderClean);
+ game = actFutureBattleLeader(game, fixture.target, { type: 'endMovement' });
+ game = advanceFutureBattleLeader(game, state => state.phase === 6 && futureBattleLeaderClean(state) && !!state.active && !state.battle);
+ const chooser = game.active!;
+ const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === fixture.territory &&
+ [row.attacker, row.defender].includes(fixture.target));
+ assert.ok(choice);
+ game = actFutureBattleLeader(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
+ assert.ok(game.battle?.event);
+ return advanceFutureBattleLeader(game, state => !!state.battle && futureBattleLeaderClean(state) &&
+ !state.battle.preparation && state.battle.preLeader?.closed !== false);
+}
diff --git a/tests/future-battle-leader.test.ts b/tests/future-battle-leader.test.ts
new file mode 100644
index 00000000..04b14ea1
--- /dev/null
+++ b/tests/future-battle-leader.test.ts
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ FutureBattleLeaderError,
+ bindFutureBattleLeader,
+ createFutureBattleLeader,
+ expireFutureBattleLeader,
+ releaseFutureBattleLeader,
+ settleFutureBattleLeader,
+ type FutureBattleLeaderReceipt,
+ type FutureBattleLeaderSource,
+} from '../game/future-battle-leader';
+
+const question = { turn: 2, asker: 'atreides', player: 'emperor', leader: 'fenring', answer: true };
+const names = ['fenring', 'aramsham'];
+const battle: FutureBattleLeaderSource = {
+ turn: 2, event: 'battle-1', attacker: 'emperor', defender: 'guild', native: true, plansOpen: true,
+};
+const revealed = { turn: 2, battle: 'battle-1', player: 'emperor', revealed: true, leader: 'fenring' };
+
+void test('conditional question waits for the first actual qualifying native battle', () => {
+ const waiting = createFutureBattleLeader(question, names);
+ const original = structuredClone(waiting);
+ assert.equal(waiting.status, 'waiting');
+ for (const unrelated of [
+ { ...battle, turn: 3 },
+ { ...battle, attacker: 'fremen' },
+ { ...battle, native: false },
+ ]) assert.deepEqual(bindFutureBattleLeader(waiting, unrelated), waiting);
+ assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, plansOpen: false }), FutureBattleLeaderError,
+ 'Missing an already-opened qualifying battle must not silently bind a later one.');
+ const bound = bindFutureBattleLeader(waiting, battle);
+ assert.deepEqual(bound, { ...original, battle: 'battle-1', status: 'bound' });
+ assert.deepEqual(waiting, original, 'Binding cannot mutate the original receipt.');
+ assert.deepEqual(bindFutureBattleLeader(bound, { ...battle, event: 'battle-2' }), bound,
+ 'A later battle must not replace the original qualifying battle.');
+ assert.deepEqual(JSON.parse(JSON.stringify(bound)), bound);
+});
+
+void test('yes settles only on an actual compliant revealed plan and the original battle', () => {
+ const bound = bindFutureBattleLeader(createFutureBattleLeader(question, names), battle);
+ assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, revealed: false }), FutureBattleLeaderError);
+ assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, battle: 'battle-2' }), FutureBattleLeaderError);
+ assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, player: 'guild' }), FutureBattleLeaderError);
+ assert.throws(() => settleFutureBattleLeader(bound, { ...revealed, leader: 'aramsham' }), FutureBattleLeaderError);
+ const settled = settleFutureBattleLeader(bound, revealed);
+ assert.equal(settled.status, 'settled');
+ assert.equal(bound.status, 'bound');
+ assert.deepEqual(settleFutureBattleLeader(settled, revealed), settled);
+ assert.throws(() => settleFutureBattleLeader(settled, { ...revealed, leader: null }), FutureBattleLeaderError);
+});
+
+void test('no forbids only the named leader at that first battle', () => {
+ const waiting = createFutureBattleLeader({ ...question, answer: false }, names);
+ const bound = bindFutureBattleLeader(waiting, battle);
+ assert.throws(() => settleFutureBattleLeader(bound, revealed), FutureBattleLeaderError);
+ assert.equal(settleFutureBattleLeader(bound, { ...revealed, leader: 'aramsham' }).status, 'settled');
+ assert.equal(settleFutureBattleLeader(bound, { ...revealed, leader: null }).status, 'settled');
+});
+
+void test('a turn with no eligible battle expires without inventing a battle', () => {
+ const waiting = createFutureBattleLeader(question, names);
+ assert.deepEqual(expireFutureBattleLeader(waiting, { turn: 2, battlePhaseEnded: false }), waiting);
+ const expired = expireFutureBattleLeader(waiting, { turn: 2, battlePhaseEnded: true });
+ assert.equal(expired.status, 'expired');
+ assert.equal(expired.battle, undefined);
+ assert.deepEqual(bindFutureBattleLeader(expired, battle), expired);
+ assert.throws(() => expireFutureBattleLeader(waiting, { turn: 3, battlePhaseEnded: true }), FutureBattleLeaderError);
+ const bound = bindFutureBattleLeader(waiting, battle);
+ assert.deepEqual(expireFutureBattleLeader(bound, { turn: 2, battlePhaseEnded: true }), bound,
+ 'An opened battle must be resolved or released, not treated as no opportunity.');
+});
+
+void test('release requires the correct turn and original bound battle', () => {
+ const waiting = createFutureBattleLeader(question, names);
+ const bound = bindFutureBattleLeader(waiting, battle);
+ assert.throws(() => releaseFutureBattleLeader(bound, { turn: 2, battle: 'battle-2' }), FutureBattleLeaderError);
+ assert.throws(() => releaseFutureBattleLeader(bound, { turn: 3, battle: 'battle-1' }), FutureBattleLeaderError);
+ assert.throws(() => releaseFutureBattleLeader(bound, { turn: 2 }), FutureBattleLeaderError);
+ const released = releaseFutureBattleLeader(bound, { turn: 2, battle: 'battle-1' });
+ assert.equal(released.status, 'released');
+ assert.deepEqual(releaseFutureBattleLeader(released, { turn: 2, battle: 'battle-1' }), released);
+ assert.throws(() => settleFutureBattleLeader(released, revealed), FutureBattleLeaderError);
+ assert.equal(releaseFutureBattleLeader(waiting, { turn: 2 }).status, 'released');
+});
+
+void test('malformed questions, saved receipts and battle identities are rejected', () => {
+ assert.throws(() => createFutureBattleLeader({ ...question, leader: 'unknown' }, names), FutureBattleLeaderError);
+ assert.throws(() => createFutureBattleLeader({ ...question, asker: question.player }, names), FutureBattleLeaderError);
+ assert.throws(() => createFutureBattleLeader({ ...question, turn: 0 }, names), FutureBattleLeaderError);
+ assert.throws(() => createFutureBattleLeader(question, ['fenring', 'fenring']), FutureBattleLeaderError);
+ const waiting = createFutureBattleLeader(question, names);
+ assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, attacker: battle.defender }), FutureBattleLeaderError);
+ assert.throws(() => bindFutureBattleLeader(waiting, { ...battle, event: '' }), FutureBattleLeaderError);
+ assert.throws(() => bindFutureBattleLeader({ ...waiting, status: 'bound' } as FutureBattleLeaderReceipt, battle), FutureBattleLeaderError);
+ assert.throws(() => bindFutureBattleLeader({ ...waiting, status: 'waiting', battle: 'old' }, battle), FutureBattleLeaderError);
+});
diff --git a/tests/truth-future-battle-leader-controls.test.tsx b/tests/truth-future-battle-leader-controls.test.tsx
new file mode 100644
index 00000000..c4d68d1a
--- /dev/null
+++ b/tests/truth-future-battle-leader-controls.test.tsx
@@ -0,0 +1,69 @@
+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 { TruthFutureBattleLeader } from '../components/truth-future-battle-leader';
+import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
+
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function control(node: ReactNode, id: string): Element {
+ const result = elements(node).find(element => element.props['data-testid'] === id);
+ assert.ok(result, `Expected ${id}`);
+ return result;
+}
+function projection(): GameView {
+ const game = createGame('TRUTHFUTURE', newPlayer('asker', 'Asker', 'atreides'), false);
+ joinGame(game, newPlayer('target', 'Target', 'emperor'));
+ joinGame(game, newPlayer('other', 'Other', 'harkonnen'));
+ return viewGame(game, 'asker');
+}
+
+void test('future battle question lists only the target faction’s public native leaders', () => {
+ const game = projection();
+ const native = game.allLeaders.find(item => item.faction === 'emperor');
+ const other = game.allLeaders.find(item => item.faction === 'harkonnen');
+ assert.ok(native && other);
+ const props = { game, target: 'target', leader: native.id, available: true, busy: false,
+ onLeaderChange: (_leader: string) => {}, onAsk: (_target: string, _leader: string) => {} };
+ const html = renderToStaticMarkup(createElement(TruthFutureBattleLeader, props));
+ assert.match(html, /If you fight a battle later this turn/);
+ assert.match(html, /first such battle/);
+ assert.match(html, /there is no opportunity/);
+ assert.ok(html.includes(native.name));
+ assert.ok(!html.includes(other.name));
+ assert.equal(control(TruthFutureBattleLeader(props), 'truth-future-battle-leader-ask').props.disabled, false);
+});
+
+void test('authoring callbacks reject unavailable, missing and foreign leaders', () => {
+ const game = projection();
+ const native = game.allLeaders.filter(item => item.faction === 'emperor');
+ const foreign = game.allLeaders.find(item => item.faction === 'harkonnen');
+ assert.ok(native.length >= 2 && foreign);
+ const changes: string[] = [], asks: string[][] = [];
+ const props = { game, target: 'target', leader: '', available: true, busy: false,
+ onLeaderChange: (id: string) => changes.push(id),
+ onAsk: (target: string, id: string) => asks.push([target, id]) };
+ const change = (tree: ReactNode, value: string) =>
+ (control(tree, 'truth-future-battle-leader-select').props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
+ const ask = (tree: ReactNode) => (control(tree, 'truth-future-battle-leader-ask').props.onClick as () => void)();
+
+ const empty = TruthFutureBattleLeader(props);
+ assert.equal(control(empty, 'truth-future-battle-leader-ask').props.disabled, true);
+ change(empty, foreign.id); ask(empty); assert.deepEqual(changes, []); assert.deepEqual(asks, []);
+ change(empty, native[1].id); assert.deepEqual(changes, [native[1].id]);
+ const selected = TruthFutureBattleLeader({ ...props, leader: native[1].id });
+ ask(selected); assert.deepEqual(asks, [['target', native[1].id]]);
+
+ for (const override of [{ busy: true }, { available: false }, { leader: foreign.id }, { target: 'asker' }]) {
+ const tree = TruthFutureBattleLeader({ ...props, leader: native[1].id, ...override });
+ assert.equal(control(tree, 'truth-future-battle-leader-ask').props.disabled, true);
+ ask(tree);
+ }
+ assert.equal(asks.length, 1);
+});
diff --git a/tests/truth-future-battle-leader-runtime.test.ts b/tests/truth-future-battle-leader-runtime.test.ts
new file mode 100644
index 00000000..938691bc
--- /dev/null
+++ b/tests/truth-future-battle-leader-runtime.test.ts
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actFutureBattleLeader as act, advanceFutureBattleLeader as advance,
+ answerFutureBattleLeader as answer, createFutureBattleLeaderFixture as fixture,
+ declareFutureBattleLeader as declare, futureBattleLeaderClean as clean,
+ futureBattleLeaderInventory as inventory, futureBattleLeaderRecords as records,
+ reachFutureBattle as reachBattle,
+} from './fixture-truth-future-battle-leader';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+for (const expected of ['yes', 'no'] as const) {
+ void test(`Basic next battle leader ${expected} binds the first real battle and constrains its native plan`, () => {
+ const f = fixture();
+ const asked = declare(f);
+ assert.equal(asked.truthtrance?.stage, 'answer');
+ assert.deepEqual(asked.truthtrance?.question, f.question);
+ let game = answer(asked, f, expected);
+ assert.equal(game.truthtrance, null);
+ assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
+ assert.equal(records(game).length, 1);
+ assert.equal(records(game)[0].status, 'waiting');
+ assert.equal(records(game)[0].player, f.target);
+ assert.equal(records(game)[0].leader, f.leader);
+ assert.equal(records(game)[0].answer, expected === 'yes');
+ rejected(game, f.target, { type: 'truthAnswer', answer: expected });
+ assert.equal(records(game).length, 1);
+
+ game = reachBattle(game, f);
+ assert.equal(records(game)[0].status, 'bound');
+ assert.equal(records(game)[0].battle, game.battle!.event);
+ const yesPlan = { ...f.targetPlan, leader: f.leader } as Action;
+ const noPlan = { ...f.targetPlan, leader: f.otherLeader } as Action;
+ rejected(game, f.target, expected === 'yes' ? noPlan : yesPlan);
+ game = act(game, f.target, expected === 'yes' ? yesPlan : noPlan);
+ assert.equal(records(game)[0].status, 'bound');
+ game = act(game, f.asker, f.ownPlan);
+ assert.ok(game.battle?.revealed);
+ assert.equal(records(game)[0].status, 'settled');
+ assert.equal(records(game).length, 1);
+ assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+ });
+}
+
+void test('Basic next battle leader question expires without creating a battle or forcing a shipment', () => {
+ const f = fixture();
+ for (const expected of ['yes', 'no'] as const) {
+ let game = answer(declare(f), f, expected);
+ const before = structuredClone(game.players.find(player => player.id === f.target));
+ game = act(game, f.target, { type: 'endMovement' });
+ game = advance(game, state => state.turn !== f.game.turn || state.phase > 6 || state.status === 'finished');
+ assert.equal(game.battle, null);
+ assert.equal(records(game).length, 1);
+ assert.equal(records(game)[0].status, 'expired');
+ const after = game.players.find(player => player.id === f.target); assert.ok(before && after);
+ assert.equal(after.reserves, before.reserves);
+ assert.deepEqual(after.forces, before.forces);
+ assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+ assert.ok(clean(game));
+ }
+});
+
+void test('future battle questions use a current turn, another seat and that faction’s native public leader', () => {
+ const f = fixture();
+ let game = act(f.game, f.asker, { type: 'card', card: f.truth });
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player);
+ game = act(game, player.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ const invalid = [
+ { ...f.question, turn: f.game.turn + 1 },
+ { ...f.question, target: f.asker },
+ { ...f.question, leader: f.base.leaders.atreides },
+ { ...f.question, leader: 'unknown-leader' },
+ ];
+ for (const question of invalid)
+ rejected(game, f.asker, { type: 'truthAsk', question } as unknown as Action);
+ const asked = act(game, f.asker, f.ask);
+ rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
+});
+
+void test('each AI profile chooses a legal conditional answer and honors it in the real battle', () => {
+ for (const difficulty of DIFFICULTIES) {
+ const f = fixture();
+ const asked = declare(f);
+ const respondent = asked.players.find(player => player.id === f.target)!;
+ respondent.bot = difficulty;
+ const reply = botActions(viewGame(asked, f.target)).find(action => action.type === 'truthAnswer');
+ assert.ok(reply, difficulty);
+ let game = act(asked, f.target, reply);
+ assert.equal(records(game)[0].status, 'waiting');
+ game = reachBattle(game, f);
+ const planned = botActions(viewGame(game, f.target)).find(action => action.type === 'battlePlan');
+ assert.ok(planned, difficulty);
+ game = act(game, f.target, planned);
+ game = act(game, f.asker, f.ownPlan);
+ assert.equal(records(game)[0].status, 'settled');
+ }
+});