Add cumulative Truthtrance force-revival commitments

This commit is contained in:
Quintine committed 2026-10-26 12:57:18 +11:00
1 parent bd6e5bbf7f
commit 24653799cf
21 files changed
+1540 -56

No files matched your search

+208 -46
View File
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises, recordPromisedRevival,
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
@@ -2136,6 +2138,7 @@ export type Game = {
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
revivalPromises?: RevivalPromise[];
movementPromises?: MovementPromise[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -18205,7 +18208,9 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
for (const answer of [true, false]) {
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
if (groundMovementCompletion(answerSource, p.id, [...live, promise]) &&
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
@@ -26654,7 +26659,8 @@ function shipmentTruthAnswers(
// Concurrent legacy readiness can reopen; a completed ordered turn cannot.
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
if (findShipmentCompletion(answerSource, p, promises))
if (findShipmentCompletion(answerSource, p, promises) &&
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }))
answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
@@ -29699,6 +29705,8 @@ function finishRevival(
const source = revival.nexusEmperorEvent ? 'nexusEmperor' :
revival.emperorExtra ? 'emperorExtra' : 'normal';
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 }));
commitRevivalForcePieces(g, p, revival);
finishNexusEmperorRevival(g, revival, 'returned');
if (quote.techIncome) {
@@ -29761,6 +29769,183 @@ function finishRevival(
}
}
}
function normalForceRevivalQuote(g: Game, p: Player, action: Action): PendingRevival {
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
const availableFree = forceRevivalQuote(g, p, n).free;
const freeElite =
p.faction === 'ixians'
? integer(
action.freeElite ?? Math.min(availableFree, elite),
Math.max(0, availableFree - (n - elite)),
Math.min(availableFree, elite),
'Free cyborg revivals',
)
: 0;
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
enabled: ixHomeworldRevivalEnabled(g, p.id) && !g.revivalRules?.freeBlocked?.includes(p.id), amount: n, elite,
free: quote.free, freeElite,
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
}) : 0;
const checks: PendingRevival['checks'] = [];
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
const uncancelableLimit = Math.max(
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
checks.push('choamRevival');
if (!choamBenefit && p.revived + n > uncancelableLimit)
checks.push('revivalLimit');
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
if (quote.cost < factionCost) checks.push('revivalDiscount');
return {
player: p.id,
kind: 'forces',
amount: n,
elite,
...quote,
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
checks,
};
}
function revivalPromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof RevivalPromiseError) throw new RuleError(error.message); throw error; }
}
function revivalPromiseIntegrity(g: Game) {
revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id)));
}
/** The current native phase only; a waiting ordered participant still owns a
* future opportunity. Basic readiness may reopen, so No lasts to phase close. */
function revivalOpportunity(g: Game, player: string): boolean {
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
!g.ready.includes(player) && (!g.revivalOrder || g.revivalOrder.remaining.includes(player));
}
function revivalQuestionWindow(g: Game): boolean {
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
!g.pendingKarama && !g.pendingTreacheryDiscard;
}
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);
}
// 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);
try {
const action = { type: 'ready' };
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
return true;
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}
/** 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 revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
if (!revivalOpportunity(g, p.id)) 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;
const maximum = forceRevivalRemaining(g, p), eliteTanks = p.elites?.tanks ?? 0;
for (let amount = 1; amount <= maximum; amount++)
for (let elite = Math.max(0, amount - (p.tanks - eliteTanks)); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) {
if (!revivalPromisesSatisfied(promises, p.id, g.turn, { amount, elite })) continue;
const free = forceRevivalQuote(g, p, amount).free;
for (let freeElite = p.faction === 'ixians' ? Math.max(0, free - (amount - elite)) : 0;
freeElite <= (p.faction === 'ixians' ? Math.min(free, elite) : 0); freeElite++) {
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;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
return 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;
const trial = { ...g, ...later };
return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id));
}
function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | 'no' | 'unknown')[] {
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn) return [];
return (['yes', 'no'] as const).filter(answer => !!revivalPromiseCompletion(g, p, [...(g.revivalPromises ?? []),
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
answer: answer === 'yes', progress: 0 }]));
}
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 });
}
function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) {
if (!g.revivalPromises?.some(promise => !promise.fulfilled && !promise.released)) 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 === 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])) {
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 revival answer; that answer is no longer binding.`);
} else kept.push(promise);
}
}
function projectedRevivalPromiseGuide(g: Game, player: string): { action: Action } | null {
if (!revivalQuestionWindow(g) || g.truthtrance || !revivalOpportunity(g, player) ||
nativeRevivalOrderBlock(g, player)) return null;
const promise = liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0];
if (!promise) return null;
const action = revivalPromiseCompletion(g, getPlayer(g, player));
return action ? { action: { ...action, revivalPromiseEvent: promise.event } } : null;
}
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 (!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.');
return;
}
// Preflight a voluntary request against its actual current invoice. Only the
// 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.
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
actor.spice -= revival.cost;
commitRevivalForcePieces(trial, actor, revival);
requireRule(!!revivalPromiseCompletion(trial, actor),
'This request would prevent you from completing your Truthtrance revival answer.');
}
function beginRevival(g: Game, revival: PendingRevival) {
requireRule(
!revivalPrevented(g, revival.player),
@@ -34568,6 +34753,7 @@ function normalizeCardNames(g: Game) {
}
export function applyAction(state: Game, id: string, action: Action): Game {
bidPromiseIntegrity(state);
revivalPromiseIntegrity(state);
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -34671,8 +34857,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
);
return normalizeAutomaticGame(state);
}
if (action.revivalPromiseEvent !== undefined) requireRule(action.type === 'revive' || action.type === 'ready',
'This revival commitment guide cannot authorize another action.');
const g = applyActionInner(state, id, action);
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileGroundMovementPromises(g, id, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
@@ -34795,6 +34984,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
observeOccupation(g);
reconcileHomeworldEcazDuke(g);
@@ -34996,6 +35186,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileRevivalPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
requireRule(
@@ -35010,6 +35201,7 @@ function settleAutomaticContinuations(g: Game) {
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
bidPromiseIntegrity(state);
revivalPromiseIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
harkonnenNexusBetrayalIntegrity(state);
@@ -35094,6 +35286,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcileRevivalPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
settleAutomaticContinuations(g);
@@ -35339,6 +35532,8 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
revivalAnswers: revivalTruthAnswers,
bindRevival: bindRevivalTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
@@ -35350,7 +35545,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -38104,49 +38299,9 @@ function applyActionInner(
if (t === 'revive') {
requireRule(g.phase === 4, 'Revival is not the current phase.');
if (execution === 'live') startNativeRevivalOrder(g,id);
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
const availableFree = forceRevivalQuote(g, p, n).free;
const freeElite =
p.faction === 'ixians'
? integer(
action.freeElite ?? Math.min(availableFree, elite),
Math.max(0, availableFree - (n - elite)),
Math.min(availableFree, elite),
'Free cyborg revivals',
)
: 0;
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
enabled: ixHomeworldRevivalEnabled(g, id) && !g.revivalRules?.freeBlocked?.includes(id), amount: n, elite,
free: quote.free, freeElite,
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
}) : 0;
const checks: PendingRevival['checks'] = [];
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
const uncancelableLimit = Math.max(
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
checks.push('choamRevival');
if (!choamBenefit && p.revived + n > uncancelableLimit)
checks.push('revivalLimit');
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
if (quote.cost < factionCost) checks.push('revivalDiscount');
beginRevival(g, {
player: id,
kind: 'forces',
amount: n,
elite,
...quote,
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
checks,
});
const revival = normalForceRevivalQuote(g, p, action);
if (execution === 'live') guardPromisedRevival(g, p, action, revival);
beginRevival(g, revival);
return g;
}
if (t === 'reviveKwisatz' || t === 'reviveLeader') {
@@ -39109,6 +39264,8 @@ function applyActionInner(
return g;
}
if (t === 'ready') {
if (g.phase === 4) guardPromisedRevival(g, p, action);
else requireRule(action.revivalPromiseEvent === undefined, 'This revival commitment guide is stale.');
requireRule(
[1, 2, 4, 7, 8].includes(g.phase) ||
(g.phase === 0 && g.stormPending !== null),
@@ -39302,6 +39459,11 @@ export function viewGame(state: Game, id: string) {
}).map(player => player.id) : [],
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
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),
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,