Add named Truthtrance leader-revival commitments

This commit is contained in:
Quintine committed 2026-10-26 14:52:17 +11:00
1 parent 5627512d16
commit a76effb932
20 files changed
+1681 -180

No files matched your search

+342 -176
View File
@@ -1,3 +1,6 @@
import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements,
leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises,
type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises';
import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements,
alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises';
import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe,
@@ -2143,6 +2146,9 @@ export type Game = {
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
/** Shared accepted order for typed force and named leader answers. */
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[];
@@ -15100,7 +15106,7 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?
} catch (error) { if (error instanceof RuleError) return null; throw error; }
}
function bribeCompatibleLaterPromises(g: Game, p: Player,
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'battle'>): boolean {
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'battle'>): boolean {
if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true;
return !!bribePromiseCompletion({ ...g, ...later }, p);
}
@@ -15170,7 +15176,8 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]);
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0;
const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer);
const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer);
const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer);
const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0;
// Reuse the authoritative promise checks on an isolated resource-only copy.
// No payment, receipt or live continuation is created by this projection.
@@ -15403,6 +15410,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) {
const trial = structuredClone(g);
trial.truthtrance = null;
@@ -29955,6 +29963,27 @@ function projectOwnedOrdinaryRevival(g: Game, owner: string) {
commitRevivalForcePieces(g, p, quote.pending);
g.pendingRevival = null; g.decision = null; g.response = null;
}
/** Physical leader/cohort/slot accounting shared by actual returns and private
* allowed-outcome quotes. Income, skills, logs and continuations stay native. */
function commitRevivalLeaderPieces(g: Game, p: Player, revival: PendingRevival) {
let leader: Leader | undefined;
if (revival.kind === 'kwisatz') {
p.kwisatz!.dead = false;
p.kwisatz!.revivalCycle = Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
} else {
const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID;
leader = sharedDuke ? g.dukeVidal!.leader : g.players.flatMap(owner => owner.leaders).find(l => l.id === revival.leader)!;
recordEcazLeaderReturn(g, leader);
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
else delete leader.gholaBy;
leader.dead = false;
delete leader.concealed;
if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!);
}
p.leaderRevived = true;
if (g.revivalRequests) delete g.revivalRequests[p.id];
return leader;
}
function finishRevival(
g: Game,
quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'),
@@ -30022,36 +30051,27 @@ function finishRevival(
{ faction: p.faction, name: 'Revival' });
stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant);
} else {
if ((revival.kind === 'leader' || revival.kind === 'kwisatz') &&
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length)
g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival(
g.leaderRevivalPromises ?? [], p.id, g.turn, revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!));
const leader = commitRevivalLeaderPieces(g, p, revival);
if (revival.kind === 'kwisatz') {
p.kwisatz!.dead = false;
p.kwisatz!.revivalCycle =
Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' });
} else {
const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID;
const leader = sharedDuke
? g.dukeVidal!.leader
: g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!;
recordEcazLeaderReturn(g, leader);
if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
else delete leader.gholaBy;
leader.dead = false;
delete leader.concealed;
if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!);
// Automatic Karama response chains may commit after the action's before/after
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) &&
!leader.capturedBy && p.leaders.some(own => own.id === leader.id)) {
if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader!) &&
!leader!.capturedBy && p.leaders.some(own => own.id === leader!.id)) {
g.leaderSkills = skillRule(() => offerRevivedLeaderSkill(
g.leaderSkills!, p.id, leader.id, crypto.randomUUID()));
g.leaderSkills!, p.id, leader!.id, crypto.randomUUID()));
}
log(
g,
`${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`,
`${p.name} revived ${leader!.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`,
{ faction: p.faction, name: 'Revival' },
);
}
p.leaderRevived = true;
if (g.revivalRequests) delete g.revivalRequests[p.id];
}
g.pendingRevival = null;
if (quote.freeIncome) {
@@ -30112,11 +30132,119 @@ function normalForceRevivalQuote(g: Game, p: Player, action: Action): PendingRev
checks,
};
}
function normalLeaderRevivalQuote(g: Game, p: Player, action: Action): { revival: PendingRevival; cycle: number | null } {
const t = action.type;
requireRule(
g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'),
'Only one leader revival is permitted this phase.',
);
requireRule(
!revivalPrevented(g, p.id),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
const options = leaderRevivalOptions(g, p);
const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID;
if (duke) {
requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.');
const blocked = ecazDukeRevivalBlock(g, p.id);
requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.');
}
const dukeQuote = duke ? quoteEcazDukeRevival(g, p.id, !!revivalDiscount(g, p)) : null;
const l = t === 'reviveLeader'
? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader)
: undefined;
if (t === 'reviveKwisatz')
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
'Kwisatz Haderach is not in the tanks.',
);
else
requireRule(
l?.dead && !l.capturedBy && !l.gholaBy,
'Choose a dead leader.',
);
const option = options.leaders.find((option) => option.id === l?.id);
const normal =
t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early;
requireRule(
normal || p.faction === 'tleilaxu',
'All leaders must die before a new revival cycle can begin.',
);
const checks: PendingRevival['checks'] = [];
if (!normal) {
requireRule(
!g.revivalRules?.earlyBlocked.includes(`${p.id}:${l!.id}`),
'Early revival of that leader was canceled this phase.',
);
checks.push('earlyRevival');
}
requireRule(
t === 'reviveKwisatz' ? !!options.kwisatz : !!option,
'That leader is unavailable for revival this phase.',
);
const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2;
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
requireRule(p.spice >= cost, 'Not enough spice.');
if (cost < normalCost) checks.push('revivalDiscount');
return { cycle: p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle) ? options.cycle : null,
revival: { player: p.id, kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader', leader: l?.id,
normalCost, cost, free: 0, checks } };
}
function personalLeaderRequestQuote(g: Game, p: Player, requested: string | undefined) {
const tleilaxu = byFaction(g, 'tleilaxu');
requireRule(g.phase === 4 && tleilaxu, 'Tleilaxu leader negotiations require the Revival phase.');
requireRule(
p.id !== tleilaxu!.id && !p.leaderRevived,
'Only one negotiated leader revival is permitted per faction per phase.',
);
requireRule(requested, 'Request a leader first.');
requireRule(
!revivalPrevented(g, p.id),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
const buyback = p.leaders.some(
(l) => l.id === requested && l.gholaBy === tleilaxu!.id,
);
requireRule(
buyback || p.leaders.some(nativeAvailable),
'When all native leaders are unavailable, use ordinary revival rules.',
);
if (requested === 'kwisatz')
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
'Kwisatz Haderach is not in the tanks.',
);
else
requireRule(
p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy),
'Choose one of your dead native leaders.',
);
requireRule(
!g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`),
'Early revival of that leader was canceled this phase.',
);
}
function negotiatedLeaderRevivalQuote(g: Game, p: Player): ReturnType<typeof normalLeaderRevivalQuote> {
const request = g.revivalRequests?.[p.id];
requireRule(request, 'Request a leader first.');
personalLeaderRequestQuote(g, p, request?.leader);
requireRule(!request?.declined, 'That request was declined.');
const price = request?.price;
requireRule(typeof price === 'number', 'Wait for Tleilaxu to quote a price.');
requireRule(p.spice >= price, 'Not enough spice for the agreed revival.');
return { cycle: null, revival: { player: p.id, kind: request.leader === 'kwisatz' ? 'kwisatz' : 'leader',
leader: request.leader, normalCost: price, cost: price, free: 0, checks: ['earlyRevival'] } };
}
function revivalPromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof RevivalPromiseError) throw new RuleError(error.message); throw error; }
}
function leaderRevivalPromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof LeaderRevivalPromiseError) throw new RuleError(error.message); throw error; }
}
function revivalPromiseIntegrity(g: Game) {
leaderRevivalPromiseRule(() => validateLeaderRevivalPromises(g.leaderRevivalPromises ?? [], g.players.map(player => player.id)));
revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id)));
}
/** An unused native opportunity this turn, including earlier phases and waiting
@@ -30130,15 +30258,26 @@ function revivalQuestionWindow(g: Game): boolean {
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
!g.pendingKarama && !g.pendingTreacheryDiscard;
}
function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) {
const quote = currentRevivalResumeQuote({ ...g, pendingRevival: { ...revival, checks: [] } }, 'finish');
requireRule(quote.outcome === 'revive', 'This ordinary force return cannot complete.');
g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id,
g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 }));
p.spice -= revival.cost;
commitRevivalForcePieces(g, p, revival);
if (quote.freeIncome) {
g.revivalFreeIncome ??= {};
g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn;
}
if (quote.nextResponse?.kind === 'revivalIncome' && quote.nextResponse.owner === p.id)
p.spice += quote.nextResponse.amount ?? 0;
}
function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean {
if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true;
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
if (revival) {
actor.spice -= revival.cost;
commitRevivalForcePieces(trial, actor, revival);
}
if (revival) stagePromisedForceReturn(trial, actor, revival);
// This is the final aggregate: later witnesses cannot invent another ordinary
// revival that would bypass this question's typed No or paid-return budget.
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
@@ -30155,6 +30294,44 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival)
function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null {
if (g.phase !== 4) return null;
if (!revivalOpportunity(g, p.id)) return null;
const leaders = leaderRevivalRequirements(g.leaderRevivalPromises ?? [], p.id, g.turn);
if (!leaders) return null;
// One completion owner reserves the actual leader price, physical cohort and
// slot before searching force returns against the resulting shared purse.
// Required identities are bounded by native discs; no scored AI shortlist.
if (leaders.required.length) {
for (const leader of leaders.required) {
const actions: Action[] = [leader === 'kwisatz' ? { type: 'reviveKwisatz' } : { type: 'reviveLeader', leader }];
const request = g.revivalRequests?.[p.id];
if (request?.leader === leader && typeof request.price === 'number' && !request.declined)
actions.push({ type: 'acceptLeaderRevival', leaderRevivalPrice: request.price });
for (const action of actions) try {
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(trial, actor)
: normalLeaderRevivalQuote(trial, actor, action);
stagePromisedLeaderReturn(trial, actor, quote);
if (currentRevivalPromiseCompletion(trial, actor, promises)) return action;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
if (p.faction === 'tleilaxu' && g.revivalFreeIncome?.[p.id] !== g.turn && !revivalPrevented(g, p.id)) {
const maximum = forceRevivalRemaining(g, p), tanksElite = p.elites?.tanks ?? 0;
for (let amount = 1; amount <= maximum; amount++)
for (let elite = Math.max(0, amount - (p.tanks - tanksElite)); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) {
const action: Action = { type: 'revive', amount, elite };
try {
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null; trial.revivalPromises = promises;
const quote = normalForceRevivalQuote(trial, actor, action);
if (quote.free <= 0) continue;
stagePromisedForceReturn(trial, actor, quote);
if (currentRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
return null;
}
if (revivalPromisesSatisfied(promises, p.id, g.turn, { amount: 0, elite: 0 }) &&
revivalKeepsLaterPromises(g, p)) return { type: 'ready' };
if (revivalPrevented(g, p.id)) return null;
@@ -30168,7 +30345,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr
const action: Action = { type: 'revive', amount, elite, ...(p.faction === 'ixians' ? { freeElite } : {}) };
try {
const revival = normalForceRevivalQuote(g, p, action);
if (revivalKeepsLaterPromises(g, p, revival)) return action;
if (revivalKeepsLaterPromises({ ...g, revivalPromises: promises }, p, revival)) return action;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
@@ -30193,7 +30370,8 @@ function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromis
return revivalPromisePlan(g, p, promises)?.action ?? null;
}
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) return true;
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) return true;
const trial = { ...g, ...later };
return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id));
}
@@ -30214,31 +30392,93 @@ function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' |
function bindRevivalTruth(g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) {
requireRule(revivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'This revival answer no longer has a legal completion.');
(g.revivalPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 });
const promise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 };
(g.revivalPromises ??= []).push(promise);
(g.revivalPromiseSequence ??= []).push(promise.event);
}
function stagePromisedLeaderReturn(g: Game, p: Player, quote: ReturnType<typeof normalLeaderRevivalQuote>) {
const revival = quote.revival;
const leader = revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!;
if (revival.kind === 'leader' || revival.kind === 'kwisatz')
g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival(
g.leaderRevivalPromises ?? [], p.id, g.turn, leader));
if (quote.cycle !== null) p.revivalCycle = quote.cycle;
p.spice -= revival.cost;
commitRevivalLeaderPieces(g, p, revival);
}
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 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 [];
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.leaderRevivalPromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }];
const trial = { ...g, leaderRevivalPromises: promises };
if (!revivalPromiseCompletion(trial, p) || !bribeCompatibleLaterPromises(trial, p, {})) return false;
const lot = bidOpportunity(trial);
return !lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p);
});
}
function bindLeaderRevivalTruth(g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) {
requireRule(leaderRevivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a named leader-revival answer compatible with native eligibility and earlier answers.');
const promise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer };
(g.leaderRevivalPromises ??= []).push(promise);
(g.revivalPromiseSequence ??= []).push(promise.event);
}
function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
quote?: ReturnType<typeof normalLeaderRevivalQuote>) {
if (!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length &&
!liveBribePromises(g.bribePromises ?? [], 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.');
return;
}
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
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.');
}
/** Reconcile the shared purse in actual answer order across both ledgers.
* Existing records without an order stamp precede newly accepted answers. */
function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) {
if (!g.revivalPromises?.some(promise => !promise.fulfilled && !promise.released)) return;
const pending = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? [])]
.filter(promise => !promise.fulfilled && !promise.released);
if (!pending.length) return;
revivalPromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingRevival ||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const kept: RevivalPromise[] = [];
const no = g.revivalPromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
for (const promise of g.revivalPromises) {
if (promise.fulfilled || promise.released) continue;
const same = g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn;
if (!promise.answer) {
if (!same || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true;
continue;
}
if (!same || !revivalPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) {
const isForce = (promise: RevivalPromise | LeaderRevivalPromise): promise is RevivalPromise => 'counter' in promise;
const same = (promise: RevivalPromise | LeaderRevivalPromise) =>
g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn;
for (const promise of pending.filter(promise => !promise.answer))
if (!same(promise) || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true;
const force: RevivalPromise[] = pending.filter(isForce).filter(promise => !promise.answer && !promise.fulfilled);
const leaders: LeaderRevivalPromise[] = pending.filter(promise => !isForce(promise) && !promise.answer && !promise.fulfilled) as LeaderRevivalPromise[];
const sequence = g.revivalPromiseSequence ?? [];
const positives = pending.filter(promise => promise.answer).sort((a,b) => sequence.indexOf(a.event) - sequence.indexOf(b.event));
for (const promise of positives) {
const trial = { ...g, revivalPromises: isForce(promise) ? [...force, promise] : force,
leaderRevivalPromises: isForce(promise) ? leaders : [...leaders, promise] };
if (!same(promise) || !revivalPromiseCompletion(trial, getPlayer(trial, promise.player))) {
requireRule(voluntary?.actor !== promise.player,
'You cannot voluntarily evade your Truthtrance revival answer.');
'You cannot voluntarily evade your Truthtrance Revival answer.');
promise.released = true;
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance revival answer; that answer is no longer binding.`);
} else kept.push(promise);
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance ${isForce(promise) ? 'force' : 'named leader'}-revival answer; that answer is no longer binding.`);
} else if (isForce(promise)) force.push(promise);
else leaders.push(promise);
}
}
function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number; action: Action } | null {
function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: boolean): { phase: number; action: Action } | null {
const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player;
if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) ||
g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard ||
@@ -30247,8 +30487,10 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number;
// cannot evade it merely because Revival comes later in this turn.
const lot = g.phase === 3 && !ownPayment && !g.richeseAuction && !g.ixAuction ? bidOpportunity(g) : null;
if (lot && bidRequired(g.bidPromises ?? [], player, lot)) return null;
const promise = liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0];
const named = liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player, g.turn);
const promise = namedLeader ? named[0] : !named.length ? liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0] : null;
if (!promise) return null;
const event = namedLeader ? { leaderRevivalPromiseEvent: promise.event } : { revivalPromiseEvent: promise.event };
const plan = revivalPromisePlan(g, getPlayer(g, player));
if (!plan) return null;
if (g.phase === 3 && g.richeseAuction && !ownPayment) {
@@ -30256,7 +30498,7 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number;
const available = !native.outcome && native.eligible.includes(player) &&
(native.method === 'silent' ? !Object.hasOwn(native.sealed, player) : native.active === player);
return available ? { phase: 3, action: { type: 'richeseBid', event: native.event,
amount: native.method === 'silent' ? 0 : null, allyPayment: 0, revivalPromiseEvent: promise.event } } : null;
amount: native.method === 'silent' ? 0 : null, allyPayment: 0, ...event } } : null;
}
if (g.phase === 3 && g.ixAuction) return null;
const current = plan.early.find(step => step.phase === g.phase)?.action;
@@ -30264,14 +30506,21 @@ function projectedRevivalPromiseGuide(g: Game, player: string): { phase: number;
? current ?? { type: 'ready' } : g.phase === 3 ? ownPayment
? current?.type === 'decision' ? current : { type: 'decision', karama: false }
: g.auction?.active === player ? { type: 'passBid' } : null : null;
return action ? { phase: g.phase, action: { ...action, revivalPromiseEvent: promise.event } } : null;
return action ? { phase: g.phase, action: { ...action, ...event } } : null;
}
function projectedRevivalPromiseGuide(g: Game, player: string) {
return projectedTypedRevivalPromiseGuide(g, player, false);
}
function projectedLeaderRevivalPromiseGuide(g: Game, player: string) {
return projectedTypedRevivalPromiseGuide(g, player, true);
}
function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) {
const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn);
if (action.revivalPromiseEvent !== undefined) requireRule(revivalOpportunity(g, p.id) &&
typeof action.revivalPromiseEvent === 'string' && live.some(promise => promise.event === action.revivalPromiseEvent),
'This revival commitment guide is stale.');
if (!live.length) return;
if (!live.length && !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return;
if (!revival) {
requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }),
'Complete the force returns required by your Truthtrance Yes before finishing Revival.');
@@ -30281,14 +30530,10 @@ function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: Pend
// completed real return below records progress; an opposing stop records none.
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
trial.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(trial.revivalPromises ?? [], p.id,
g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 }));
// Legacy concurrent Ready does not forbid a later native request. Its No
// stays binding to phase close; only the detached completion probe reopens.
// Legacy concurrent Ready may reopen; quoted progress remains detached.
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
actor.spice -= revival.cost;
commitRevivalForcePieces(trial, actor, revival);
requireRule(!!revivalPromiseCompletion(trial, actor),
stagePromisedForceReturn(trial, actor, revival);
requireRule(!!revivalPromiseCompletion(trial, actor) && bribeCompatibleLaterPromises(trial, actor, {}),
'This request would prevent you from completing your Truthtrance revival answer.');
}
function beginRevival(g: Game, revival: PendingRevival) {
@@ -35216,6 +35461,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This bribe commitment guide is stale or belongs to another payment.');
}
if (action.leaderRevivalPromiseEvent !== undefined) {
const guide = projectedLeaderRevivalPromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This named leader-revival commitment guide is stale or belongs to another opportunity.');
}
if (action.revivalPromiseEvent !== undefined) {
const guide = projectedRevivalPromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -35908,6 +36159,8 @@ function applyActionInner(
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
bindBribe: bindBribeTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
bindRevival: bindRevivalTruth,
bidAnswers: bidTruthAnswers,
@@ -35921,7 +36174,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -38465,7 +38718,9 @@ function applyActionInner(
'Not enough spice for these extra revivals.',
);
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
// A later responder cannot erase the owner's voluntary gift/revival
// invoice. Check its allowed outcome before opening the response.
const afterPayment = structuredClone(g);
@@ -38473,6 +38728,7 @@ function applyActionInner(
? amount : paidForceRevivalCost(recipient, amount, elite);
reconcileShipmentPromises(afterPayment, { actor: p.id, action });
reconcileGroundMovementPromises(afterPayment, p.id, action, g.phase);
reconcileRevivalPromises(afterPayment, { actor: p.id, action });
}
g.response = {
kind: t,
@@ -38560,62 +38816,14 @@ function applyActionInner(
}
return g;
}
requireRule(
p.id !== tleilaxu.id && !p.leaderRevived,
'Only one negotiated leader revival is permitted per faction per phase.',
);
const requested =
t === 'requestLeaderRevival'
? stringField(action.leader)
: g.revivalRequests[id]?.leader;
requireRule(requested, 'Request a leader first.');
requireRule(
!revivalPrevented(g, p.id),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
const buyback = p.leaders.some(
(l) => l.id === requested && l.gholaBy === tleilaxu.id,
);
requireRule(
buyback || p.leaders.some(nativeAvailable),
'When all native leaders are unavailable, use ordinary revival rules.',
);
if (requested === 'kwisatz')
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
'Kwisatz Haderach is not in the tanks.',
);
else
requireRule(
p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy),
'Choose one of your dead native leaders.',
);
requireRule(
!g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`),
'Early revival of that leader was canceled this phase.',
);
if (t === 'requestLeaderRevival')
if (t === 'requestLeaderRevival') {
const requested = stringField(action.leader);
personalLeaderRequestQuote(g, p, requested);
g.revivalRequests[id] = { leader: requested, price: null };
else {
const price = g.revivalRequests[id]?.price;
requireRule(
!g.revivalRequests[id]?.declined,
'That request was declined.',
);
requireRule(
typeof price === 'number',
'Wait for Tleilaxu to quote a price.',
);
requireRule(p.spice >= price, 'Not enough spice for the agreed revival.');
beginRevival(g, {
player: id,
kind: requested === 'kwisatz' ? 'kwisatz' : 'leader',
leader: requested,
normalCost: price,
cost: price,
free: 0,
checks: ['earlyRevival'],
});
} else {
const quote = negotiatedLeaderRevivalQuote(g, p);
if (execution === 'live') guardPromisedLeaderRevival(g, p, action, quote);
beginRevival(g, quote.revival);
}
return g;
}
@@ -38652,7 +38860,7 @@ function applyActionInner(
const normalCost = leader.strength;
const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost;
requireRule(p.spice >= cost, 'Not enough spice for this ghola.');
beginRevival(g, {
const revival: PendingRevival = {
player: id,
kind: 'foreignGhola',
leader: leader.id,
@@ -38663,7 +38871,9 @@ function applyActionInner(
cost < normalCost
? ['foreignGhola', 'revivalDiscount']
: ['foreignGhola'],
});
};
if (execution === 'live') guardPromisedLeaderRevival(g, p, action, { revival, cycle: null });
beginRevival(g, revival);
return g;
}
if (t === 'revive') {
@@ -38675,71 +38885,14 @@ function applyActionInner(
return g;
}
if (t === 'reviveKwisatz' || t === 'reviveLeader') {
requireRule(
g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'),
'Only one leader revival is permitted this phase.',
);
if (execution === 'live') startNativeRevivalOrder(g,id);
requireRule(
!revivalPrevented(g, p.id),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
const options = leaderRevivalOptions(g, p);
const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID;
if (duke) {
requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.');
const blocked = ecazDukeRevivalBlock(g, p.id);
requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.');
const quote = normalLeaderRevivalQuote(g, p, action);
if (execution === 'live') {
startNativeRevivalOrder(g, id);
guardPromisedLeaderRevival(g, p, action, quote);
}
const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null;
const l = t === 'reviveLeader'
? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader)
: undefined;
if (t === 'reviveKwisatz')
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
'Kwisatz Haderach is not in the tanks.',
);
else
requireRule(
l?.dead && !l.capturedBy && !l.gholaBy,
'Choose a dead leader.',
);
const option = options.leaders.find((option) => option.id === l?.id);
const normal =
t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early;
requireRule(
normal || p.faction === 'tleilaxu',
'All leaders must die before a new revival cycle can begin.',
);
const checks: PendingRevival['checks'] = [];
if (!normal) {
requireRule(
!g.revivalRules?.earlyBlocked.includes(`${id}:${l!.id}`),
'Early revival of that leader was canceled this phase.',
);
checks.push('earlyRevival');
}
requireRule(
t === 'reviveKwisatz' ? !!options.kwisatz : !!option,
'That leader is unavailable for revival this phase.',
);
const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2;
const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost);
requireRule(p.spice >= cost, 'Not enough spice.');
if (cost < normalCost) checks.push('revivalDiscount');
// Ecaz records its physical cohort only after the return actually commits.
if (p.faction !== 'ecaz' && (!duke || options.cycle > p.revivalCycle))
p.revivalCycle = options.cycle;
beginRevival(g, {
player: id,
kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader',
leader: l?.id,
normalCost,
cost,
free: 0,
checks,
});
if (quote.cycle !== null) p.revivalCycle = quote.cycle;
beginRevival(g, quote.revival);
return g;
}
if (t === 'revealNoField') {
@@ -39597,6 +39750,14 @@ function applyActionInner(
if (cunning) { cunning.hajrPlays = p.hajrPlays; saveGuildCunning(g,cunning); }
} else if (c.effect === 'ghola') {
applyGholaEffect(g, p, action, c.id);
if (execution === 'live' && (liveRevivalPromises(g.revivalPromises ?? [], id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], id, g.turn).some(promise => promise.answer))) {
// The voluntary physical return must not evade an ordinary-return Yes
// by opening a later income response owned by a different actor.
const trial = structuredClone(g);
trial.truthtrance = null; trial.response = null;
reconcileRevivalPromises(trial, { actor: id, action });
}
} else if (c.effect === 'harvester') {
const blow = g.spiceWindow!;
if (blow.sector !== g.storm) {
@@ -39637,7 +39798,7 @@ function applyActionInner(
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer),
'Complete your Truthtrance bribe payments before finishing Collection.');
if (g.phase === 4) guardPromisedRevival(g, p, action);
if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); }
requireRule(
[1, 2, 4, 7, 8].includes(g.phase) ||
(g.phase === 0 && g.stormPending !== null),
@@ -39850,6 +40011,11 @@ export function viewGame(state: Game, id: string) {
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),
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,
leaderRevivalPromises: (g.leaderRevivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
leaderRevivalPromiseGuide: projectedLeaderRevivalPromiseGuide(g, id),
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,