Support multiple named Ghola commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
6c1530382d
commit
bdd5aaf18c
8 files changed
+286
-26
No files matched your search
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user