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

+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'] },
],
},
{