Support multiple named Ghola commitments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 17:50:08 +11:00
1 parent 6c1530382d
commit bdd5aaf18c
8 files changed
+286 -26

No files matched your search

+22 -10
View File
@@ -14,25 +14,37 @@ export function GholaLeaderPromiseGuideButton(props: Props) {
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 box = game.nullentropy, search = box?.search, boxSignature = JSON.stringify(box);
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) &&
const live = liveGholaLeaderPromises(current.gholaLeaderPromises, actor, turn).filter(promise => promise.answer);
const named = typeof action.leader === 'string' && live.some(promise => promise.leader === action.leader);
const ghola = action.type === 'card' && action.mode === undefined && typeof action.card === 'string' && !!named &&
!!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 &&
: quote.leaders.some(leader => leader.id === action.leader)) && !current.decision;
const startBox = action.type === 'card' && action.mode === undefined && typeof action.card === 'string' &&
!!card && card.kind === 'special' && card.effect === 'nullentropyBox' && !!me?.hand?.includes(card) &&
current.nullentropy === box && JSON.stringify(box) === boxSignature && !!box &&
box.card.id === card.id && !box.blocked && !box.search && !current.decision;
const chooseBox = action.type === 'decision' && typeof action.card === 'string' &&
current.decision?.kind === 'nullentropy' && current.decision.player === actor &&
current.nullentropy === box && JSON.stringify(box) === boxSignature && !!search &&
box?.search === search && search.event === action.event && !box.blocked &&
search.cards.some(candidate => candidate.id === action.card && candidate.effect === 'ghola');
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 && live.length > 0 && !!me && !me.autopilot &&
(ghola || startBox || chooseBox) && !current.truthtrance && !current.response && !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
{action.type === 'decision' ? 'Recover Ghola from your paid Nullentropy Box search'
: card?.effect === 'nullentropyBox' ? 'Pay 2 spice to search with Nullentropy Box for your Ghola answer'
: `Revive ${leaderName(game, typeof action.leader === 'string' ? action.leader : '')} with Ghola while honoring your answer`}
</Button>;
}
+11 -2
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Multiple named Ghola returns — 29 October. Prototyped / Partial.**
Multiple distinct Yes answers can share a bounded native completion when
currently held Ghola and known Box recovery permit separate physical returns.
The private human and bot guide covers the Box start and selection. A controlled
fixture stages two leader deaths and conserved cards; ten related files pass
132 cases. Future-resource preparation, forecasts beginning in an already-paid
Box search, broader interactions and deployed verification remain open.
[Contract](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md#multiple-named-ghola-answers--29-october-2026).
**Group paid travel — 29 October. Prototyped / Partial.** A Basic group
travel offer can include one immediate bribe between participants. Every
party consents before the named traveler accepts last; payment and the travel
@@ -40,8 +49,8 @@ native revival income before clearing its response in the detached forecast.
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
protects the remaining card and leader. The later multiple-target prototype is described above; 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).
+31 -2
View File
@@ -4,6 +4,35 @@ 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.
## Multiple named Ghola answers — 29 October 2026
**Prototyped / Partial.** Multiple
simultaneously outstanding Yes answers may name different leaders when the
shared planner can execute their actual owned Ghola returns with currently
held cards and known, paid Nullentropy Box recovery. Repeated Yes answers for
the same leader can share that one return; different leaders require different
physical plays. Search is bounded by the outstanding names and actual held
Boxes. The owner's guide can offer the next immediately executable Ghola play,
the Box start, and its paid private search choice without revealing a hidden
card to other players.
After a prior return, an owned paid Box search can retain its original decision
and quote the real eligible Ghola choice without charging twice. Forecasting
multiple outstanding returns starting from an already-open paid search remains
unsupported.
A multi-name Yes is not inferred from unknown draws, invented casualties,
foreign consent or hypothetical future preparation. The multi-name planner is
currently limited to already executable owned paths and known recovery; other
future preparation, richer interactions, final saved-game assurance and deployed
acceptance remain open. These are provisional online completion policies, not
a publisher ruling.
The focused multi-name runtime fixture starts from a native Richese/Box game
and retains one physical Ghola. It explicitly stages custody of that card and
the second Truthtrance and stages two dead leaders; it does not claim those
acquisitions or deaths were earned in play. The focused union passes 132 cases; this does not certify complete modes or
deployed behavior.
## Named Ghola leader question — 29 October 2026
**Prototyped / Partial.** A separate provisional
@@ -32,8 +61,8 @@ 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
actions. The newer multiple-name scope above supersedes the earlier one-target
cutoff. 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.
+3
View File
@@ -1305,6 +1305,9 @@ function policyActions(g: GameView): Action[] {
if (g.decision.player !== me.id) return [];
const search = g.nullentropy?.search;
if (!search) return [];
const promised = g.gholaLeaderPromiseGuide?.action;
if (promised?.type === 'decision' && promised.event === search.event &&
search.cards.some(card => card.id === promised.card && card.effect === 'ghola')) return [promised];
const ordered = [...search.cards].sort((a, b) => {
const score = (card: typeof a) => {
const richese = richeseCardDefinition(card);
+80 -9
View File
@@ -22201,6 +22201,53 @@ function* turnActionTransferOrderSeeds(source: Game, actor: string, requirements
* A separate Movement forecast retains the later-play alternative. */
function* turnActionOwnedCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
Generator<{ game: Game; actions: Action[] }> {
const outstandingTargets = (game: Game): string[] => [...new Set(requirements.flatMap(required => {
if (!required.gholaEvent) return [];
const promise = game.gholaLeaderPromises?.find(row => row.event === required.gholaEvent);
return promise && !promise.fulfilled && !promise.released && promise.answer ? [promise.leader] : [];
}))];
const targets = outstandingTargets(source);
if (targets.length > 1) {
// A named return can share a physical Ghola play with same-leader answers,
// but different leaders need distinct plays. Search actual held Ghola and
// known Box recoveries only; neither random draws nor extra cards are
// invented. Each Ghola step satisfies a new identity and each Box step
// consumes a physical Box, so the recursion is bounded.
const maxSteps = targets.length + getPlayer(source, actor).hand.filter(card => card.effect === 'nullentropyBox').length;
const search = function* (game: Game, actions: Action[]): Generator<{ game: Game; actions: Action[] }> {
if (actions.length >= maxSteps) return;
const remaining = outstandingTargets(game);
if (!remaining.length) return;
for (const action of nativePrintedCardActions(game, actor, 'ghola')) {
if (typeof action.leader !== 'string' || !remaining.includes(action.leader)) continue;
try {
const played = applyActionInner(game, actor, action, 'movementPreparation');
const owner = getPlayer(played, actor);
owner.spice += pendingBattleRevivalIncome(played, owner);
const projected = groundMovementProbe(played, actor), next = [...actions, action];
const outstanding = outstandingTargets(projected);
if (outstanding.length >= remaining.length) continue;
if (!outstanding.length) yield { game: projected, actions: next };
else yield* search(projected, next);
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
const owner = getPlayer(game, actor);
const known = knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard)
.filter(card => card.effect === 'ghola');
for (const box of owner.hand.filter(card => card.effect === 'nullentropyBox')) for (const card of known) {
const projected = projectMovementBoxRecovery(game, actor, box.id, card.id, false);
if (!projected) continue;
const next = [...actions, { type: 'card', card: box.id }];
yield* search(projected, next);
}
};
yield* search(source, []);
// For several names, do not let the generic one-card forecast search an
// expensive movement tree after each partial play: it tracks printed-use
// counts, not named-event progress. This bounded current-state route keeps
// already accepted one-name forecasts unchanged.
return;
}
const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer &&
nativePrintedCardCount(source, actor, 'ghola') <= required.baseline);
const boxDue = requirements.some(required => required.effect === 'nullentropyBox' && required.answer &&
@@ -37494,7 +37541,7 @@ function gholaLeaderTruthAnswers(g: Game, p: Player, claim: GholaLeaderClaim): T
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;
if (!requirements) return false;
return !!turnActionCompletion({ ...g, gholaLeaderPromises: promises }, p.id);
});
}
@@ -37518,17 +37565,29 @@ function reconcileGholaLeaderTruth(g: Game, voluntary?: { actor: string; action:
}
}
function projectedGholaLeaderGuide(g: Game, player: string): { action: Action } | null {
const promises = liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], player, g.turn).filter(p => p.answer);
if (!promises.length) return null;
// A paid Box selection reveals its candidates only to its owner. Prefer a
// known physical Ghola that preserves all remaining commitments.
if (g.pendingNullentropy?.player === player && g.decision?.kind === 'nullentropy' &&
g.decision.player === player && !g.truthtrance && !g.response && !g.pendingKarama && !g.pendingTreacheryDiscard) {
const paid = g.pendingNullentropy;
if (!nullentropyIntegrity(g)) for (const card of eligibleNullentropyCards(g.discard).filter(card => card.effect === 'ghola')) {
const projected = projectMovementBoxRecovery(g, player, paid.box, card.id, true);
if (projected && turnActionCompletion(projected, player))
return { action: { type: 'decision', event: paid.event, card: card.id } };
}
return 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.
// A completion may defer a play into a later Movement forecast. Quote an
// immediately legal native return only if the same physical play preserves
// every remaining commitment.
for (const action of nativePrintedCardActions(g, player, 'ghola')) {
if (action.leader !== promise.leader) continue;
if (!promises.some(promise => promise.leader === action.leader)) continue;
try {
const trial = applyActionInner(g, player, action, 'movementPreparation');
if (!trial.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled)) continue;
if (!promises.some(promise => trial.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled))) continue;
const owner = getPlayer(trial, player);
owner.spice += pendingBattleRevivalIncome(trial, owner);
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
@@ -37536,6 +37595,15 @@ function projectedGholaLeaderGuide(g: Game, player: string): { action: Action }
if (turnActionCompletion(trial, player)) return { action };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
// If the shared plan starts with an owned Box, expose that exact native
// start; the paid private search supplies its own decision on the next step.
const owner = getPlayer(g, player);
const known = knownMovementDiscards(g.movementDiscardKnowledge, player, g.discard)
.filter(card => card.effect === 'ghola');
for (const box of owner.hand.filter(card => card.effect === 'nullentropyBox')) for (const card of known) {
const projected = projectMovementBoxRecovery(g, player, box.id, card.id, false);
if (projected && turnActionCompletion(projected, player)) return { action: { type: 'card', card: box.id } };
}
return null;
}
function leaderRevivalTruthAnswers(g: Game, p: Player, claim: LeaderRevivalClaim): ('yes' | 'no' | 'unknown')[] {
@@ -43260,7 +43328,10 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
!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.truthtrance = null; trial.response = null;
// The owner's paid Box choice is part of the witness, not an unrelated
// foreign decision to clear before quoting its eventual selected card.
if (!(trial.pendingNullentropy?.player === id && trial.decision?.kind === 'nullentropy')) 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.');
+3 -3
View File
@@ -3660,8 +3660,8 @@ export const RULE_TOPICS: RuleTopic[] = [
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.',
'Yes requires a compatible owned completion; No forbids that specific Ghola leader return. Different Yes leaders require distinct actual plays; currently executable held Ghola and known Box recovery can supply them. Unknown draws and broader future preparation are not assumed; some pending interactions remain conservatively guarded.',
'The private panel and minimal bot guide use the native Ghola action, paid Box start and its original private search. The multi-name test stages two dead leaders and conserved card custody; broader future preparation, saved-game assurance and deployed acceptance remain open.',
],
related: ['card-truthtrance', 'truthtrance-leader-revival'],
checklist: [
@@ -3669,7 +3669,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused native and control tests; deployed acceptance is open.', evidence: ['tests/ghola-leader-truth-runtime.test.ts', 'tests/multi-ghola-leader-runtime.test.ts', 'tests/ghola-leader-promise-controls.test.tsx'] },
],
},
{
@@ -40,3 +40,27 @@ void test('the Ghola guide forwards only the current legal card and leader', ()
game.ghola.available = true; game.truthtrance = { stage: 'priority', queue: [], passed: [], question: null } as typeof game.truthtrance;
button.props.onClick(); assert.equal(sent.length, 1);
});
void test('Ghola guide starts the current Box and rejects a stale quote', () => {
const game = projected(), me = game.players.find(player => player.id === 'p')!;
const box = { id: 'box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
game.gholaLeaderPromiseGuide = { action: { type: 'card', card: box.id } };
const sent: Action[] = [], button = GholaLeaderPromiseGuideButton({ game, busy: false, send(action) { sent.push(action); } });
assert.ok(button); assert.equal(button.props.disabled, false); button.props.onClick();
assert.deepEqual(sent, [game.gholaLeaderPromiseGuide.action]);
game.nullentropy.blocked = 'The search is no longer available.'; button.props.onClick();
assert.equal(sent.length, 1);
});
void test('Ghola guide selects only its current paid Box search card', () => {
const game = projected(), box = { id: 'box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
const ghola = { id: 'recovered', name: 'Ghola', kind: 'special' as const, effect: 'ghola' };
game.decision = { kind: 'nullentropy', player: 'p' };
game.nullentropy = { card: box, blocked: null, search: { event: 'paid', cards: [ghola] } };
game.gholaLeaderPromiseGuide = { action: { type: 'decision', event: 'paid', card: ghola.id } };
const sent: Action[] = [], button = GholaLeaderPromiseGuideButton({ game, busy: false, send(action) { sent.push(action); } });
assert.ok(button); assert.equal(button.props.disabled, false); button.props.onClick();
assert.deepEqual(sent, [game.gholaLeaderPromiseGuide.action]);
game.nullentropy.search!.cards = []; button.props.onClick(); assert.equal(sent.length, 1);
});
+112
View File
@@ -0,0 +1,112 @@
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 { actTruthMovementGhola as act, advanceTruthMovementGhola as advance,
createTruthMovementBoxGholaFixture as fixture, truthMovementGholaClean as clean,
truthMovementGholaInventory as inventory, truthMovementGholaPlayer as player } from './fixture-truth-movement-ghola';
/** Original native Richese Box source, with controlled physical custody and
* two staged dead Atreides leaders. Each card is moved, never copied; this is
* a targeted composition fixture, not evidence of earned leader deaths. */
function source(withBox: boolean) {
const f = fixture(), game = structuredClone(f.game);
const actor = player(game, f.target), asker = player(game, f.asker);
const gholaIndex = game.discard.findIndex(card => card.id === f.ghola);
assert.ok(gholaIndex >= 0);
actor.hand.push(game.discard.splice(gholaIndex, 1)[0]);
if (!withBox) {
const index = actor.hand.findIndex(card => card.id === f.box); assert.ok(index >= 0);
game.richeseCache!.push(actor.hand.splice(index, 1)[0]);
}
const otherTruth = game.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(otherTruth >= 0, 'The second physical Truthtrance must remain in the undealt deck');
asker.hand.push(game.deck.splice(otherTruth, 1)[0]);
const truths = asker.hand.filter(card => card.effect === 'truthtrance');
assert.equal(truths.length, 2);
const leaders = actor.leaders.slice(0, 2);
for (const leader of leaders) { leader.dead = true; leader.deaths = 1; delete leader.usedAt; }
assert.deepEqual(inventory(game), f.originalCards);
return { ...f, game, leaders: leaders.map(leader => leader.id), truths: truths.map(card => card.id) };
}
function declared(f: ReturnType<typeof source>) {
let game = act(f.game, f.asker, { type:'card', card:f.truths[0], cards:[f.truths[1]] });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
game = act(game, seat.id, { type:'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
function settleDiscard(game: Game): Game {
for (let count = 0; game.pendingTreacheryDiscard && count < 20; count++) {
const pending = game.pendingTreacheryDiscard;
if (pending.reaction?.stage === 'offer') {
const passed = pending.reaction.passed;
const actor = game.players.find(p => !passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type:'semutaPass', event:pending.batch.event });
} else {
assert.ok(!pending.reaction, 'A neutral pass does not select a card');
game = act(game, game.host, { type:'advanceBots' });
}
}
assert.equal(game.pendingTreacheryDiscard, null);
return game;
}
function ask(game: Game, f: ReturnType<typeof source>, leader: string) {
return act(game, f.asker, { type:'truthAsk', question: {kind:'gholaLeader', target:f.target, turn:game.turn, leader} });
}
function answer(game: Game, actor: string, value: 'yes' | 'no') {
return act(game, actor, {type:'truthAnswer', answer:value});
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
void test('two distinct Ghola Yes answers require a real recovery route', () => {
const f = source(false);
let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes');
game = ask(settleDiscard(game), f, f.leaders[1]);
assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
reject(game, f.target, {type:'truthAnswer', answer:'yes'});
game = answer(game, f.target, 'no');
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two named Ghola Yes answers reuse one physical card through the paid Box', () => {
const f = source(true);
let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes');
game = ask(settleDiscard(game), f, f.leaders[1]);
assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && !row.fulfilled).length, 2);
game = advance(game, clean);
const firstGuide = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(firstGuide); assert.equal(firstGuide.action.type, 'card');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target);
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], firstGuide.action);
}
game = act(game, f.target, firstGuide.action);
game = advance(game, clean);
assert.ok(game.discard.some(card => card.id === f.ghola));
assert.equal(game.gholaLeaderPromises!.filter(row => row.fulfilled).length, 1);
const recovery = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(recovery, 'The owner receives the next Box preparation');
assert.equal(recovery.action.type, 'card'); assert.equal(recovery.action.card, f.box);
game = act(game, f.target, recovery.action);
if (game.decision?.kind === 'nullentropy')
game = act(game, f.target, {type:'decision', event:viewGame(game, f.target).nullentropy!.search!.event, card:f.ghola});
game = advance(game, clean);
assert.ok(player(game, f.target).hand.some(card => card.id === f.ghola));
const secondGuide = viewGame(game, f.target).gholaLeaderPromiseGuide;
assert.ok(secondGuide); assert.equal(secondGuide.action.card, f.ghola);
assert.notEqual(secondGuide.action.leader, firstGuide.action.leader);
game = advance(act(game, f.target, secondGuide.action), clean);
assert.ok(f.leaders.every(id => !player(game, f.target).leaders.find(leader => leader.id === id)!.dead));
assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && row.fulfilled).length, 2);
assert.deepEqual(inventory(game), f.originalCards);
});