From c1ffa1c28a151c25239e29ac01f4d17595f7d678 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 17:34:55 +1100
Subject: [PATCH] Prototype named Ghola leader Truthtrance promises
---
components/game-table.tsx | 2 +
components/ghola-leader-claim-fields.tsx | 46 +++++++
components/ghola-leader-promises.tsx | 55 +++++++++
components/truthtrance.tsx | 23 +++-
docs/CURRENT_STATUS.md | 10 ++
docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md | 34 ++++++
game/bots.ts | 5 +
game/engine.ts | 109 +++++++++++++++--
game/ghola-leader-promises.ts | 82 +++++++++++++
game/reference.ts | 19 +++
game/truthtrance.ts | 23 ++++
tests/ghola-leader-claim-fields.test.tsx | 41 +++++++
tests/ghola-leader-promise-controls.test.tsx | 42 +++++++
tests/ghola-leader-promises.test.ts | 51 ++++++++
tests/ghola-leader-truth-parser.test.ts | 36 ++++++
tests/ghola-leader-truth-runtime.test.ts | 120 +++++++++++++++++++
16 files changed, 689 insertions(+), 9 deletions(-)
create mode 100644 components/ghola-leader-claim-fields.tsx
create mode 100644 components/ghola-leader-promises.tsx
create mode 100644 game/ghola-leader-promises.ts
create mode 100644 tests/ghola-leader-claim-fields.test.tsx
create mode 100644 tests/ghola-leader-promise-controls.test.tsx
create mode 100644 tests/ghola-leader-promises.test.ts
create mode 100644 tests/ghola-leader-truth-parser.test.ts
create mode 100644 tests/ghola-leader-truth-runtime.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 877a5991..4ee9029c 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -46,6 +46,7 @@ import { MixedBidPromises } from './mixed-bid-promises';
import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
+import { GholaLeaderPromises } from './ghola-leader-promises';
import { BidPromises } from './bid-promises';
import { PhaseBidPromises } from './phase-bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
@@ -2158,6 +2159,7 @@ export function GameTable({
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
+ {g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
diff --git a/components/ghola-leader-claim-fields.tsx b/components/ghola-leader-claim-fields.tsx
new file mode 100644
index 00000000..de87e041
--- /dev/null
+++ b/components/ghola-leader-claim-fields.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { parseGholaLeaderClaim, type GholaLeaderClaim } from '@/game/ghola-leader-promises';
+
+type LeaderChoice = { id: string; name: string };
+
+export function gholaLeaderClaimInputError(value: unknown, leaders?: readonly LeaderChoice[]): string | null {
+ try {
+ const claim = parseGholaLeaderClaim(value);
+ return leaders && !leaders.some(leader => leader.id === claim.leader)
+ ? 'Choose one of the current named leaders.' : null;
+ } catch (error) {
+ return error instanceof Error ? error.message : 'Choose the current turn and a named leader.';
+ }
+}
+
+/** The parent supplies the current turn and public leader names. Server rules
+ * decide whether the named return is possible and whether a response is legal. */
+export function GholaLeaderClaimFields(props: {
+ value: GholaLeaderClaim;
+ onChange: (claim: GholaLeaderClaim) => void;
+ leaders: readonly LeaderChoice[];
+ disabled?: boolean;
+}) {
+ const error = gholaLeaderClaimInputError(props.value, props.leaders);
+ return
+ Future Ghola leader return
+ Turn {props.value.turn}
+ Named leader
+ leader.id === props.value.leader) ? props.value.leader : ''}
+ onChange={event => {
+ const next = { ...props.value, leader: event.currentTarget.value };
+ if (!props.disabled && !gholaLeaderClaimInputError(next, props.leaders)) props.onChange(parseGholaLeaderClaim(next));
+ }}>
+ Choose a named leader
+ {props.leaders.map(leader => {leader.name} )}
+
+ {error && {error}
}
+
+ Ask whether this named leader will actually return using Tleilaxu Ghola after the answer this turn.
+ Playing Ghola to return forces, ordinary revival, or a return before the answer does not count.
+ The card and its normal eligibility rules still apply.
+
+ ;
+}
diff --git a/components/ghola-leader-promises.tsx b/components/ghola-leader-promises.tsx
new file mode 100644
index 00000000..787f9ca1
--- /dev/null
+++ b/components/ghola-leader-promises.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { gholaLeaderClaimText, liveGholaLeaderPromises } from '@/game/ghola-leader-promises';
+import { Button } from './ui/button';
+
+type Props = { game: GameView; busy: boolean; send: (action: Action) => void };
+const leaderName = (game: GameView, id: string) => id === 'kwisatz' ? 'Kwisatz Haderach'
+ : game.allLeaders.find(leader => leader.id === id)?.name ?? 'the named leader';
+
+/** Forward only the exact current native card action from the private guide. */
+export function GholaLeaderPromiseGuideButton(props: Props) {
+ const game = props.game, guide = game.gholaLeaderPromiseGuide;
+ if (!guide) return null;
+ const actor = game.me, turn = game.turn, phase = game.phase, action = guide.action;
+ const signature = JSON.stringify(action), quote = game.ghola, quoteSignature = JSON.stringify(quote);
+ const card = game.players.find(player => player.id === actor)?.hand?.find(c => c.id === action.card);
+ const canAct = () => {
+ const current = props.game, me = current.players.find(player => player.id === actor);
+ const named = typeof action.leader === 'string' && liveGholaLeaderPromises(current.gholaLeaderPromises, actor, turn)
+ .some(promise => promise.answer && promise.leader === action.leader);
+ return !props.busy && current === game && current.status === 'playing' && current.me === actor &&
+ current.turn === turn && current.phase === phase && current.gholaLeaderPromiseGuide === guide &&
+ guide.action === action && JSON.stringify(action) === signature && action.type === 'card' &&
+ action.mode === undefined && typeof action.card === 'string' && !!named && !!me && !me.autopilot &&
+ !!card && card.kind === 'special' && card.effect === 'ghola' && me.hand?.includes(card) &&
+ current.ghola === quote && JSON.stringify(quote) === quoteSignature && quote.available &&
+ quote.cards.includes(action.card) && (action.leader === 'kwisatz' ? quote.kwisatz
+ : quote.leaders.some(leader => leader.id === action.leader)) &&
+ !current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
+ !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
+ };
+ return { if (canAct()) props.send(action); }}>
+ Revive {leaderName(game, typeof action.leader === 'string' ? action.leader : '')} with Ghola while honoring your answer
+ ;
+}
+
+export function GholaLeaderPromises(props: Props) {
+ const own = props.game.gholaLeaderPromises.filter(promise => promise.player === props.game.me);
+ if (!own.length) return null;
+ return
+ Your Ghola leader promises
+ {own.map(promise =>
+ Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {gholaLeaderClaimText(promise, id => leaderName(props.game, id))}.
+ {promise.released ? ' Released: this commitment is no longer possible.'
+ : promise.fulfilled ? ' Fulfilled: this commitment is complete.'
+ : promise.turn !== props.game.turn ? ' This turn has ended.' : ' This answer is binding for this turn.'}
+ )}
+ Yes requires an actual future return of the named leader with Ghola while it remains possible.
+ No forbids that particular Ghola leader return this turn. An ordinary return, a force return, or an earlier Ghola play does not fulfill it.
+ The original card timing and leader eligibility still apply. This question is a provisional interpretation of Truthtrance.
+
+ ;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 3264acc3..1daeaab3 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -33,6 +33,7 @@ import { bribeExpressionRecipients, type BribeExpression } from '@/game/bribe-ex
import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields';
import type { RevivalExpression } from '@/game/revival-expression-promises';
import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader-revival-claim-fields';
+import { GholaLeaderClaimFields, gholaLeaderClaimInputError } from './ghola-leader-claim-fields';
import type { LeaderRevivalClaim } from '@/game/leader-revival-promises';
import { MovementClaimFields } from './movement-claim-fields';
import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields';
@@ -131,7 +132,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
- 'fact' | 'freeform' | 'charity' | 'battlePlan' | 'futureBattleLeader' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'pledgeAid' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
+ 'fact' | 'freeform' | 'charity' | 'battlePlan' | 'futureBattleLeader' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'pledgeAid' | 'alliance' | 'leaderRevival' | 'gholaLeader' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -270,7 +271,7 @@ export function Truthtrance({
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'mixedRevival' ? { kind, target, turn: g.turn, mixed: mixedRevival }
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
- : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
+ : kind === 'leaderRevival' || kind === 'gholaLeader' ? { kind, target, ...leaderRevivalClaim }
: kind === 'allianceExpression' ? { kind, target, turn: g.turn, claim: allianceExpression }
: kind === 'mixedAlliance' ? { kind, target, turn: g.turn, mixed: mixedAlliance }
: kind === 'alliance' ? { kind, target, ...allianceClaim }
@@ -425,6 +426,13 @@ export function Truthtrance({
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })}
)}
>
+ ) : w.question!.kind === 'gholaLeader' ? (
+ <>
+ This provisional question concerns a future return of the named leader using Ghola this turn. A force return, ordinary revival, or play before this answer does not fulfill it.
+ {(g.truthGholaLeaderAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'leaderRevival' ? (
<>
This concerns a future actual ordinary return of the named leader before your Revival opportunity ends. Native cost, eligibility, cycle and order still apply. A request alone, Ghola card, special or foreign-ghola return does not fulfill it.
@@ -670,6 +678,7 @@ export function Truthtrance({
Combine current facts and ordinary Revival
Bind combined force and leader revivals
Bind a named leader revival this turn
+ Bind a named leader return with Ghola this turn (preview)
Bind ordinary force revivals this turn
Bind a bid in this ordinary lot
Bind a Charity claim this turn
@@ -768,6 +777,15 @@ export function Truthtrance({
{!g.truthRevivalExpressionTargets.includes(target) && Choose a supported participant with an ordinary Revival opportunity still ahead this turn.
}
{truthQuestionText(question, leaderName, locationName, name)}
>
+ ) : kind === 'gholaLeader' ? (
+ <>
+ setRevivalLeader(claim.leader)} />
+ This question is a provisional preview. Only a named leader returned through Ghola after the answer counts.
+ {!g.truthGholaLeaderTargets.includes(target) && Choose a supported participant for this question.
}
+ {truthQuestionText(question, leaderName, locationName, name)}
+ >
) : kind === 'leaderRevival' ? (
<>
answer !== 'unknown') ?? choices?.[0];
diff --git a/game/engine.ts b/game/engine.ts
index 891823aa..91b79020 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,4 @@
+import { GholaLeaderPromiseError, liveGholaLeaderPromises, gholaLeaderRequirements, recordPromisedGholaLeader, validateGholaLeaderPromises, type GholaLeaderClaim, type GholaLeaderPromise } from './ghola-leader-promises';
import { basicPendingMovement } from './basic-pending-movement';
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
@@ -2468,6 +2469,7 @@ export type Game = {
})[];
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
+ gholaLeaderPromises?: GholaLeaderPromise[];
revivalExpressionPromises?: RevivalExpressionPromise[];
/** Original mixed questions link to the existing ordinary-return ledger. */
mixedRevivalQuestions?: (MixedRevivalClaim & {
@@ -22049,7 +22051,7 @@ function turnActionRule(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
}
-type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
+type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number; gholaEvent?: string };
/** This field exists only on detached completion clones and is never returned
* as a real game or installed as a selected whole-answer branch. */
type TurnActionCardProbe = Game & {
@@ -22086,7 +22088,9 @@ function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCar
recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect));
}
function turnActionCardsMatch(g: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): boolean {
- return requirements.every(required => (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
+ return requirements.every(required => required.gholaEvent
+ ? !!g.gholaLeaderPromises?.find(promise => promise.event === required.gholaEvent && promise.fulfilled)
+ : (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
}
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator {
const player = getPlayer(g, actor);
@@ -22163,7 +22167,8 @@ function* turnActionBoxSeeds(source: Game, actor: string, onlyBox?: string):
/** Reject the known printed fee before paid inspection can suspend legality.
* The native owner chooses the recovered card only after actual payment. */
function guardTurnActionBoxCost(source: Game, owner: Player, box: string) {
- if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length) return;
+ if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length &&
+ !liveGholaLeaderPromises(source.gholaLeaderPromises ?? [], owner.id, source.turn).some(promise => promise.answer)) return;
for (const seed of turnActionBoxSeeds(source, owner.id, box)) if (turnActionCompletion(seed.game, owner.id)) return;
throw new RuleError('The two-spice Box payment must preserve your accepted whole Truthtrance answers.');
}
@@ -22502,7 +22507,8 @@ function reopenTurnActionWindowReady(g: Game, actor: string) {
/** Check the actual spent card and returned pieces before a later foreign
* income/discard response can hide an own voluntary loss of feasibility. */
function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
- if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length) return;
+ if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length &&
+ !liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], player.id, g.turn).some(promise => promise.answer)) return;
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
owner.spice += pendingBattleRevivalIncome(trial, owner);
const suspended = g.pendingTreacheryDiscard?.continuation;
@@ -23071,6 +23077,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
delete trial.travelPromiseOrder;
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
+ for (const promise of liveGholaLeaderPromises(source.gholaLeaderPromises ?? [], actor, source.turn))
+ if (promise.answer) cardRequirements.push({ effect: 'ghola', answer: true,
+ baseline: nativePrintedCardCount(source, actor, 'ghola'), gholaEvent: promise.event });
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
};
@@ -32959,6 +32968,10 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
);
if (cardId) stageHomeworldRevivalReturn(g, p, 'ghola', group, grant, cardId);
}
+ if (typeof action.leader === 'string') {
+ try { g.gholaLeaderPromises = recordPromisedGholaLeader(g.gholaLeaderPromises ?? [], p.id, g.turn, action.leader); }
+ catch (error) { if (error instanceof GholaLeaderPromiseError) throw new RuleError(error.message); throw error; }
+ }
techIncome(g, 'axlotl', p);
collectRevivalIncome(g, p, 0, false, true);
recordNativePrintedCardPlay(g, p.id, 'ghola');
@@ -37474,6 +37487,57 @@ function leaderRevivalIdentity(g: Game, p: Player, leader: string): boolean {
return leader === 'kwisatz' ? g.advanced && p.faction === 'atreides' :
leader === DUKE_VIDAL_ID ? p.faction === 'ecaz' && !!g.dukeVidal : p.leaders.some(l => l.id === leader);
}
+function gholaLeaderTruthAnswers(g: Game, p: Player, claim: GholaLeaderClaim): TruthAnswer[] {
+ if (claim.turn !== g.turn || !leaderRevivalIdentity(g, p, claim.leader)) return [];
+ const ask = g.truthtrance?.queue[0]?.player ?? g.host;
+ return (['yes', 'no'] as const).filter(answer => {
+ const promises = [...(g.gholaLeaderPromises ?? []), { ...claim, event: 'ghola-candidate',
+ player: p.id, asker: ask, answer: answer === 'yes' }];
+ const requirements = gholaLeaderRequirements(promises, p.id, g.turn);
+ if (!requirements || requirements.required.length > 1) return false;
+ return !!turnActionCompletion({ ...g, gholaLeaderPromises: promises }, p.id);
+ });
+}
+function bindGholaLeaderTruth(g: Game, p: Player, claim: GholaLeaderClaim, answer: boolean, asker: string) {
+ requireRule(gholaLeaderTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
+ 'Choose an answer with a legal named Ghola completion.');
+ (g.gholaLeaderPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer });
+}
+function reconcileGholaLeaderTruth(g: Game, voluntary?: { actor: string; action: Action }) {
+ if (!g.gholaLeaderPromises?.length) return;
+ try { validateGholaLeaderPromises(g.gholaLeaderPromises, g.players.map(p => p.id)); }
+ catch (error) { if (error instanceof GholaLeaderPromiseError) throw new RuleError(error.message); throw error; }
+ if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama) return;
+ for (const promise of g.gholaLeaderPromises) {
+ if (promise.fulfilled || promise.released) continue;
+ if (!promise.answer && (promise.turn !== g.turn || g.status !== 'playing')) { promise.fulfilled = true; continue; }
+ if (promise.answer && (promise.turn !== g.turn || g.status !== 'playing' || !turnActionCompletion(g, promise.player))) {
+ requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your named Ghola Truthtrance answer.');
+ promise.released = true;
+ }
+ }
+}
+function projectedGholaLeaderGuide(g: Game, player: string): { action: Action } | null {
+ if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama) return null;
+ const promise = liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], player, g.turn).find(p => p.answer);
+ if (!promise) return null;
+ // A completion can defer the Ghola play into a later movement forecast.
+ // Offer an immediate action only if its actual physical play is legal now
+ // and leaves a joint completion for every other accepted answer.
+ for (const action of nativePrintedCardActions(g, player, 'ghola')) {
+ if (action.leader !== promise.leader) continue;
+ try {
+ const trial = applyActionInner(g, player, action, 'movementPreparation');
+ if (!trial.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled)) continue;
+ trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
+ const owner = getPlayer(trial, player);
+ owner.spice += pendingBattleRevivalIncome(trial, owner);
+ reconcileRevivalPromises(trial, { actor: player, action });
+ if (turnActionCompletion(trial, player)) return { action };
+ } catch (error) { if (!(error instanceof RuleError)) throw error; }
+ }
+ return null;
+}
function leaderRevivalTruthAnswers(g: Game, p: Player, claim: LeaderRevivalClaim): ('yes' | 'no' | 'unknown')[] {
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn ||
!leaderRevivalIdentity(g, p, claim.leader)) return [];
@@ -37586,7 +37650,8 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!hasMovementPromises(g, p.id) &&
- !hasBribePromises(g, p.id, true)) return;
+ !hasBribePromises(g, p.id, true) &&
+ !liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], p.id, g.turn).some(promise => promise.answer)) return;
if (!quote) {
requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []),
'Complete the named leader returns required by your Truthtrance Yes before finishing Revival.');
@@ -37596,8 +37661,10 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
trial.truthtrance = null;
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
stagePromisedLeaderReturn(trial, actor, quote);
- requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}),
- 'This leader request would prevent you from honoring your Revival answers.');
+ requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}) &&
+ (!liveGholaLeaderPromises(trial.gholaLeaderPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
+ !!turnActionCompletion(trial, p.id)),
+ 'This leader request would prevent you from honoring your Truthtrance answers.');
}
/** Reconcile the shared purse in actual answer order across both ledgers.
* Existing records without an order stamp precede newly accepted answers. */
@@ -43181,6 +43248,24 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
// turn that forced debit into the passer's voluntary payment or expenditure.
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
const pledgeVoluntary = initiator === id ? voluntary : undefined;
+ // Voluntary actions may create a response owned by somebody else before the
+ // regular reconciler can run. Quote the already-completed action on a
+ // detached state before that response can hide loss of the last witness.
+ if (voluntary && liveGholaLeaderPromises(state.gholaLeaderPromises ?? [], id, state.turn).some(promise => promise.answer)) {
+ const prior = liveGholaLeaderPromises(state.gholaLeaderPromises ?? [], id, state.turn).filter(promise => promise.answer);
+ const unresolved = prior.some(promise => !g.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled));
+ if (unresolved && (g.turn !== state.turn || g.status !== 'playing'))
+ throw new RuleError('Complete your named Ghola Truthtrance answer before ending this turn.');
+ if (unresolved && !state.truthtrance && !state.response && !state.decision && !state.phaseOpening &&
+ !state.pendingTreacheryDiscard && !state.pendingKarama &&
+ (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama)) {
+ const trial = structuredClone(g);
+ trial.truthtrance = null; trial.response = null; trial.decision = null;
+ trial.phaseOpening = null; trial.pendingTreacheryDiscard = null; trial.pendingKarama = null;
+ requireRule(!!turnActionCompletion(trial, id),
+ 'This action would remove the last legal completion of your named Ghola Truthtrance answer.');
+ }
+ }
reconcilePledgeCommitments(g, pledgeVoluntary);
reconcileTravelPromises(g, promiseActor, action, state.phase, state);
reconcileBidPromises(g, voluntary);
@@ -43189,6 +43274,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileRicheseBids(g, voluntary);
reconcileCharityTruth(g, voluntary);
reconcileRevivalPromises(g, voluntary);
+ reconcileGholaLeaderTruth(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
@@ -44055,6 +44141,8 @@ function applyActionInner(
bindRevivalExpression: bindRevivalExpressionTruth,
mixedRevivalAnswers: mixedRevivalTruthAnswers,
bindMixedRevival: bindMixedRevivalTruth,
+ gholaLeaderAnswers: gholaLeaderTruthAnswers,
+ bindGholaLeader: bindGholaLeaderTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
@@ -48186,6 +48274,8 @@ function applyActionInner(
return g;
}
if (t === 'ready') {
+ if (g.phase === 8) requireRule(!liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], id, g.turn)
+ .some(promise => promise.answer), 'Complete your named Ghola Truthtrance answer before finishing Mentat Pause.');
requireRule(!(g.phase === 1 && g.nexus && g.coalitionState?.proposal),
'Resolve the group invitation before finishing the Nexus.');
if (g.phase === 2) {
@@ -48545,6 +48635,11 @@ export function viewGame(state: Game, id: string, now?: number) {
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
revivalPromises: (g.revivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
revivalPromiseGuide: projectedRevivalPromiseGuide(g, id),
+ truthGholaLeaderTargets: g.status === 'playing' && g.phase >= 0 && g.phase <= 7 ? g.players.map(player => player.id) : [],
+ truthGholaLeaderAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+ g.truthtrance.question.kind === 'gholaLeader' ? gholaLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
+ gholaLeaderPromises: (g.gholaLeaderPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
+ gholaLeaderPromiseGuide: projectedGholaLeaderGuide(g, id),
truthLeaderRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthLeaderRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'leaderRevival' ? leaderRevivalTruthAnswers(g, me, g.truthtrance.question) : null,
diff --git a/game/ghola-leader-promises.ts b/game/ghola-leader-promises.ts
new file mode 100644
index 00000000..50b75869
--- /dev/null
+++ b/game/ghola-leader-promises.ts
@@ -0,0 +1,82 @@
+/** A separate question from ordinary leader revival or merely playing Ghola. */
+export type GholaLeaderClaim = { turn: number; leader: string };
+export type GholaLeaderPromise = GholaLeaderClaim & {
+ event: string;
+ player: string;
+ asker: string;
+ answer: boolean;
+ fulfilled?: boolean;
+ released?: boolean;
+};
+
+export class GholaLeaderPromiseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'GholaLeaderPromiseError';
+ }
+}
+
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0 && value === value.trim();
+const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
+
+/** Question envelopes may contain their own kind and target; extract only the
+ * canonical claim, never coercing an identity or turn. */
+export function parseGholaLeaderClaim(value: unknown): GholaLeaderClaim {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new GholaLeaderPromiseError('Choose a current-turn named Ghola leader question.');
+ const input = value as Record;
+ if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.leader))
+ throw new GholaLeaderPromiseError('Choose the current turn and exact leader identity.');
+ return { turn: input.turn, leader: input.leader };
+}
+
+export function gholaLeaderClaimText(claim: GholaLeaderClaim,
+ leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string {
+ return `revive ${leaderName(claim.leader)} with Ghola from now until the end of this turn`;
+}
+
+export function liveGholaLeaderPromises(promises: readonly GholaLeaderPromise[], player: string,
+ turn: number): GholaLeaderPromise[] {
+ return promises.filter(promise => promise.player === player && promise.turn === turn &&
+ !promise.fulfilled && !promise.released);
+}
+
+/** This describes obligations, not permission or a guarantee that enough cards
+ * exist. The engine's shared completion planner must establish feasibility. */
+export function gholaLeaderRequirements(promises: readonly GholaLeaderPromise[], player: string,
+ turn: number): { required: string[]; forbidden: string[] } | null {
+ const required = new Set(), forbidden = new Set();
+ for (const promise of liveGholaLeaderPromises(promises, player, turn))
+ (promise.answer ? required : forbidden).add(promise.leader);
+ if ([...required].some(leader => forbidden.has(leader))) return null;
+ return { required: [...required], forbidden: [...forbidden] };
+}
+
+/** Call only for an actual successful Ghola leader return, never for a play
+ * returning forces, a proposal, or any other source of revival. */
+export function recordPromisedGholaLeader(promises: readonly GholaLeaderPromise[], player: string,
+ turn: number, leader: string): GholaLeaderPromise[] {
+ if (!identifier(leader)) throw new GholaLeaderPromiseError('Retain the exact Ghola-returned leader identity.');
+ const matching = liveGholaLeaderPromises(promises, player, turn).filter(promise => promise.leader === leader);
+ if (matching.some(promise => !promise.answer))
+ throw new GholaLeaderPromiseError('This Ghola leader return conflicts with your Truthtrance No answer.');
+ return promises.map(promise => matching.includes(promise) ? { ...promise, fulfilled: true } : promise);
+}
+
+/** Validate durable event and participant identities; native engine rules own
+ * the physical leader, ownership, costs, and legal Ghola source. */
+export function validateGholaLeaderPromises(promises: readonly GholaLeaderPromise[], players: readonly string[]): void {
+ if (!Array.isArray(promises)) throw new GholaLeaderPromiseError('The Ghola leader commitment record is invalid.');
+ const events = new Set();
+ for (const promise of promises) {
+ parseGholaLeaderClaim(promise);
+ if (!identifier(promise.event) || events.has(promise.event) ||
+ !identifier(promise.player) || !identifier(promise.asker) ||
+ !players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker ||
+ typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
+ promise.fulfilled && promise.released)
+ throw new GholaLeaderPromiseError('The Ghola leader commitment lost its event or participants.');
+ events.add(promise.event);
+ }
+}
diff --git a/game/reference.ts b/game/reference.ts
index 8f4f1a40..e960869d 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3652,6 +3652,25 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Pure whole-tree logic, controlled editor/guide and inherited original H/A/T battle/death histories; broader source families, browser/deployed and final assurance remain due.', evidence: ['tests/fixture-truth-revival-expression.ts', 'tests/truth-revival-expression-runtime.test.ts', 'tests/truth-revival-expression-controls.test.tsx', 'tests/truth-revival-expression-funding.test.ts'] },
],
},
+ {
+ id: 'truthtrance-ghola-leader',
+ title: 'Truthtrance: named Ghola leader',
+ category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
+ summary: 'Ask whether a named leader will return using Ghola this turn.',
+ steps: [
+ 'The provisional question binds a future personal Ghola return of the named leader. Returning forces, ordinary revival and earlier plays do not count.',
+ 'Yes requires a compatible owned completion; No forbids that specific Ghola leader return. One distinct outstanding Yes target is supported. Some pending interactions remain conservatively guarded.',
+ 'The private panel and minimal bot guide use the native Ghola action. Broader interactions, saved-game assurance and deployed acceptance remain open.',
+ ],
+ related: ['card-truthtrance', 'truthtrance-leader-revival'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Named Ghola outcomes participate in the joint completion planner.', evidence: ['game/ghola-leader-promises.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private question, answers and guide.', evidence: ['components/ghola-leader-claim-fields.tsx', 'components/ghola-leader-promises.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Minimal legal answer and action guide; calibration remains deferred.', evidence: ['game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Provisional interpretation and current limits.', evidence: ['docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused native and control tests; deployed acceptance is open.', evidence: ['tests/ghola-leader-truth-runtime.test.ts', 'tests/ghola-leader-promise-controls.test.tsx'] },
+ ],
+ },
{
id: 'truthtrance-leader-revival',
title: 'Truthtrance: named leader revival this turn',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 0bf7de79..24b3c082 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -21,6 +21,7 @@ import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionTe
type BribeExpressionClaim } from './bribe-expression-promises';
import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises';
import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises';
+import { parseGholaLeaderClaim, gholaLeaderClaimText, type GholaLeaderClaim } from './ghola-leader-promises';
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
import { parsePledgeClaim, type PledgeClaim } from './pledge-promises';
@@ -113,6 +114,7 @@ export type TruthQuestion =
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
+ | ({ kind: 'gholaLeader'; target: string } & GholaLeaderClaim)
| ({ kind: 'alliance'; target: string } & AllianceClaim)
| ({ kind: 'bribe'; target: string } & BribeClaim)
| ({ kind: 'pledgeAid'; target: string } & PledgeClaim)
@@ -321,6 +323,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose a supported current-turn revival expression in a quiet window.');
return { kind: 'revivalExpression', target: v.target, ...claim };
}
+ if (v.kind === 'gholaLeader') {
+ check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn',
+ 'Supply the current turn, exact named leader and respondent.');
+ const claim = parseGholaLeaderClaim(v);
+ check(leaderRevivalPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 7 &&
+ !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama &&
+ !g.pendingTreacheryDiscard && claim.turn === g.turn,
+ 'Choose a supported current-turn Ghola leader question in a quiet window.');
+ return { kind: 'gholaLeader', target: v.target, ...claim };
+ }
if (v.kind === 'leaderRevival') {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn',
'Supply the current turn, exact named leader and respondent.');
@@ -849,6 +861,7 @@ export function truthQuestionText(
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this turn’s Charity phase?';
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
+ if (q.kind === 'gholaLeader') return `Will you ${gholaLeaderClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
if (q.kind === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`;
if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`;
if (q.kind === 'bribeExpression') return `Will you ${bribeExpressionText(q.claim, playerName)} from now until this turn ends?`;
@@ -990,6 +1003,8 @@ export function resolveTruthAction(
bindMixedRevival?: (g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean, asker: string) => void;
revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[];
bindRevivalExpression?: (g: Game, p: Player, claim: RevivalExpressionClaim, answer: boolean, asker: string) => void;
+ gholaLeaderAnswers?: (g: Game, p: Player, claim: GholaLeaderClaim) => TruthAnswer[];
+ bindGholaLeader?: (g: Game, p: Player, claim: GholaLeaderClaim, answer: boolean, asker: string) => void;
leaderRevivalAnswers?: (g: Game, p: Player, claim: LeaderRevivalClaim) => TruthAnswer[];
bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void;
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
@@ -1172,6 +1187,9 @@ export function resolveTruthAction(
if (w.question.kind === 'revivalExpression')
check(effects.revivalExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole revival expression with a compatible native completion.');
+ if (w.question.kind === 'gholaLeader')
+ check(effects.gholaLeaderAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
+ 'Choose a named Ghola leader question with a compatible owned completion.');
if (w.question.kind === 'leaderRevival')
check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a named leader question with a compatible native completion.');
@@ -1303,6 +1321,11 @@ export function resolveTruthAction(
'Choose a whole revival answer compatible with native returns and earlier promises.');
if (a.answer !== 'unknown') effects.bindRevivalExpression(g, p, q, a.answer === 'yes', current.player);
}
+ if (q.kind === 'gholaLeader') {
+ check(effects.gholaLeaderAnswers && effects.bindGholaLeader && effects.gholaLeaderAnswers(g, p, q).includes(a.answer),
+ 'Choose a named Ghola leader answer compatible with native eligibility and earlier promises.');
+ if (a.answer !== 'unknown') effects.bindGholaLeader(g, p, q, a.answer === 'yes', current.player);
+ }
if (q.kind === 'leaderRevival') {
check(effects.leaderRevivalAnswers && effects.bindLeaderRevival && effects.leaderRevivalAnswers(g, p, q).includes(a.answer),
'Choose a named leader-revival answer compatible with native eligibility and earlier promises.');
diff --git a/tests/ghola-leader-claim-fields.test.tsx b/tests/ghola-leader-claim-fields.test.tsx
new file mode 100644
index 00000000..a08fdc68
--- /dev/null
+++ b/tests/ghola-leader-claim-fields.test.tsx
@@ -0,0 +1,41 @@
+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 { GholaLeaderClaimFields, gholaLeaderClaimInputError } from '../components/ghola-leader-claim-fields';
+import type { GholaLeaderClaim } from '../game/ghola-leader-promises';
+
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+function elements(node: ReactNode): ReactElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function choose(props: Parameters[0], value: string) {
+ const select = elements(GholaLeaderClaimFields(props)).find(element => element.props.id === 'truth-ghola-leader');
+ assert.ok(select);
+ (select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
+}
+
+void test('Ghola named leader field submits only a current-turn listed leader', () => {
+ const value: GholaLeaderClaim = { turn: 2, leader: 'leader-a' }, changes: GholaLeaderClaim[] = [];
+ const leaders = [{ id: 'leader-a', name: 'Commander A' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }];
+ const props = { value, leaders, onChange(next: GholaLeaderClaim) { changes.push(next); } };
+ const html = renderToStaticMarkup(createElement(GholaLeaderClaimFields, props));
+ assert.match(html, /Turn 2/);
+ assert.match(html, /Commander A/);
+ assert.match(html, /Kwisatz Haderach/);
+ assert.match(html, /return using Tleilaxu Ghola/);
+ assert.match(html, /Playing Ghola to return forces, ordinary revival/);
+ choose(props, 'kwisatz');
+ assert.deepEqual(changes, [{ turn: 2, leader: 'kwisatz' }]);
+ for (const invalid of ['', 'missing', ' leader-a ']) choose(props, invalid);
+ choose({ ...props, disabled: true }, 'kwisatz');
+ choose({ ...props, leaders: [] }, 'kwisatz');
+ choose({ ...props, value: { ...value, turn: 0 } }, 'kwisatz');
+ assert.equal(changes.length, 1);
+ for (const invalid of [null, {}, { turn: 0, leader: 'leader-a' }, { turn: 2, leader: '' }])
+ assert.ok(gholaLeaderClaimInputError(invalid));
+ assert.ok(gholaLeaderClaimInputError(value, []));
+ assert.match(renderToStaticMarkup(createElement(GholaLeaderClaimFields, { ...props, leaders: [] })), /role="alert"/);
+});
diff --git a/tests/ghola-leader-promise-controls.test.tsx b/tests/ghola-leader-promise-controls.test.tsx
new file mode 100644
index 00000000..47518a19
--- /dev/null
+++ b/tests/ghola-leader-promise-controls.test.tsx
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { GholaLeaderPromises, GholaLeaderPromiseGuideButton } from '../components/ghola-leader-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine';
+
+function projected() {
+ const source = createGame('GHOLAGUIDECONTROLS', newPlayer('p', 'Player', 'emperor'), false);
+ joinGame(source, newPlayer('a', 'Asker', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.turn = 2; game.phase = 4;
+ game.allLeaders = [{ id: 'named', name: 'Named Commander', faction: 'emperor', strength: 3 }];
+ const me = game.players.find(p => p.id === 'p')!;
+ me.hand = [{ id: 'ghola', name: 'Ghola', kind: 'special', effect: 'ghola' }];
+ game.ghola = { cards: ['ghola'], leaders: [{ id: 'named', name: 'Named Commander', strength: 3 }],
+ kwisatz: false, maxForces: 0, eliteRemaining: 0, eliteBlock: null, available: true, reason: null };
+ game.gholaLeaderPromises = [{ event: 'event', turn: 2, player: 'p', asker: 'a', leader: 'named', answer: true }];
+ game.gholaLeaderPromiseGuide = { action: { type: 'card', card: 'ghola', leader: 'named' } };
+ return game;
+}
+
+void test('private Ghola promises describe the named actual return and statuses', () => {
+ const game = projected();
+ game.gholaLeaderPromises.push({ event: 'foreign', turn: 2, player: 'a', asker: 'p', leader: 'secret', answer: false });
+ const html = renderToStaticMarkup(createElement(GholaLeaderPromises, { game, busy: false, send() {} }));
+ assert.match(html, /Named Commander/);
+ assert.match(html, /ordinary return, a force return, or an earlier Ghola play does not fulfill it/);
+ assert.doesNotMatch(html, /secret|foreign/);
+});
+
+void test('the Ghola guide forwards only the current legal card and leader', () => {
+ const game = projected(), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = GholaLeaderPromiseGuideButton(props); assert.ok(button);
+ assert.equal(button.props.disabled, false); button.props.onClick();
+ assert.deepEqual(sent, [game.gholaLeaderPromiseGuide!.action]);
+ game.ghola.available = false; button.props.onClick();
+ assert.equal(sent.length, 1);
+ game.ghola.available = true; game.truthtrance = { stage: 'priority', queue: [], passed: [], question: null } as typeof game.truthtrance;
+ button.props.onClick(); assert.equal(sent.length, 1);
+});
diff --git a/tests/ghola-leader-promises.test.ts b/tests/ghola-leader-promises.test.ts
new file mode 100644
index 00000000..cadd28e7
--- /dev/null
+++ b/tests/ghola-leader-promises.test.ts
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { GholaLeaderPromiseError, gholaLeaderClaimText, gholaLeaderRequirements,
+ liveGholaLeaderPromises, parseGholaLeaderClaim, recordPromisedGholaLeader,
+ validateGholaLeaderPromises, type GholaLeaderPromise } from '../game/ghola-leader-promises';
+
+const promise = (event: string, leader = 'leader-a', changes: Partial = {}): GholaLeaderPromise =>
+ ({ event, player: 'target', asker: 'asker', turn: 2, leader, answer: true, ...changes });
+
+void test('named Ghola claims require a current turn and exact identity without coercion', () => {
+ assert.deepEqual(parseGholaLeaderClaim({ kind: 'gholaLeader', target: 'target', turn: 2, leader: 'kwisatz' }),
+ { turn: 2, leader: 'kwisatz' });
+ assert.match(gholaLeaderClaimText({ turn: 2, leader: 'kwisatz' }), /Kwisatz Haderach.*Ghola/);
+ for (const value of [null, [], {}, { turn: 0, leader: 'a' }, { turn: 2.5, leader: 'a' },
+ { turn: '2', leader: 'a' }, { turn: 2, leader: '' }, { turn: 2, leader: ' a' }])
+ assert.throws(() => parseGholaLeaderClaim(value), GholaLeaderPromiseError);
+});
+
+void test('live obligations combine duplicates, flag conflicts, and do not grant extra Ghola uses', () => {
+ const records = [promise('a'), promise('a-again'), promise('no', 'leader-b', { answer: false }),
+ promise('old', 'leader-c', { turn: 1 }), promise('other', 'leader-d', { player: 'other' }),
+ promise('done', 'leader-e', { fulfilled: true }), promise('released', 'leader-f', { released: true })];
+ assert.deepEqual(liveGholaLeaderPromises(records, 'target', 2), records.slice(0, 3));
+ assert.deepEqual(gholaLeaderRequirements(records, 'target', 2), { required: ['leader-a'], forbidden: ['leader-b'] });
+ assert.equal(gholaLeaderRequirements([...records, promise('contradiction', 'leader-a', { answer: false })], 'target', 2), null);
+});
+
+void test('only an actual matching return fulfills Yes, and No rejects without mutation', () => {
+ const records = [promise('a'), promise('a-again'), promise('no', 'leader-b', { answer: false }),
+ promise('other', 'leader-a', { player: 'other' }), promise('earlier', 'leader-a', { fulfilled: true })];
+ const before = structuredClone(records);
+ assert.throws(() => recordPromisedGholaLeader(records, 'target', 2, 'leader-b'), GholaLeaderPromiseError);
+ assert.deepEqual(records, before);
+ const after = recordPromisedGholaLeader(records, 'target', 2, 'leader-a');
+ assert.equal(after[0].fulfilled, true); assert.equal(after[1].fulfilled, true);
+ assert.equal(after[2], records[2]); assert.equal(after[3], records[3]); assert.equal(after[4], records[4]);
+ assert.deepEqual(records, before);
+ const unrelated = recordPromisedGholaLeader(records, 'target', 2, 'kwisatz');
+ assert.equal(unrelated[0], records[0]);
+ assert.throws(() => recordPromisedGholaLeader(records, 'target', 2, ' leader-a'), GholaLeaderPromiseError);
+});
+
+void test('ledger validation enforces participants, unique events and terminal state', () => {
+ const valid = [promise('a'), promise('b', 'kwisatz', { answer: false }), promise('c', 'leader-c', { released: true })];
+ assert.doesNotThrow(() => validateGholaLeaderPromises(JSON.parse(JSON.stringify(valid)), ['target', 'asker']));
+ for (const invalid of [null, [null], [promise('a'), promise('a')],
+ [promise('bad', 'a', { asker: 'target' })], [promise('bad', 'a', { player: 'absent' })],
+ [promise('bad', 'a', { fulfilled: true, released: true })],
+ [{ ...promise('bad'), answer: 'yes' }]])
+ assert.throws(() => validateGholaLeaderPromises(invalid as GholaLeaderPromise[], ['target', 'asker']), GholaLeaderPromiseError);
+});
diff --git a/tests/ghola-leader-truth-parser.test.ts b/tests/ghola-leader-truth-parser.test.ts
new file mode 100644
index 00000000..391b23e8
--- /dev/null
+++ b/tests/ghola-leader-truth-parser.test.ts
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, joinGame, newPlayer, type Game } from '../game/engine';
+import { truthQuestionText, validateTruthQuestionReceipt, type TruthQuestion } from '../game/truthtrance';
+
+/** Parser-only game; this does not claim a legal Ghola completion or bind an answer. */
+function gameAt(phase: number): Game {
+ const g = createGame('GHOLATRUTHPARSER', newPlayer('a', 'Asker', 'atreides'));
+ joinGame(g, newPlayer('p', 'Respondent', 'harkonnen'));
+ g.status = 'playing'; g.turn = 2; g.phase = phase;
+ return g;
+}
+const question = { kind: 'gholaLeader', target: 'p', turn: 2, leader: 'named-leader' } as const;
+
+void test('Ghola named-leader questions parse through quiet phases until turn end', () => {
+ for (const phase of [0, 4, 7]) {
+ const game = gameAt(phase);
+ assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
+ }
+ assert.match(truthQuestionText(question, id => id === 'named-leader' ? 'Named Commander' : id, id => id),
+ /revive Named Commander with Ghola.*end of this turn/);
+ assert.match(truthQuestionText({ ...question, leader: 'kwisatz' }, id => id, id => id), /Kwisatz Haderach/);
+ assert.throws(() => validateTruthQuestionReceipt(gameAt(8), 'a', question));
+});
+
+void test('Ghola question rejects stale, self-targeted and noncanonical receipts', () => {
+ const game = gameAt(4);
+ for (const malformed of [
+ { ...question, turn: 1 }, { ...question, target: 'a' }, { ...question, target: 'absent' },
+ { ...question, leader: '' }, { ...question, leader: ' named-leader' },
+ { ...question, extra: true },
+ ]) assert.throws(() => validateTruthQuestionReceipt(game, 'a', malformed as TruthQuestion));
+ const busy = gameAt(4);
+ busy.phaseOpening = { passed: [], initialize: false };
+ assert.throws(() => validateTruthQuestionReceipt(busy, 'a', question));
+});
diff --git a/tests/ghola-leader-truth-runtime.test.ts b/tests/ghola-leader-truth-runtime.test.ts
new file mode 100644
index 00000000..54cb2677
--- /dev/null
+++ b/tests/ghola-leader-truth-runtime.test.ts
@@ -0,0 +1,120 @@
+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 {
+ actTruthLeaderRevival as act, answerTruthLeaderRevival as answer,
+ createTruthLeaderRevivalFixture as fixture, declareTruthLeaderRevival as declare,
+ settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory,
+ truthLeaderRevivalPlayer as player,
+} from './fixture-truth-leader-revival';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+function askGhola(f: ReturnType) {
+ const declared = declare(f);
+ const game = act(declared.game, f.asker, { type: 'truthAsk', question: {
+ kind: 'gholaLeader', target: f.target, turn: declared.game.turn, leader: f.leader,
+ } });
+ return game;
+}
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} named Ghola Yes is fulfilled by an actual Ghola leader return`, () => {
+ const f = fixture({ advanced, ghola: true });
+ assert.ok(f.ghola);
+ const asked = askGhola(f);
+ assert.ok(viewGame(asked, f.target).truthGholaLeaderAnswers?.includes('yes'));
+ let game = answer(asked, f.target, 'yes');
+ const promise = game.gholaLeaderPromises!.find(row => row.player === f.target)!;
+ assert.equal(promise.leader, f.leader);
+ assert.ok(!promise.fulfilled);
+ // Spending the only dealt Ghola on a force return cannot satisfy the named leader.
+ rejected(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 });
+ game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader }));
+ assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, false);
+ assert.equal(player(game, f.target).leaderRevived, false);
+ assert.equal(game.gholaLeaderPromises!.find(row => row.event === promise.event)!.fulfilled, true);
+ assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+ });
+}
+
+void test('named Ghola No rejects the matching leader return but allows its independent force return', () => {
+ const f = fixture({ ghola: true });
+ assert.ok(f.ghola);
+ const asked = askGhola(f);
+ assert.ok(viewGame(asked, f.target).truthGholaLeaderAnswers?.includes('no'));
+ let game = answer(asked, f.target, 'no');
+ const promise = game.gholaLeaderPromises!.find(row => row.player === f.target)!;
+ rejected(game, f.target, { type: 'card', card: f.ghola, leader: f.leader });
+ game = settle(act(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 }));
+ assert.equal(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead, true);
+ assert.ok(!game.gholaLeaderPromises!.find(row => row.event === promise.event)!.fulfilled);
+ assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two Truthtrances cannot bind contradictory answers about the same Ghola leader', () => {
+ const f = fixture({ ghola: true });
+ let game = declare(f, f.game, { copies: 2 }).game;
+ const question = { kind: 'gholaLeader', target: f.target, turn: game.turn, leader: f.leader };
+ game = act(game, f.asker, { type: 'truthAsk', question });
+ game = answer(game, f.target, 'yes');
+ game = act(game, f.asker, { type: 'truthAsk', question });
+ assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
+ assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('no'));
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
+ game = answer(game, f.target, 'yes');
+ assert.equal(game.gholaLeaderPromises!.filter(row => row.player === f.target).length, 2);
+});
+
+void test('prior ordinary-revival Yes prevents a conflicting Ghola leader Yes', () => {
+ const f = fixture({ ghola: true });
+ let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
+ } });
+ game = answer(game, f.target, 'yes');
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'gholaLeader', target: f.target, turn: game.turn, leader: f.leader,
+ } });
+ assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
+ assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('no'));
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = answer(game, f.target, 'no');
+ assert.ok(game.leaderRevivalPromises!.some(row => row.player === f.target && row.answer));
+ assert.ok(game.gholaLeaderPromises!.some(row => row.player === f.target && !row.answer));
+});
+
+void test('all bot profiles choose the native named Ghola guide', () => {
+ const f = fixture({ ghola: true });
+ const game = answer(askGhola(f), f.target, 'yes');
+ const view = viewGame(game, f.target);
+ const guide = view.gholaLeaderPromiseGuide;
+ assert.ok(guide);
+ assert.equal(guide.action.type, 'card');
+ assert.equal(guide.action.leader, f.leader);
+ for (const difficulty of DIFFICULTIES) {
+ const botView = viewGame(game, f.target);
+ botView.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ assert.deepEqual(botActions(botView)[0], guide.action);
+ }
+ const after = settle(act(game, f.target, guide.action));
+ assert.ok(after.gholaLeaderPromises!.find(row => row.player === f.target)?.fulfilled);
+});
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} ordinary revival cannot consume the leader promised for Ghola`, () => {
+ const f = fixture({ advanced, ghola: true });
+ const game = answer(askGhola(f), f.target, 'yes');
+ assert.ok(game.gholaLeaderPromises!.some(row => row.player === f.target && row.answer && !row.fulfilled));
+ rejected(game, f.target, f.revivalAction);
+ assert.ok(player(game, f.target).leaders.find(leader => leader.id === f.leader)!.dead);
+ });
+}