diff --git a/components/ghola-leader-promises.tsx b/components/ghola-leader-promises.tsx index 787f9ca1..d970df61 100644 --- a/components/ghola-leader-promises.tsx +++ b/components/ghola-leader-promises.tsx @@ -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 ; } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7d2480b0..5df98643 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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). diff --git a/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md index 3f1aeb94..fb2ee362 100644 --- a/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md +++ b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md @@ -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. diff --git a/game/bots.ts b/game/bots.ts index 54eba69f..7bc6baa3 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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); diff --git a/game/engine.ts b/game/engine.ts index 69d47693..629baacd 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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.'); diff --git a/game/reference.ts b/game/reference.ts index ceece3fe..ec347dfa 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'] }, ], }, { diff --git a/tests/ghola-leader-promise-controls.test.tsx b/tests/ghola-leader-promise-controls.test.tsx index 47518a19..d58e7b24 100644 --- a/tests/ghola-leader-promise-controls.test.tsx +++ b/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); +}); diff --git a/tests/multi-ghola-leader-runtime.test.ts b/tests/multi-ghola-leader-runtime.test.ts new file mode 100644 index 00000000..60ff5f1b --- /dev/null +++ b/tests/multi-ghola-leader-runtime.test.ts @@ -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) { + 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, 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); +});