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

+5
View File
@@ -1309,6 +1309,7 @@ function policyActions(g: GameView): Action[] {
}
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
@@ -1317,6 +1318,10 @@ function policyActions(g: GameView): Action[] {
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
if (w.question.kind === 'leaderRevival') {
const answer = g.truthLeaderRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'alliance') {
const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+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,
+90
View File
@@ -0,0 +1,90 @@
export { revivalPromiseModeSupported as leaderRevivalPromiseModeSupported } from './revival-promises';
export type LeaderRevivalClaim = { turn: number; leader: string };
export type LeaderRevivalPromise = LeaderRevivalClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export class LeaderRevivalPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'LeaderRevivalPromiseError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
export function parseLeaderRevivalClaim(value: unknown): LeaderRevivalClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new LeaderRevivalPromiseError('Choose a current-turn named leader-revival question.');
const input = value as Record<string, unknown>;
if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.leader))
throw new LeaderRevivalPromiseError('Choose the current turn and exact leader identity.');
return { turn: input.turn, leader: input.leader };
}
export function leaderRevivalClaimText(claim: LeaderRevivalClaim,
leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string {
return `revive ${leaderName(claim.leader)} through ordinary leader revival from now until your Revival opportunity ends this turn`;
}
export function liveLeaderRevivalPromises(promises: readonly LeaderRevivalPromise[], player: string,
turn: number): LeaderRevivalPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
/** Distinct required identities are not a grant of additional native slots.
* The engine's joint Revival quote owns allowance, custody, price and order. */
export function leaderRevivalRequirements(promises: readonly LeaderRevivalPromise[], player: string,
turn: number): { required: string[]; forbidden: string[] } | null {
const required = new Set<string>(), forbidden = new Set<string>();
for (const promise of liveLeaderRevivalPromises(promises, player, turn))
(promise.answer ? required : forbidden).add(promise.leader);
if ([...required].some(leader => forbidden.has(leader))) return null;
return { required: [...required], forbidden: [...forbidden] };
}
/** Proposed final future ordinary returns, not currently living leaders or
* historical deaths. No remains binding across other nonmatching returns. */
export function leaderRevivalPromisesSatisfied(promises: readonly LeaderRevivalPromise[], player: string,
turn: number, returned: readonly string[]): boolean {
return liveLeaderRevivalPromises(promises, player, turn)
.every(promise => returned.includes(promise.leader) === promise.answer);
}
/** Provisional named future-return composition. The actual ordinary-return
* caller owns source semantics, including the native KH token. Declaration,
* prevention, special/card returns and income do not count as this event. */
export function recordPromisedLeaderRevival(promises: readonly LeaderRevivalPromise[], player: string,
turn: number, leader: string): LeaderRevivalPromise[] {
if (!identifier(leader)) throw new LeaderRevivalPromiseError('Retain the exact returned leader identity.');
const matching = liveLeaderRevivalPromises(promises, player, turn).filter(promise => promise.leader === leader);
if (matching.some(promise => !promise.answer))
throw new LeaderRevivalPromiseError('This ordinary leader revival conflicts with your Truthtrance No answer.');
return promises.map(promise => matching.includes(promise) ? { ...promise, fulfilled: true } : promise);
}
/** Identity eligibility and historical physical membership remain native
* rules. This validates the ledger's public event and actual participants. */
export function validateLeaderRevivalPromises(promises: readonly LeaderRevivalPromise[], players: readonly string[]): void {
if (!Array.isArray(promises)) throw new LeaderRevivalPromiseError('The named leader-revival commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
parseLeaderRevivalClaim(promise);
if (!identifier(promise.event) || events.has(promise.event) ||
!identifier(promise.player) || !identifier(promise.asker) ||
!players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker ||
typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
promise.fulfilled && promise.released)
throw new LeaderRevivalPromiseError('The named leader-revival commitment lost its event or participants.');
events.add(promise.event);
}
}
+28 -1
View File
@@ -3258,6 +3258,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
],
},
{
id: 'truthtrance-leader-revival',
title: 'Truthtrance: named leader revival this turn',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind an actual future personal native return of a named leader or Kwisatz, retaining native cost, custody, cohort and allowance.',
steps: [
'Choose Bind a named leader revival this turn, the respondent and a public printed leader identity. Registered native Basic/Advanced tables support quiet phases0–4 with a remaining Revival opportunity. Advanced Atreides may name Kwisatz Haderach; Ecaz may name the shared Duke where present. Optional compositions remain unfinished.',
'Only a future actual personal native return after this answer counts. Direct ordinary requests and actually accepted personal early negotiations share real return accounting. A request, quote, prior return, Ghola card, special return or foreign ghola does not fulfill Yes. No forbids the named native return without banning other legal actions.',
'The original dead/captured/foreign custody, cohort, cost, native leader allowance and ordered ownership remain. Several same-identity Yes answers may share one actual return; different names need actual native slots. Force and leader commitments share one purse and completion plan.',
'The owned guide may claim available Charity, preserve or pay a known auction liability, make a required force return, return a leader/Kwisatz or finish at the real native opportunity. Tleilaxu may prepare through its actual one-time self free-revival income. An already-priced, not-declined personal negotiation can be accepted; a future foreign agreement or price is never invented.',
'Yes prevents voluntary spending, invoices or Finish that erase its last completion. A completed opposing effect can release impossible answers in shared force/leader acceptance order. The actual allowed return fulfills Yes; responses and payment/income keep their original timing. Basic No lasts to phase close; stamped Advanced No ends with the native opportunity.',
'Your own persistent panel shows pending, fulfilled and released answers. Exact event/phase guides and all four minimal policies use the same legal native actions. Strategy and difficulty calibration remain deferred.',
'This named future-return source, interval, owned witness and release policy are provisional online compositions of general current-turn Truthtrance authority. Dedicated original cohort, Auditor, Duke and Kwisatz commitment journeys, wider preparation/compositions, browser and complete-mode acceptance remain due. No start gate opens.',
],
related: ['card-truthtrance', 'truthtrance-revival', 'advanced-revival-order', 'truthtrance-bribe', 'implementation-checklist', phaseRuleId(4)],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Named native future returns, joint force/leader purse and slots, real completion, source exclusions, owned preparation and accepted-order release retain original rules.', evidence: ['game/leader-revival-promises.ts', 'game/revival.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/leader-revival-promises.test.ts', 'tests/truth-leader-revival-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Public-name fields/preview, target-only answers and persistent exact event/phase native guides.', evidence: ['components/leader-revival-claim-fields.tsx', 'components/leader-revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-leader-revival-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing minimal profiles answer and use the same native guide. Full negotiation strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-leader-revival-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General authority, native source/interval, owned preparation and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original native Tleilaxu battle/death/return source, pure and controlled UI evidence; broader normal-cohort/KH/Duke, browser and deployed acceptance remain due.', evidence: ['tests/fixture-truth-leader-revival.ts', 'tests/truth-leader-revival-runtime.test.ts', 'tests/truth-leader-revival-controls.test.tsx'] },
],
},
{
id: 'truthtrance-revival',
title: 'Truthtrance: ordinary force-revival promises',
@@ -3457,7 +3482,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named native leader-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3471,6 +3496,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.',
'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
@@ -3490,6 +3516,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-movement',
'truthtrance-bid',
'truthtrance-revival',
'truthtrance-leader-revival',
'truthtrance-bribe',
'truthtrance-alliance',
'richese-no-field',
+23
View File
@@ -1,3 +1,4 @@
import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises';
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
@@ -63,6 +64,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
| ({ kind: 'alliance'; target: string } & AllianceClaim)
| ({ kind: 'bribe'; target: string } & BribeClaim)
| ({ kind: 'revival'; target: string } & RevivalClaim)
@@ -218,6 +220,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'leaderRevival') {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn',
'Supply the current turn, exact named leader and respondent.');
const claim = parseLeaderRevivalClaim(v);
check(leaderRevivalPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama &&
!g.pendingTreacheryDiscard && claim.turn === g.turn,
'Choose a supported current-turn ordinary leader-revival question in a quiet window.');
return { kind: 'leaderRevival', target: v.target, ...claim };
}
if (v.kind === 'alliance') {
check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn',
'Supply the current Nexus turn, named alliance partner and respondent.');
@@ -488,6 +500,7 @@ export function truthQuestionText(
playerName: (id: string) => string = id => id,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`;
if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`;
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
@@ -541,6 +554,8 @@ export function resolveTruthAction(
bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void;
bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[];
bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void;
leaderRevivalAnswers?: (g: Game, p: Player, claim: LeaderRevivalClaim) => TruthAnswer[];
bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void;
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
@@ -663,6 +678,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
if (w.question.kind === 'leaderRevival')
check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a named leader question with a compatible native completion.');
if (w.question.kind === 'alliance')
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a current Nexus question with a compatible answer.');
@@ -725,6 +743,11 @@ export function resolveTruthAction(
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'leaderRevival') {
check(effects.leaderRevivalAnswers && effects.bindLeaderRevival && effects.leaderRevivalAnswers(g, p, q).includes(a.answer),
'Choose a named leader-revival answer compatible with native eligibility and earlier promises.');
if (a.answer !== 'unknown') effects.bindLeaderRevival(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'alliance') {
check(effects.allianceAnswers && effects.bindAlliance && effects.allianceAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with actual alliance consent and earlier commitments.');