Support grouped Truthtrance revival commitments

This commit is contained in:
Quintine committed 2026-10-26 15:16:32 +11:00
1 parent a76effb932
commit f5ae61f382
20 files changed
+1730 -30

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.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
@@ -1318,6 +1319,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 === 'revivalExpression') {
const answer = g.truthRevivalExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'leaderRevival') {
const answer = g.truthLeaderRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+135 -26
View File
@@ -1,3 +1,6 @@
import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalExpressionBranches,
evaluateRevivalExpression, recordRevivalExpressionReturn, validateRevivalExpressionPromises,
type RevivalExpression, type RevivalExpressionClaim, type RevivalExpressionPromise, type RevivalExpressionReturn } from './revival-expression-promises';
import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements,
leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises,
type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises';
@@ -2147,6 +2150,7 @@ export type Game = {
bidPromises?: BidPromise[];
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
revivalExpressionPromises?: RevivalExpressionPromise[];
/** Shared accepted order for typed force and named leader answers. */
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
@@ -15106,7 +15110,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' | 'leaderRevivalPromises' | 'battle'>): boolean {
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean {
if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true;
return !!bribePromiseCompletion({ ...g, ...later }, p);
}
@@ -15177,7 +15181,8 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
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) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer);
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], 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.
@@ -15411,6 +15416,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) {
const trial = structuredClone(g);
trial.truthtrance = null;
@@ -30034,6 +30040,7 @@ function finishRevival(
const group = { amount: n, elite, free: revival.free };
if (source === 'normal' && liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length)
g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, g.turn, { amount: n, elite }));
if (source === 'normal') recordRevivalExpressionEvent(g, p.id, { kind: 'forces', amount: n, elite });
commitRevivalForcePieces(g, p, revival);
finishNexusEmperorRevival(g, revival, 'returned');
if (quote.techIncome) {
@@ -30055,6 +30062,8 @@ function finishRevival(
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length)
g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival(
g.leaderRevivalPromises ?? [], p.id, g.turn, revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!));
if (revival.kind === 'leader' || revival.kind === 'kwisatz')
recordRevivalExpressionEvent(g, p.id, { kind: 'leader', leader: revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader! });
const leader = commitRevivalLeaderPieces(g, p, revival);
if (revival.kind === 'kwisatz') {
log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' });
@@ -30243,7 +30252,17 @@ function leaderRevivalPromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof LeaderRevivalPromiseError) throw new RuleError(error.message); throw error; }
}
function revivalExpressionRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof RevivalExpressionPromiseError) throw new RuleError(error.message); throw error; }
}
function recordRevivalExpressionEvent(g: Game, player: string, event: RevivalExpressionReturn) {
if (liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn).length)
g.revivalExpressionPromises = revivalExpressionRule(() => recordRevivalExpressionReturn(
g.revivalExpressionPromises ?? [], player, g.turn, event));
}
function revivalPromiseIntegrity(g: Game) {
revivalExpressionRule(() => validateRevivalExpressionPromises(g.revivalExpressionPromises ?? [], g.players.map(player => player.id)));
leaderRevivalPromiseRule(() => validateLeaderRevivalPromises(g.leaderRevivalPromises ?? [], g.players.map(player => player.id)));
revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id)));
}
@@ -30263,6 +30282,7 @@ function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) {
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 }));
recordRevivalExpressionEvent(g, p.id, { kind: 'forces', amount: revival.amount!, elite: revival.elite ?? 0 });
p.spice -= revival.cost;
commitRevivalForcePieces(g, p, revival);
if (quote.freeIncome) {
@@ -30291,7 +30311,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival)
/** A final aggregate can be requested together, including Ixian free-Cyborg
* selection. The native quote owns prices, typed Tanks, free quota and limits;
* no future gift, card draw or foreign decision is invented. */
function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null {
function currentAtomicRevivalPromiseCompletion(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);
@@ -30311,7 +30331,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr
const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(trial, actor)
: normalLeaderRevivalQuote(trial, actor, action);
stagePromisedLeaderReturn(trial, actor, quote);
if (currentRevivalPromiseCompletion(trial, actor, promises)) return action;
if (currentAtomicRevivalPromiseCompletion(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)) {
@@ -30325,7 +30345,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr
const quote = normalForceRevivalQuote(trial, actor, action);
if (quote.free <= 0) continue;
stagePromisedForceReturn(trial, actor, quote);
if (currentRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action;
if (currentAtomicRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
@@ -30351,6 +30371,34 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr
}
return null;
}
/** Lazily try all whole-expression completions against the same native
* quotes and existing atomic promises. Rebuild on every real action so an
* earlier guide never pins an OR route or one reason for an AND No. */
function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null {
const expressions = liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn);
if (!expressions.length) return currentAtomicRevivalPromiseCompletion(g, p, promises);
const search = (index: number, forces: RevivalPromise[], leaders: LeaderRevivalPromise[]): Action | null => {
if (index === expressions.length) {
const trial = { ...g, revivalPromises: forces, leaderRevivalPromises: leaders };
return currentAtomicRevivalPromiseCompletion(trial, getPlayer(trial, p.id), forces);
}
const expression = expressions[index];
for (const branch of revivalExpressionBranches(expression)) {
const nextForces = [...forces], nextLeaders = [...leaders];
branch.forEach((leaf, ordinal) => {
const common = { turn: g.turn, event: `${expression.event}:branch:${ordinal}`, player: p.id,
asker: expression.asker, answer: leaf.answer };
if (leaf.kind === 'forces') nextForces.push({ ...common, counter: leaf.counter,
minimum: leaf.minimum, progress: leaf.progress });
else nextLeaders.push({ ...common, leader: leaf.leader });
});
const action = search(index + 1, nextForces, nextLeaders);
if (action) return action;
}
return null;
};
return search(0, promises, g.leaderRevivalPromises ?? []);
}
type RevivalPromisePlan = { action: Action; early: { phase: number; action: Action }[] };
function revivalPromisePlan(g: Game, p: Player, promises = g.revivalPromises ?? []): RevivalPromisePlan | null {
if (!revivalOpportunity(g, p.id)) return null;
@@ -30371,7 +30419,8 @@ function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromis
}
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) return true;
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
!liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length) return true;
const trial = { ...g, ...later };
return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id));
}
@@ -30402,6 +30451,8 @@ function stagePromisedLeaderReturn(g: Game, p: Player, quote: ReturnType<typeof
if (revival.kind === 'leader' || revival.kind === 'kwisatz')
g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival(
g.leaderRevivalPromises ?? [], p.id, g.turn, leader));
if (revival.kind === 'leader' || revival.kind === 'kwisatz')
recordRevivalExpressionEvent(g, p.id, { kind: 'leader', leader });
if (quote.cycle !== null) p.revivalCycle = quote.cycle;
p.spice -= revival.cost;
commitRevivalLeaderPieces(g, p, revival);
@@ -30429,9 +30480,40 @@ function bindLeaderRevivalTruth(g: Game, p: Player, claim: LeaderRevivalClaim, a
(g.leaderRevivalPromises ??= []).push(promise);
(g.revivalPromiseSequence ??= []).push(promise.event);
}
function revivalExpressionIdentity(g: Game, p: Player, claim: RevivalExpression): boolean {
return claim.kind === 'and' || claim.kind === 'or' ? claim.terms.every(term => revivalExpressionIdentity(g, p, term)) :
claim.kind === 'forces' || claim.kind === 'leader' && leaderRevivalIdentity(g, p, claim.leader);
}
function revivalExpressionTruthAnswers(g: Game, p: Player, claim: RevivalExpressionClaim): ('yes' | 'no' | 'unknown')[] {
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn ||
!revivalExpressionIdentity(g, p, claim.claim)) return [];
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.revivalExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes',
progress: { total: 0, normal: 0, elite: 0, leaders: [] } }];
const trial = { ...g, revivalExpressionPromises: 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 bindRevivalExpressionTruth(g: Game, p: Player, claim: RevivalExpressionClaim, answer: boolean, asker: string) {
requireRule(revivalExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole revival answer compatible with native returns and earlier commitments.');
const promise: RevivalExpressionPromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer,
progress: { total: 0, normal: 0, elite: 0, leaders: [] } };
(g.revivalExpressionPromises ??= []).push(promise);
(g.revivalPromiseSequence ??= []).push(promise.event);
}
function guardRevivalExpressionReady(g: Game, p: Player) {
requireRule(liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn)
.every(promise => evaluateRevivalExpression(promise.claim, promise.progress) === promise.answer),
'Honor your whole Truthtrance revival answer before finishing Revival.');
}
function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
quote?: ReturnType<typeof normalLeaderRevivalQuote>) {
if (!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
if (!liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length &&
!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 &&
@@ -30451,34 +30533,39 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
/** 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 }) {
const pending = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? [])]
type Promise = RevivalPromise | LeaderRevivalPromise | RevivalExpressionPromise;
const pending: Promise[] = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? [])]
.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 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;
const isForce = (promise: Promise): promise is RevivalPromise => 'counter' in promise;
const isExpression = (promise: Promise): promise is RevivalExpressionPromise => 'claim' in promise;
const isLeader = (promise: Promise): promise is LeaderRevivalPromise => !isForce(promise) && !isExpression(promise);
const same = (promise: Promise) => 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 force = pending.filter(isForce).filter(promise => !promise.answer && !promise.fulfilled);
const leaders = pending.filter(isLeader).filter(promise => !promise.answer && !promise.fulfilled);
const expressions = pending.filter(isExpression).filter(promise => !promise.answer && !promise.fulfilled);
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] };
leaderRevivalPromises: isLeader(promise) ? [...leaders, promise] : leaders,
revivalExpressionPromises: isExpression(promise) ? [...expressions, promise] : expressions };
if (!same(promise) || !revivalPromiseCompletion(trial, getPlayer(trial, promise.player))) {
requireRule(voluntary?.actor !== promise.player,
'You cannot voluntarily evade your Truthtrance Revival answer.');
promise.released = true;
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.`);
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance ${isForce(promise) ? 'force' : isExpression(promise) ? 'grouped' : 'named leader'}-revival answer; that answer is no longer binding.`);
} else if (isForce(promise)) force.push(promise);
else leaders.push(promise);
else if (isLeader(promise)) leaders.push(promise);
else expressions.push(promise);
}
}
function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: boolean): { phase: number; action: Action } | null {
function projectedTypedRevivalPromiseGuide(g: Game, player: string, kind: 'forces' | 'leader' | 'expression'): { 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 ||
@@ -30487,10 +30574,13 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader:
// 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 grouped = liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn);
const named = liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player, g.turn);
const promise = namedLeader ? named[0] : !named.length ? liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0] : null;
const promise = kind === 'expression' ? grouped[0] : grouped.length ? null : kind === 'leader' ? 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 event = kind === 'expression' ? { revivalExpressionPromiseEvent: promise.event }
: kind === 'leader' ? { leaderRevivalPromiseEvent: promise.event } : { revivalPromiseEvent: promise.event };
const plan = revivalPromisePlan(g, getPlayer(g, player));
if (!plan) return null;
if (g.phase === 3 && g.richeseAuction && !ownPayment) {
@@ -30509,17 +30599,21 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader:
return action ? { phase: g.phase, action: { ...action, ...event } } : null;
}
function projectedRevivalPromiseGuide(g: Game, player: string) {
return projectedTypedRevivalPromiseGuide(g, player, false);
return projectedTypedRevivalPromiseGuide(g, player, 'forces');
}
function projectedLeaderRevivalPromiseGuide(g: Game, player: string) {
return projectedTypedRevivalPromiseGuide(g, player, true);
return projectedTypedRevivalPromiseGuide(g, player, 'leader');
}
function projectedRevivalExpressionPromiseGuide(g: Game, player: string) {
return projectedTypedRevivalPromiseGuide(g, player, 'expression');
}
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 && !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
if (!live.length && !liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).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 }),
@@ -35461,6 +35555,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.revivalExpressionPromiseEvent !== undefined) {
const guide = projectedRevivalExpressionPromiseGuide(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 grouped revival guide is stale or belongs to another opportunity.');
}
if (action.leaderRevivalPromiseEvent !== undefined) {
const guide = projectedLeaderRevivalPromiseGuide(state, id);
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
@@ -36159,6 +36259,8 @@ function applyActionInner(
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
bindBribe: bindBribeTruth,
revivalExpressionAnswers: revivalExpressionTruthAnswers,
bindRevivalExpression: bindRevivalExpressionTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
@@ -36174,7 +36276,7 @@ function applyActionInner(
)
return g;
} catch (error) {
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)
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || 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;
}
@@ -38720,7 +38822,8 @@ function applyActionInner(
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
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)) {
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], 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);
@@ -39751,7 +39854,8 @@ function applyActionInner(
} 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))) {
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], 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);
@@ -39798,7 +39902,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); guardPromisedLeaderRevival(g, p, action); }
if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); }
requireRule(
[1, 2, 4, 7, 8].includes(g.phase) ||
(g.phase === 0 && g.stormPending !== null),
@@ -40016,6 +40120,11 @@ export function viewGame(state: Game, id: string) {
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),
truthRevivalExpressionTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthRevivalExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'revivalExpression' ? revivalExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
revivalExpressionPromises: (g.revivalExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id),
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,
+27 -1
View File
@@ -3258,6 +3258,30 @@ 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-revival-expression',
title: 'Truthtrance: grouped force and leader revivals',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask a whole AND/OR question across future ordinary force minima and named personal native leader returns, retaining every legal alternative.',
steps: [
'Choose Bind combined force and leader revivals. Build AND/OR groups with ordinary total/normal/elite force minima or a named personal native leader/Kwisatz return. Registered bare native Basic/Advanced tables support quiet phases0–4 with a remaining ordinary opportunity. The input supports sixteen leaves and four grouped levels; these are software bounds, not printed question limits.',
'Each force leaf counts cumulative actual ordinary physical returns after this answer; each elite counts once. Each leader leaf means a future actual personal native return of that printed identity. Previous returns, requests, unaccepted quotes, Ghola/cards, special returns and foreign gholas do not count. Original prices, custody, cohort, slots and order remain.',
'Yes requires the complete positive expression to become true. AND needs every child; OR at least one. No keeps the whole expression false: No to force1 AND leader may permit either first return but forbids completing both; No to force1 OR leader forbids either qualifying return.',
'Your answer stores the full expression and actual progress, never a fixed branch. A guide suggests one legal action; another legal OR route or AND-No reason remains available. Every action rebuilds compatible alternatives against earlier scalar and grouped answers, sharing native funding and allowance.',
'The persistent panel displays the full expression, typed forces and named future returns. Exact current phase/event guides prepare, return or finish through native controls. Yes settles only on actual whole-expression progress; No lasts to Basic phase close or the stamped Advanced opportunity close. All four minimal profiles use the same legal path.',
'The owned current-turn interval, source and accepted-order release policy remain provisional compositions of general Truthtrance authority. Other future families, cross-opportunity expressions, special/card revival, optional modules and complete-mode/browser/deployed acceptance remain due. No start gate opens.',
],
related: ['card-truthtrance', 'truthtrance-revival', 'truthtrance-leader-revival', 'advanced-revival-order', 'implementation-checklist', phaseRuleId(4)],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Strict full-tree parser, cumulative physical/native progress and lazy dynamic alternatives reuse the actual native planner and shared constraints without storing a branch.', evidence: ['game/revival-expression-promises.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/revival-expression-promises.test.ts', 'tests/truth-revival-expression-runtime.test.ts', 'tests/truth-revival-expression-funding.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full controlled AND/OR editor, public preview, target-only answers and persistent whole-expression progress/exact guide.', evidence: ['components/revival-expression-fields.tsx', 'components/revival-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-expression-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four existing minimal policies answer the whole claim and use the native guide. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-expression-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'AND/OR authority, provisional future interval/source, dynamic alternatives and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Pure whole-tree logic, controlled editor/guide and inherited original H/A/T battle/death histories; broader source families, browser/deployed and final assurance remain due.', evidence: ['tests/fixture-truth-revival-expression.ts', 'tests/truth-revival-expression-runtime.test.ts', 'tests/truth-revival-expression-controls.test.tsx', 'tests/truth-revival-expression-funding.test.ts'] },
],
},
{
id: 'truthtrance-leader-revival',
title: 'Truthtrance: named leader revival this turn',
@@ -3482,7 +3506,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, 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.',
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named/grouped native 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.',
@@ -3496,6 +3520,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 grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.',
'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.',
@@ -3517,6 +3542,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-bid',
'truthtrance-revival',
'truthtrance-leader-revival',
'truthtrance-revival-expression',
'truthtrance-bribe',
'truthtrance-alliance',
'richese-no-field',
+227
View File
@@ -0,0 +1,227 @@
export { revivalPromiseModeSupported as revivalExpressionPromiseModeSupported } from './revival-promises';
export type RevivalExpressionLeaf =
| { kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number }
| { kind: 'leader'; leader: string };
export type RevivalExpression = RevivalExpressionLeaf |
{ kind: 'and' | 'or'; terms: RevivalExpression[] };
export type RevivalExpressionClaim = { turn: number; claim: RevivalExpression };
export type RevivalExpressionProgress = {
total: number; normal: number; elite: number; leaders: string[];
};
export type RevivalExpressionPromise = RevivalExpressionClaim & {
event: string; player: string; asker: string; answer: boolean;
progress: RevivalExpressionProgress;
fulfilled?: boolean; released?: boolean;
};
export type RevivalExpressionReturn =
| { kind: 'forces'; amount: number; elite: number }
| { kind: 'leader'; leader: string };
export type RevivalExpressionBranchLeaf =
| { kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean }
| { kind: 'leader'; leader: string; answer: boolean };
export const REVIVAL_EXPRESSION_MAX_DEPTH = 4;
export const REVIVAL_EXPRESSION_MAX_LEAVES = 16;
export class RevivalExpressionPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'RevivalExpressionPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new RevivalExpressionPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
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';
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20;
/** Software input limits shared with shipment, not a printed question limit.
* Parse every subtree before any evaluation can short-circuit its answer. */
export function parseRevivalExpression(value: unknown): RevivalExpression {
let leaves = 0;
const parse = (input: unknown, depth: number): RevivalExpression => {
check(object(input) && depth <= REVIVAL_EXPRESSION_MAX_DEPTH,
'Use a revival expression within four nested groups.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= REVIVAL_EXPRESSION_MAX_LEAVES,
'Join two or more ordinary revival statements with AND or OR.');
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
}
check(++leaves <= REVIVAL_EXPRESSION_MAX_LEAVES,
'Use at most sixteen ordinary revival statements.');
if (input.kind === 'forces') {
check(keys(input, 'counter,kind,minimum') &&
(input.counter === 'total' || input.counter === 'normal' || input.counter === 'elite') &&
count(input.minimum) && input.minimum >= 1,
'Choose total, normal or elite physical forces and a minimum from 1 to 20.');
return { kind: 'forces', counter: input.counter, minimum: input.minimum };
}
check(input.kind === 'leader' && keys(input, 'kind,leader') && identifier(input.leader),
'Choose an exact ordinary leader identity.');
return { kind: 'leader', leader: input.leader };
};
return parse(value, 0);
}
export function parseRevivalExpressionClaim(value: unknown): RevivalExpressionClaim {
check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' &&
Number.isSafeInteger(value.turn) && value.turn >= 1,
'Choose the current turn and an ordinary revival expression.');
return { turn: value.turn, claim: parseRevivalExpression(value.claim) };
}
export function revivalExpressionText(expression: RevivalExpression,
leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string {
const text = (node: RevivalExpression): string => {
if ('terms' in node)
return `(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
if (node.kind === 'leader') return `revive ${leaderName(node.leader)} through ordinary leader revival`;
const counters = node.counter === 'total' ? 'physical forces' : `${node.counter} forces`;
return `revive at least ${node.minimum} ${counters} through ordinary force revival`;
};
return text(parseRevivalExpression(expression));
}
function validateProgress(value: unknown): asserts value is RevivalExpressionProgress {
check(object(value) && keys(value, 'elite,leaders,normal,total') && count(value.total) &&
count(value.normal) && count(value.elite) && value.total === value.normal + value.elite &&
Array.isArray(value.leaders) && value.leaders.every(identifier) &&
new Set(value.leaders).size === value.leaders.length,
'Retain cumulative typed physical returns and unique returned leader identities.');
}
function evaluate(expression: RevivalExpression, progress: RevivalExpressionProgress): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
return expression.kind === 'leader' ? progress.leaders.includes(expression.leader) :
progress[expression.counter] >= expression.minimum;
}
export function evaluateRevivalExpression(expression: RevivalExpression, progress: RevivalExpressionProgress): boolean {
const parsed = parseRevivalExpression(expression);
validateProgress(progress);
return evaluate(parsed, progress);
}
export function liveRevivalExpressionPromises(promises: readonly RevivalExpressionPromise[], player: string,
turn: number): RevivalExpressionPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function validateReturn(event: RevivalExpressionReturn): void {
check(object(event) && (event.kind === 'forces' && keys(event, 'amount,elite,kind') && count(event.amount) &&
count(event.elite) && event.elite <= event.amount ||
event.kind === 'leader' && keys(event, 'kind,leader') && identifier(event.leader)),
'Retain the actual typed ordinary return event.');
}
/** Only completed ordinary native returns call this function. Each answer has
* its own future interval; past returns and special/card returns are not added. */
export function recordRevivalExpressionReturn(promises: readonly RevivalExpressionPromise[], player: string,
turn: number, event: RevivalExpressionReturn): RevivalExpressionPromise[] {
validateReturn(event);
const updates = new Map<RevivalExpressionPromise, RevivalExpressionPromise>();
for (const promise of liveRevivalExpressionPromises(promises, player, turn)) {
const claim = parseRevivalExpression(promise.claim);
validateProgress(promise.progress);
check(typeof promise.answer === 'boolean', 'Retain a definite revival expression answer.');
const old = promise.progress;
if (event.kind === 'forces' && event.amount === 0 || event.kind === 'leader' && old.leaders.includes(event.leader))
continue;
const progress: RevivalExpressionProgress = event.kind === 'forces' ? {
total: old.total + event.amount, normal: old.normal + event.amount - event.elite,
elite: old.elite + event.elite, leaders: [...old.leaders],
} : { ...old, leaders: [...old.leaders, event.leader] };
validateProgress(progress);
const satisfied = evaluate(claim, progress);
check(promise.answer || !satisfied,
'This ordinary return conflicts with your whole Truthtrance revival No answer.');
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Keep the strongest bound for each counter/polarity and reject contradictory
* same-counter or named-disc requirements before expanding the next subtree. */
function mergeBranches(left: RevivalExpressionBranchLeaf[], right: RevivalExpressionBranchLeaf[]):
RevivalExpressionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
if (leaf.kind === 'leader') {
const prior = merged.find(other => other.kind === 'leader' && other.leader === leaf.leader);
if (prior && prior.answer !== leaf.answer) return null;
if (!prior) merged.push({ ...leaf });
continue;
}
const same = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && other.answer === leaf.answer);
if (same?.kind === 'forces') same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const positive = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && other.answer);
const negative = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && !other.answer);
if (positive?.kind === 'forces' && negative?.kind === 'forces' && positive.minimum >= negative.minimum) return null;
}
return merged;
}
/** Lazy alternatives retain original minima plus this answer's counter
* progress. Whole No uses De Morgan, never a No imposed on every AND child.
* Native allowance, prices and cross-counter feasibility remain quote-owned. */
export function* revivalExpressionBranches(promise: RevivalExpressionPromise): Generator<RevivalExpressionBranchLeaf[]> {
const expression = parseRevivalExpression(promise.claim);
validateProgress(promise.progress);
check(typeof promise.answer === 'boolean', 'Retain a definite revival expression answer.');
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: RevivalExpression, answer: boolean): Generator<RevivalExpressionBranchLeaf[]> {
if (node.kind === 'forces' || node.kind === 'leader') {
if (evaluate(node, promise.progress)) { if (answer) yield []; return; }
yield [node.kind === 'forces' ? { ...node, progress: promise.progress[node.counter], answer } : { ...node, answer }];
return;
}
const conjunction = node.kind === 'and' ? answer : !answer;
if (!conjunction) {
for (const term of node.terms) yield* alternatives(term, answer);
return;
}
const terms = node.terms;
function* combine(index: number, prefix: RevivalExpressionBranchLeaf[]): Generator<RevivalExpressionBranchLeaf[]> {
if (index === terms.length) { yield prefix; return; }
for (const suffix of alternatives(terms[index], answer)) {
const merged = mergeBranches(prefix, suffix);
if (merged) yield* combine(index + 1, merged);
}
}
yield* combine(0, []);
}
if (promise.answer && evaluate(expression, promise.progress)) { yield []; return; }
yield* alternatives(expression, promise.answer);
}
export function validateRevivalExpressionPromises(promises: readonly RevivalExpressionPromise[], players: readonly string[]): void {
check(Array.isArray(promises), 'The revival expression commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
check(object(promise), 'The revival expression commitment record is invalid.');
const { claim } = parseRevivalExpressionClaim({ turn: promise.turn, claim: promise.claim });
validateProgress(promise.progress);
const satisfied = evaluate(claim, promise.progress);
check(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) &&
(promise.answer || !satisfied) &&
(!promise.answer || promise.released || !!promise.fulfilled === satisfied),
'The revival expression lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}
+22
View File
@@ -1,3 +1,4 @@
import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises';
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';
@@ -64,6 +65,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
| ({ kind: 'alliance'; target: string } & AllianceClaim)
| ({ kind: 'bribe'; target: string } & BribeClaim)
@@ -220,6 +222,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'revivalExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', 'Supply the current turn, full revival expression and respondent.');
const claim = parseRevivalExpressionClaim({ turn: v.turn, claim: v.claim });
check(revivalExpressionPromiseModeSupported(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 revival expression in a quiet window.');
return { kind: 'revivalExpression', target: v.target, ...claim };
}
if (v.kind === 'leaderRevival') {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn',
'Supply the current turn, exact named leader and respondent.');
@@ -500,6 +511,7 @@ export function truthQuestionText(
playerName: (id: string) => string = id => id,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
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)}?`;
@@ -554,6 +566,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;
revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[];
bindRevivalExpression?: (g: Game, p: Player, claim: RevivalExpressionClaim, 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[];
@@ -678,6 +692,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 === 'revivalExpression')
check(effects.revivalExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole revival expression with a compatible native completion.');
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.');
@@ -743,6 +760,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 === 'revivalExpression') {
check(effects.revivalExpressionAnswers && effects.bindRevivalExpression && effects.revivalExpressionAnswers(g, p, q).includes(a.answer),
'Choose a whole revival answer compatible with native returns and earlier promises.');
if (a.answer !== 'unknown') effects.bindRevivalExpression(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.');