Extend Truthtrance revival promises through earlier turn phases

This commit is contained in:
Quintine committed 2026-10-26 13:09:17 +11:00
1 parent 24653799cf
commit 7fc21113e3
14 files changed
+810 -52

No files matched your search

+88 -22
View File
@@ -15124,7 +15124,8 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
try {
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) {
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) {
const trial = structuredClone(g);
trial.truthtrance = null;
trial.auction!.bid = action.amount as number;
@@ -15132,6 +15133,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
trial.auction!.allyPayment = action.allyPayment as number;
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
reconcileRevivalPromises(trial, { actor: p.id, action });
}
return action;
}
@@ -26337,7 +26339,7 @@ type ShipmentCompletion = { actions: Action[]; early?: { phase: number; action:
type ShipmentCompletionSeed = { game: Game; actions: Action[]; early: { phase: number; action: Action }[] };
/** Private owned-stage witnesses, not an early action or a simulation of rival
* choices, unknown draws, future gifts or the unresolved Storm outcome. */
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
let seeds: ShipmentCompletionSeed[] = [{ game: initial, actions: [], early: [] }];
if (source.phase > 4) return seeds;
if (source.phase <= 2 && source.choamCharity?.turn !== source.turn && getPlayer(source,owner).faction === 'choam') {
@@ -26412,13 +26414,21 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
}
seeds = fundingSeeds;
}
const revivalSeeds: ShipmentCompletionSeed[] = [];
for (const seed of seeds) {
return seeds.map(seed => {
const g = structuredClone(seed.game); g.phase = 4;
if (source.phase < 4) {
g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {};
g.ready = []; g.active = owner; delete g.revivalOrder;
const p = getPlayer(g, owner); p.revived = 0; p.freeForcesRevived = 0; p.leaderRevived = false;
}
return { ...seed, game: g };
});
}
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
const seeds = earlyRevivalCompletionSeeds(initial, source, owner);
const revivalSeeds: ShipmentCompletionSeed[] = [];
for (const seed of seeds) {
const g = structuredClone(seed.game); g.phase = 4;
revivalSeeds.push({ ...seed, game: g });
if (source.phase === 4 && source.ready.includes(owner)) continue;
const p = getPlayer(g, owner);
@@ -29821,14 +29831,14 @@ function revivalPromiseRule<T>(calculate: () => T): T {
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. */
/** An unused native opportunity this turn, including earlier phases and waiting
* ordered participants. 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));
return g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && revivalPromiseModeSupported(g) &&
(g.phase < 4 || !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) &&
return g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && revivalPromiseModeSupported(g) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
!g.pendingKarama && !g.pendingTreacheryDiscard;
}
@@ -29854,7 +29864,8 @@ 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 revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null {
if (g.phase !== 4) return 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' };
@@ -29875,6 +29886,24 @@ function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromis
}
return null;
}
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;
if (g.phase === 4) {
const action = currentRevivalPromiseCompletion(g, p, promises);
return action ? { action, early: [] } : null;
}
const initial = structuredClone(g);
initial.truthtrance = null;
for (const seed of earlyRevivalCompletionSeeds(initial, g, p.id)) {
const action = currentRevivalPromiseCompletion(seed.game, getPlayer(seed.game, p.id), promises);
if (action) return { action, early: seed.early };
}
return null;
}
function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
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;
const trial = { ...g, ...later };
@@ -29882,9 +29911,16 @@ function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<
}
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 }]));
return (['yes', 'no'] as const).filter(answer => {
const promises = [...(g.revivalPromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
if (!revivalPromiseCompletion(g, p, promises)) return false;
const lot = bidOpportunity(g);
// An earlier Bid Yes reserves a future declaration even before it becomes
// the actual leading invoice. Check that declaration with this candidate.
return !lot || !bidRequired(g.bidPromises ?? [], p.id, lot) ||
!!bidPromiseCompletion({ ...g, revivalPromises: promises }, p);
});
}
function bindRevivalTruth(g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) {
requireRule(revivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
@@ -29900,7 +29936,7 @@ function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action:
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;
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;
@@ -29913,13 +29949,33 @@ function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action:
} 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;
function projectedRevivalPromiseGuide(g: Game, player: string): { 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 ||
g.phase === 4 && nativeRevivalOrderBlock(g, player)) return null;
// The separate bid guide owns a jointly binding Yes declaration. Passing
// 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];
if (!promise) return null;
const action = revivalPromiseCompletion(g, getPlayer(g, player));
return action ? { action: { ...action, revivalPromiseEvent: promise.event } } : null;
const plan = revivalPromisePlan(g, getPlayer(g, player));
if (!plan) return null;
if (g.phase === 3 && g.richeseAuction && !ownPayment) {
const native = g.richeseAuction;
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;
}
if (g.phase === 3 && g.ixAuction) return null;
const current = plan.early.find(step => step.phase === g.phase)?.action;
const action = g.phase === 4 ? plan.action : g.phase === 2 && !g.ready.includes(player)
? 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;
}
function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) {
const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn);
@@ -34857,8 +34913,12 @@ 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.');
if (action.revivalPromiseEvent !== undefined) {
const guide = projectedRevivalPromiseGuide(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 revival commitment guide is stale or belongs to another native opportunity.');
}
const g = applyActionInner(state, id, action);
reconcileBidPromises(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
@@ -38033,6 +38093,13 @@ function applyActionInner(
const a = g.auction;
if (t === 'bid') {
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
const trial = structuredClone(g);
trial.auction!.bid = quotedBid.amount;
trial.auction!.bidder = p.id;
trial.auction!.allyPayment = quotedBid.allyPayment;
reconcileRevivalPromises(trial, { actor: p.id, action });
}
guardPromisedBid(g, p, action, quotedBid.amount);
a.bid = quotedBid.amount;
a.allyPayment = quotedBid.allyPayment;
@@ -39265,7 +39332,6 @@ function applyActionInner(
}
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),
+7 -7
View File
@@ -3214,19 +3214,19 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind cumulative future total, normal or elite physical returns in the current native Revival phase.',
summary: 'Bind cumulative future total, normal or elite physical returns in the same turn before native Revival closes.',
steps: [
'Choose Bind ordinary force revivals this phase, another participant with a remaining native opportunity, total/normal/elite counters and a minimum from1 to20. This first version supports registered native Basic and Advanced tables without optional modules; it opens no new game-start configuration.',
'Choose Bind ordinary force revivals this turn, another participant with a remaining native opportunity, total/normal/elite counters and a minimum from1 to20. This first version supports registered native Basic and Advanced tables without optional modules; it opens no new game-start configuration.',
'Only ordinary force returns actually completed after your answer count. Each elite is one physical counter. Earlier returns, leaders, Ghola, Emperor-funded extras and other special returns are separate. Smaller ordinary requests add together.',
'Yes requires enough matching returns while possible. No forbids reaching the minimum, even through several smaller requests. Several answers must remain jointly possible. The original typed Tanks, free allowance, paid prices, elite limits and Revival order remain authoritative.',
'The private answer choices and guide use current native quotas and funding, including native free/paid selection. Earlier shipment and ground-movement promises retain their funding. Unknown gifts, future draws and foreign choices are not assumed.',
'The private answer choices and guide use current native quotas and funding, including native free/paid selection. Earlier questions retain current Tanks and actual liabilities, project fresh normal Revival ledgers, and may prepare through actual Charity or owned opening income. Unknown future casualties do not supply forces. Earlier shipment and ground-movement promises retain their funding. Unknown gifts, future draws and foreign choices are not assumed.',
'Your own request, spending or Finish Revival cannot evade an unmet Yes. A completed opposing effect may release an impossible answer in accepted order. A requested return does not fulfill Yes until the actual return completes; its original responses still decide the outcome.',
'The persistent own promise panel displays progress and fulfilled/released status. It waits for your actual native opportunity before offering a normal revival or Finish Revival. Basic No remains through phase close because native readiness can reopen; stamped Advanced No ends when your opportunity finishes.',
'This cumulative current-phase protocol is a provisional online composition of general current-turn Truthtrance authority. Earlier-phase questions, optional-module compositions, special/card revival claims, grouped revival expressions and complete-mode acceptance remain required work.',
'The persistent own promise panel displays progress and fulfilled/released status. Before Revival it waits for your actual Charity/Ready, ordinary/native pass, Silent zero or owned ordinary auction-payment choice; at Revival it offers a normal return or Finish Revival. Each guide belongs to its current phase and original event. Basic No remains through phase close because native readiness can reopen; stamped Advanced No ends when your opportunity finishes.',
'This cumulative this-turn protocol is a provisional online composition of general current-turn Truthtrance authority. Optional-module compositions, special/card revival claims, grouped revival expressions and complete-mode acceptance remain required work.',
],
related: ['card-truthtrance', 'advanced-revival-order', 'truthtrance-shipment', 'truthtrance-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Typed cumulative current-phase native ordinary returns, real completion, compatible answers, voluntary guards and forced release retain original cost/quota/order.', evidence: ['game/revival-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/revival-promises.test.ts', 'tests/truth-revival-runtime.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Typed cumulative phases0–4 native ordinary returns, real completion, compatible answers, voluntary guards and forced release retain original cost/quota/order.', evidence: ['game/revival-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/revival-promises.test.ts', 'tests/truth-revival-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Structured question, target-only feasible answers and persistent event-bound normal revival/finish guide.', evidence: ['components/revival-claim-fields.tsx', 'components/revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing four profiles use feasible answers and the native action guide; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General authority, provisional timing/count interpretation, native order and remaining boundaries are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
@@ -3419,7 +3419,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
'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.',
'An ordinary force-revival question binds cumulative future total, normal or elite returns in the current native Revival phase. 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.',
'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.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
+4 -4
View File
@@ -218,12 +218,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn',
'Supply the current Revival turn, physical counter type, minimum and respondent.');
const claim = parseRevivalClaim(v);
check(revivalPromiseModeSupported(g) && g.status === 'playing' && g.phase === 4 &&
claim.turn === g.turn && !g.ready.includes(v.target as string) &&
(!g.revivalOrder || g.revivalOrder.remaining.includes(v.target as string)) &&
check(revivalPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 &&
claim.turn === g.turn && (g.phase < 4 || !g.ready.includes(v.target as string) &&
(!g.revivalOrder || g.revivalOrder.remaining.includes(v.target as string))) &&
!g.pendingRevival && !g.response && !g.decision && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a remaining native Revival opportunity in a supported table and finish pending decisions.');
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
return { kind: 'revival', target: v.target, ...claim };
}
if (v.kind === 'bid') {