Prototype named Ghola leader Truthtrance promises
Verify and publish checkpoint container / container (push) Successful in 10m45s

This commit is contained in:
Quintine committed 2026-10-29 17:34:55 +11:00
1 parent f8078ed5e2
commit c1ffa1c28a
16 files changed
+689 -9

No files matched your search

+2
View File
@@ -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' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <GholaLeaderPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <PhaseBidPromises game={g} send={act} busy={busy} />}
+46
View File
@@ -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 <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Named Ghola leader return condition">
<legend>Future Ghola leader return</legend>
<p>Turn {props.value.turn}</p>
<label htmlFor="truth-ghola-leader">Named leader</label>
<select id="truth-ghola-leader" disabled={props.disabled}
value={props.leaders.some(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));
}}>
<option value="">Choose a named leader</option>
{props.leaders.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
</select>
{error && <p role="alert">{error}</p>}
<p className="fine">
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.
</p>
</fieldset>;
}
+55
View File
@@ -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 <Button className="game-action min-h-11 whitespace-normal" data-testid="ghola-leader-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
Revive {leaderName(game, typeof action.leader === 'string' ? action.leader : '')} with Ghola while honoring your answer
</Button>;
}
export function GholaLeaderPromises(props: Props) {
const own = props.game.gholaLeaderPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
return <section className="notice space-y-3" aria-label="Your Truthtrance Ghola leader promises" id="ghola-leader-promise-guidance">
<h3>Your Ghola leader promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
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.'}
</li>)}</ul>
<p>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.</p>
<p className="fine">The original card timing and leader eligibility still apply. This question is a provisional interpretation of Truthtrance.</p>
<GholaLeaderPromiseGuideButton {...props} />
</section>;
}
+21 -2
View File
@@ -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 })}
</div>)}
</>
) : w.question!.kind === 'gholaLeader' ? (
<>
<p className="notice">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.</p>
{(g.truthGholaLeaderAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'leaderRevival' ? (
<>
<p className="notice">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.</p>
@@ -670,6 +678,7 @@ export function Truthtrance({
<option value="mixedRevival">Combine current facts and ordinary Revival</option>
<option value="revivalExpression">Bind combined force and leader revivals</option>
<option value="leaderRevival">Bind a named leader revival this turn</option>
<option value="gholaLeader">Bind a named leader return with Ghola this turn (preview)</option>
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="charity">Bind a Charity claim this turn</option>
@@ -768,6 +777,15 @@ export function Truthtrance({
{!g.truthRevivalExpressionTargets.includes(target) && <p className="notice">Choose a supported participant with an ordinary Revival opportunity still ahead this turn.</p>}
<p className="notice" aria-label="Public grouped revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'gholaLeader' ? (
<>
<GholaLeaderClaimFields value={leaderRevivalClaim} leaders={leaderRevivalChoices}
disabled={busy || !g.truthGholaLeaderTargets.includes(target)}
onChange={claim => setRevivalLeader(claim.leader)} />
<p className="fine">This question is a provisional preview. Only a named leader returned through Ghola after the answer counts.</p>
{!g.truthGholaLeaderTargets.includes(target) && <p className="notice">Choose a supported participant for this question.</p>}
<p className="notice" aria-label="Public named Ghola leader question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'leaderRevival' ? (
<>
<LeaderRevivalClaimFields value={leaderRevivalClaim} leaders={leaderRevivalChoices}
@@ -988,6 +1006,7 @@ export function Truthtrance({
(kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) ||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
(kind === 'gholaLeader' && (!!gholaLeaderClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthGholaLeaderTargets.includes(target))) ||
(kind === 'mixedBattlePlan' && (!!mixedBattleError || !g.truthMixedBattleTargets.includes(target))) ||
(kind === 'allianceExpression' && (!!allianceExpressionError || !g.truthAllianceExpressionTargets.includes(target))) ||
(kind === 'mixedAlliance' && (!!mixedAllianceError || !g.truthMixedAllianceTargets.includes(target))) ||
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Truthtrance named Ghola leader — 29 October. Prototyped / Partial.** A
provisional question binds an actual named leader return with Ghola, distinct
from ordinary revival or returning forces. Private human and bot guidance uses
the same native action; the shared planner checks earlier commitments and
protects the remaining card and leader. One distinct outstanding Yes target is
supported; complex pending interactions are conservatively guarded. Nine
related files pass 128 cases. Full interactions, saved-game assurance and
deployed verification remain open.
[Contract](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md#named-ghola-leader-question--29-october-2026).
**Distrans/Semuta special-auction continuation — 29 October. Prototyped /
Partial.** The Semuta claim/pass following a Distrans transfer can now suspend
and resume a supported Once Around, Silent or normal Black Market lot, retaining
@@ -4,6 +4,40 @@ The [grouped follow-up](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md) combines these named
returns and ordinary force minima with whole AND/OR, preserving dynamic choices
and the shared native planner. Scalar questions keep their existing meaning.
## Named Ghola leader question — 29 October 2026
**Prototyped / Partial.** A separate provisional
`{kind:'gholaLeader',target,turn,leader}` question asks whether the respondent
will personally return the named leader with an owned Tleilaxu Ghola card after
the answer and before the current turn ends. This is a composition of the
general current-turn Truthtrance authority with the existing Ghola effect,
not a publisher ruling on this exact question. Public leader names may be used
to author the question; the answer, any commitment and its guidance belong to
the respondent's private view.
An actual successful Ghola return of the named leader fulfills Yes. Playing
Ghola to return forces or a different leader, making an ordinary or special
revival, another player's foreign Ghola, a prior play, a pending request or a
prevented return does not. No prohibits the named personal Ghola return during
the relevant remaining turn without prohibiting other legal Ghola choices.
The question grants no card, leader, return allowance or exception to native
card timing, eligibility, custody or physical costs. Existing ordinary
leader-revival questions and Ghola-play questions retain their separate meaning.
A Yes answer must have a real owned completion consistent with existing
obligations; the engine must protect that completion against voluntary actions
and handle actual later prevention without inventing hidden draws, foreign
consent or future casualties. Conflicting promises must not be accepted merely
because each is feasible alone. Exact support for recovery, concurrent promise
families and special interaction profiles remains a matter for the integrated
planner and focused tests. Human guidance and minimal legal bot participation
must use the same authoritative answer and return choices. The prototype uses the shared completion planner, private guidance and minimal bot
actions. It supports one distinct outstanding Yes target; more than one remains
guarded. Pending-interaction preflight is conservative. Nine related files pass
128 focused cases, including native Basic and Advanced returns, contradictory
answers and competing ordinary returns. Full interactions, saved-game assurance,
complete modes and deployed behavior remain open.
## Native future-return prototype — 26 October 2026
**Prototyped / Partial.** Registered bare native Basic/Advanced tables support
+5
View File
@@ -1343,6 +1343,7 @@ function policyActions(g: GameView): Action[] {
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
if (g.gholaLeaderPromiseGuide) return [g.gholaLeaderPromiseGuide.action];
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
if (g.richeseBidPromiseGuide) return [g.richeseBidPromiseGuide.action];
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
@@ -1376,6 +1377,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthLeaderRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'gholaLeader') {
const answer = g.truthGholaLeaderAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'allianceExpression' || w.question.kind === 'mixedAlliance') {
const choices = w.question.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers;
const answer = choices?.find(answer => answer !== 'unknown') ?? choices?.[0];
+102 -7
View File
@@ -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<T>(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<Action> {
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,
+82
View File
@@ -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<string, unknown>;
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<string>(), forbidden = new Set<string>();
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<string>();
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);
}
}
+19
View File
@@ -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',
+23
View File
@@ -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.');
+41
View File
@@ -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<ElementProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function choose(props: Parameters<typeof GholaLeaderClaimFields>[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"/);
});
@@ -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);
});
+51
View File
@@ -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> = {}): 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);
});
+36
View File
@@ -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));
});
+120
View File
@@ -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<typeof fixture>) {
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);
});
}