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}

+ + + {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 ; +} + +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({ + @@ -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' ? ( <>