Prototype named Ghola leader Truthtrance promises
Verify and publish checkpoint container / container (push) Successful in 10m45s
Verify and publish checkpoint container / container (push) Successful in 10m45s
This commit is contained in:
1 parent
f8078ed5e2
commit
c1ffa1c28a
16 files changed
+689
-9
No files matched your search
@@ -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} />}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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))) ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user