Prototype named Ghola leader Truthtrance promises
Verify and publish checkpoint container / container (push) Successful in 10m45s

This commit is contained in:
Quintine committed 2026-10-29 17:34:55 +11:00
1 parent f8078ed5e2
commit c1ffa1c28a
16 files changed
+689 -9

No files matched your search

+102 -7
View File
@@ -1,3 +1,4 @@
import { GholaLeaderPromiseError, liveGholaLeaderPromises, gholaLeaderRequirements, recordPromisedGholaLeader, validateGholaLeaderPromises, type GholaLeaderClaim, type GholaLeaderPromise } from './ghola-leader-promises';
import { basicPendingMovement } from './basic-pending-movement';
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
@@ -2468,6 +2469,7 @@ export type Game = {
})[];
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
gholaLeaderPromises?: GholaLeaderPromise[];
revivalExpressionPromises?: RevivalExpressionPromise[];
/** Original mixed questions link to the existing ordinary-return ledger. */
mixedRevivalQuestions?: (MixedRevivalClaim & {
@@ -22049,7 +22051,7 @@ function turnActionRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
}
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number; gholaEvent?: string };
/** This field exists only on detached completion clones and is never returned
* as a real game or installed as a selected whole-answer branch. */
type TurnActionCardProbe = Game & {
@@ -22086,7 +22088,9 @@ function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCar
recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect));
}
function turnActionCardsMatch(g: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): boolean {
return requirements.every(required => (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
return requirements.every(required => required.gholaEvent
? !!g.gholaLeaderPromises?.find(promise => promise.event === required.gholaEvent && promise.fulfilled)
: (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
}
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
const player = getPlayer(g, actor);
@@ -22163,7 +22167,8 @@ function* turnActionBoxSeeds(source: Game, actor: string, onlyBox?: string):
/** Reject the known printed fee before paid inspection can suspend legality.
* The native owner chooses the recovered card only after actual payment. */
function guardTurnActionBoxCost(source: Game, owner: Player, box: string) {
if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length) return;
if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length &&
!liveGholaLeaderPromises(source.gholaLeaderPromises ?? [], owner.id, source.turn).some(promise => promise.answer)) return;
for (const seed of turnActionBoxSeeds(source, owner.id, box)) if (turnActionCompletion(seed.game, owner.id)) return;
throw new RuleError('The two-spice Box payment must preserve your accepted whole Truthtrance answers.');
}
@@ -22502,7 +22507,8 @@ function reopenTurnActionWindowReady(g: Game, actor: string) {
/** Check the actual spent card and returned pieces before a later foreign
* income/discard response can hide an own voluntary loss of feasibility. */
function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length) return;
if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length &&
!liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], player.id, g.turn).some(promise => promise.answer)) return;
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
owner.spice += pendingBattleRevivalIncome(trial, owner);
const suspended = g.pendingTreacheryDiscard?.continuation;
@@ -23071,6 +23077,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
delete trial.travelPromiseOrder;
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
for (const promise of liveGholaLeaderPromises(source.gholaLeaderPromises ?? [], actor, source.turn))
if (promise.answer) cardRequirements.push({ effect: 'ghola', answer: true,
baseline: nativePrintedCardCount(source, actor, 'ghola'), gholaEvent: promise.event });
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
};
@@ -32959,6 +32968,10 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
);
if (cardId) stageHomeworldRevivalReturn(g, p, 'ghola', group, grant, cardId);
}
if (typeof action.leader === 'string') {
try { g.gholaLeaderPromises = recordPromisedGholaLeader(g.gholaLeaderPromises ?? [], p.id, g.turn, action.leader); }
catch (error) { if (error instanceof GholaLeaderPromiseError) throw new RuleError(error.message); throw error; }
}
techIncome(g, 'axlotl', p);
collectRevivalIncome(g, p, 0, false, true);
recordNativePrintedCardPlay(g, p.id, 'ghola');
@@ -37474,6 +37487,57 @@ function leaderRevivalIdentity(g: Game, p: Player, leader: string): boolean {
return leader === 'kwisatz' ? g.advanced && p.faction === 'atreides' :
leader === DUKE_VIDAL_ID ? p.faction === 'ecaz' && !!g.dukeVidal : p.leaders.some(l => l.id === leader);
}
function gholaLeaderTruthAnswers(g: Game, p: Player, claim: GholaLeaderClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !leaderRevivalIdentity(g, p, claim.leader)) return [];
const ask = g.truthtrance?.queue[0]?.player ?? g.host;
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.gholaLeaderPromises ?? []), { ...claim, event: 'ghola-candidate',
player: p.id, asker: ask, answer: answer === 'yes' }];
const requirements = gholaLeaderRequirements(promises, p.id, g.turn);
if (!requirements || requirements.required.length > 1) return false;
return !!turnActionCompletion({ ...g, gholaLeaderPromises: promises }, p.id);
});
}
function bindGholaLeaderTruth(g: Game, p: Player, claim: GholaLeaderClaim, answer: boolean, asker: string) {
requireRule(gholaLeaderTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose an answer with a legal named Ghola completion.');
(g.gholaLeaderPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer });
}
function reconcileGholaLeaderTruth(g: Game, voluntary?: { actor: string; action: Action }) {
if (!g.gholaLeaderPromises?.length) return;
try { validateGholaLeaderPromises(g.gholaLeaderPromises, g.players.map(p => p.id)); }
catch (error) { if (error instanceof GholaLeaderPromiseError) throw new RuleError(error.message); throw error; }
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama) return;
for (const promise of g.gholaLeaderPromises) {
if (promise.fulfilled || promise.released) continue;
if (!promise.answer && (promise.turn !== g.turn || g.status !== 'playing')) { promise.fulfilled = true; continue; }
if (promise.answer && (promise.turn !== g.turn || g.status !== 'playing' || !turnActionCompletion(g, promise.player))) {
requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your named Ghola Truthtrance answer.');
promise.released = true;
}
}
}
function projectedGholaLeaderGuide(g: Game, player: string): { action: Action } | null {
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama) return null;
const promise = liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], player, g.turn).find(p => p.answer);
if (!promise) return null;
// A completion can defer the Ghola play into a later movement forecast.
// Offer an immediate action only if its actual physical play is legal now
// and leaves a joint completion for every other accepted answer.
for (const action of nativePrintedCardActions(g, player, 'ghola')) {
if (action.leader !== promise.leader) continue;
try {
const trial = applyActionInner(g, player, action, 'movementPreparation');
if (!trial.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled)) continue;
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
const owner = getPlayer(trial, player);
owner.spice += pendingBattleRevivalIncome(trial, owner);
reconcileRevivalPromises(trial, { actor: player, action });
if (turnActionCompletion(trial, player)) return { action };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
}
function leaderRevivalTruthAnswers(g: Game, p: Player, claim: LeaderRevivalClaim): ('yes' | 'no' | 'unknown')[] {
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn ||
!leaderRevivalIdentity(g, p, claim.leader)) return [];
@@ -37586,7 +37650,8 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!hasMovementPromises(g, p.id) &&
!hasBribePromises(g, p.id, true)) return;
!hasBribePromises(g, p.id, true) &&
!liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], p.id, g.turn).some(promise => promise.answer)) return;
if (!quote) {
requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []),
'Complete the named leader returns required by your Truthtrance Yes before finishing Revival.');
@@ -37596,8 +37661,10 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
trial.truthtrance = null;
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
stagePromisedLeaderReturn(trial, actor, quote);
requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}),
'This leader request would prevent you from honoring your Revival answers.');
requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}) &&
(!liveGholaLeaderPromises(trial.gholaLeaderPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
!!turnActionCompletion(trial, p.id)),
'This leader request would prevent you from honoring your Truthtrance answers.');
}
/** Reconcile the shared purse in actual answer order across both ledgers.
* Existing records without an order stamp precede newly accepted answers. */
@@ -43181,6 +43248,24 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
// turn that forced debit into the passer's voluntary payment or expenditure.
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
const pledgeVoluntary = initiator === id ? voluntary : undefined;
// Voluntary actions may create a response owned by somebody else before the
// regular reconciler can run. Quote the already-completed action on a
// detached state before that response can hide loss of the last witness.
if (voluntary && liveGholaLeaderPromises(state.gholaLeaderPromises ?? [], id, state.turn).some(promise => promise.answer)) {
const prior = liveGholaLeaderPromises(state.gholaLeaderPromises ?? [], id, state.turn).filter(promise => promise.answer);
const unresolved = prior.some(promise => !g.gholaLeaderPromises?.find(row => row.event === promise.event && row.fulfilled));
if (unresolved && (g.turn !== state.turn || g.status !== 'playing'))
throw new RuleError('Complete your named Ghola Truthtrance answer before ending this turn.');
if (unresolved && !state.truthtrance && !state.response && !state.decision && !state.phaseOpening &&
!state.pendingTreacheryDiscard && !state.pendingKarama &&
(g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama)) {
const trial = structuredClone(g);
trial.truthtrance = null; trial.response = null; trial.decision = null;
trial.phaseOpening = null; trial.pendingTreacheryDiscard = null; trial.pendingKarama = null;
requireRule(!!turnActionCompletion(trial, id),
'This action would remove the last legal completion of your named Ghola Truthtrance answer.');
}
}
reconcilePledgeCommitments(g, pledgeVoluntary);
reconcileTravelPromises(g, promiseActor, action, state.phase, state);
reconcileBidPromises(g, voluntary);
@@ -43189,6 +43274,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileRicheseBids(g, voluntary);
reconcileCharityTruth(g, voluntary);
reconcileRevivalPromises(g, voluntary);
reconcileGholaLeaderTruth(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
@@ -44055,6 +44141,8 @@ function applyActionInner(
bindRevivalExpression: bindRevivalExpressionTruth,
mixedRevivalAnswers: mixedRevivalTruthAnswers,
bindMixedRevival: bindMixedRevivalTruth,
gholaLeaderAnswers: gholaLeaderTruthAnswers,
bindGholaLeader: bindGholaLeaderTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
@@ -48186,6 +48274,8 @@ function applyActionInner(
return g;
}
if (t === 'ready') {
if (g.phase === 8) requireRule(!liveGholaLeaderPromises(g.gholaLeaderPromises ?? [], id, g.turn)
.some(promise => promise.answer), 'Complete your named Ghola Truthtrance answer before finishing Mentat Pause.');
requireRule(!(g.phase === 1 && g.nexus && g.coalitionState?.proposal),
'Resolve the group invitation before finishing the Nexus.');
if (g.phase === 2) {
@@ -48545,6 +48635,11 @@ export function viewGame(state: Game, id: string, now?: number) {
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
revivalPromises: (g.revivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
revivalPromiseGuide: projectedRevivalPromiseGuide(g, id),
truthGholaLeaderTargets: g.status === 'playing' && g.phase >= 0 && g.phase <= 7 ? g.players.map(player => player.id) : [],
truthGholaLeaderAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'gholaLeader' ? gholaLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
gholaLeaderPromises: (g.gholaLeaderPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
gholaLeaderPromiseGuide: projectedGholaLeaderGuide(g, id),
truthLeaderRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthLeaderRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'leaderRevival' ? leaderRevivalTruthAnswers(g, me, g.truthtrance.question) : null,