diff --git a/components/revival-promises.tsx b/components/revival-promises.tsx
index b012469b..6a9a1193 100644
--- a/components/revival-promises.tsx
+++ b/components/revival-promises.tsx
@@ -6,29 +6,49 @@ import { Button } from './ui/button';
type RevivalPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
-/** Preserve the server's exact native return/Ready quote. The client checks
- * only the live personalized event and current owned action opportunity. */
+/** Preserve the server's exact preparation or native return/Ready quote. The
+ * client checks only its live event and current owned action opportunity. */
export function RevivalPromiseGuideButton(props: RevivalPromiseProps) {
const game = props.game, guide = game.revivalPromiseGuide;
if (!guide) return null;
- const actor = game.me, turn = game.turn, order = game.revivalOrder;
+ const actor = game.me, turn = game.turn, phase = guide.phase, order = game.revivalOrder;
const action = guide.action, event = action.revivalPromiseEvent, signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
+ const payment = phase === 3 && action.type === 'decision' &&
+ current.decision?.kind === 'auctionPayment' && current.decision.player === actor;
+ const native = current.richeseAuction;
+ const nativePass = phase === 3 && action.type === 'richeseBid' && native &&
+ native.event === action.event && !native.outcome && native.eligible.includes(actor) &&
+ (native.method === 'silent' ? !native.submitted.includes(actor) && action.amount === 0
+ : native.active === actor && action.amount === null) && action.allyPayment === 0;
+ const opportunity = phase === 2
+ ? !current.ready.includes(actor) && (action.type === 'charity' || action.type === 'ready')
+ : phase === 3
+ ? payment || nativePass || action.type === 'passBid' && !native && current.auction?.active === actor
+ : phase === 4 && !current.ready.includes(actor) && (action.type === 'revive' || action.type === 'ready') &&
+ current.revivalOrder === order && (!current.revivalOrder || current.revivalOrder.turn === turn &&
+ current.revivalOrder.remaining[0] === actor && current.active === actor);
return !props.busy && current === game && revivalPromiseModeSupported(current) &&
- current.status === 'playing' && current.phase === 4 && current.me === actor && current.turn === turn &&
- !current.ready.includes(actor) && current.revivalOrder === order &&
- (!current.revivalOrder || current.revivalOrder.turn === turn && current.revivalOrder.remaining[0] === actor && current.active === actor) &&
- current.revivalPromiseGuide === guide && current.revivalPromiseGuide.action === action && JSON.stringify(action) === signature &&
- typeof event === 'string' && (action.type === 'revive' || action.type === 'ready') &&
+ current.status === 'playing' && current.phase === phase && current.me === actor && current.turn === turn && opportunity &&
+ current.revivalPromiseGuide === guide && current.revivalPromiseGuide.phase === phase &&
+ current.revivalPromiseGuide.action === action && JSON.stringify(action) === signature && typeof event === 'string' &&
liveRevivalPromises(current.revivalPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot &&
- !current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
+ !current.truthtrance && !current.response && (!current.decision || payment) && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return { if (canAct()) props.send(action); }}>
- {action.type === 'revive'
- ? `Revive ${typeof action.amount === 'number' ? action.amount : 'the suggested number of'} forces while honoring your answers`
+ {action.type === 'charity' ? 'Claim Charity for your later Revival'
+ : action.type === 'richeseBid' ? action.amount === 0
+ ? 'Submit zero in this Silent auction while preserving your Revival answers'
+ : 'Pass this Richese bid while preserving your Revival answers'
+ : action.type === 'passBid' ? 'Pass this bid while preserving your Revival answers'
+ : action.type === 'decision' ? action.karama === true
+ ? 'Use the quoted Karama auction payment for your later Revival'
+ : 'Pay the current auction price while preserving your Revival answers'
+ : action.type === 'revive' ? `Revive ${typeof action.amount === 'number' ? action.amount : 'the suggested number of'} forces while honoring your answers`
+ : phase === 2 ? 'Finish Charity while preserving your Revival answers'
: 'Finish Revival while honoring your answers'}
;
}
@@ -57,7 +77,13 @@ export function RevivalPromises(props: RevivalPromiseProps) {
Original revival costs, limits and turn order still apply. Optional modules remain outside this commitment.
{' '}Revival promise rules .
- {waiting && Your Revival opportunity is still ahead; this promise remains visible while the current actor finishes.
}
+ {props.game.phase < 4 &&
+ A current preparation may claim Charity, pass an auction bid, or complete an already committed auction payment.
+ These steps preserve your later Revival; they do not count as returned forces.
+
}
+ {waiting && Your Revival opportunity is still ahead; {props.game.phase < 4
+ ? 'continue the current phase and keep the required spice and forces available.'
+ : 'this promise remains visible while the current actor finishes.'}
}
;
}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 1a66f0d1..f75085b8 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -342,7 +342,7 @@ export function Truthtrance({
Verified current facts
Bind ground movement this turn
- Bind ordinary force revivals this phase
+ Bind ordinary force revivals this turn
Bind a bid in this ordinary lot
Bind this battle’s Traitor declaration
@@ -363,8 +363,9 @@ export function Truthtrance({
<>
setRevivalSelection({ counter: claim.counter, minimum: claim.minimum })} />
+ {g.phase < 4 && Revival comes later this turn. Keep its required funding; actual Charity or an owned auction payment may prepare the return. Unknown future draws, gifts and casualties are not assumed.
}
Count physical total, normal or elite counters returned by ordinary native Revival after the answer. Smaller requests add together. Earlier returns, Ghola, Emperor-funded extras and other special returns do not count. Current native limits, prices and response choices remain.
- {!g.truthRevivalTargets.includes(target) && Choose a respondent whose native opportunity remains in the current Revival phase and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.
}
+ {!g.truthRevivalTargets.includes(target) && Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.
}
{truthQuestionText(question, leaderName, locationName)}
>
) : kind === 'bid' ? (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 04794e6e..1ed678f6 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,42 @@ open mode gates.
## Current checkpoint and work
+**Truthtrance / earlier-turn native Revival promises — 26 October. Prototyped / Partial.**
+Structured ordinary-return questions now work in quiet phases0–4, retaining
+current Tanks/elite usage and projecting the real future normal quota reset.
+Shared owned Charity/opening-income and auction-liability witnesses support
+phase-bound preparation guides, actual return/Ready, stale-event rejection and
+joint bid/revival funding in either question order. Ordinary bid acceptance
+cannot reserve funds needed by an earlier Yes. Original native responses and
+actual return accounting remain. No new start/module gate opens.
+
+Root shared integration with disjoint early-native/control lanes and bounded
+quote review passes **17 files,165/165**, types/lint/build and **six native
+paths**,47 GameTable SSR renders/20 actual guide callbacks on unchanged source.
+Original H/A/E post-Storm Basic41/Advanced42-action prefixes use another21/24
+steps through actual Spice, Charity and Bidding to bind/return3 for1free+4paid.
+Basic48/Advanced52-action poor sources really bribe away4 first;16 more actions
+earn actual Charity2, preserve progress0 through preparation and return2 for2.
+All four minimal policies submit legal Charity in both source modes.
+
+A66-action actual turn3 Storm source retains a prior-turn ordinary return1;
+another26 actions use the real new quota reset and return2 for2 with progress
+starting0. An original dealt-Karama59-action Bidding source adds17 actions:
+actual bid5 opens its own payment guide, spends that one Karama, keeps4 and
+later returns3 for4. All33 physical cards remain; no played hand, purse,
+force/Tanks, clock or promise is installed. Fourteen native/seven early-control
+cases also retain No through phases, actual native Tleilaxu prevention and
+compatible/incompatible bid commitments in both answer orders. Richese zero/pass
+controls have controlled projection evidence; their dedicated original Revival
+journey and pre-invoice escrow-withdrawal/retained-Karama preparation remain due.
+
+Frozen report/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-early-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-revival-early-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or assurance campaign.
+SSR/callbacks leave browser/mobile/deployed, broader compositions and complete
+rules acceptance open. [Source and boundaries](TRUTHTRANCE_REVIVAL_PROMISES.md#earlier-turn-follow-up--26-october-2026).
+
**Truthtrance / native force-revival commitments — 26 October. Prototyped / Partial.**
Registered bare native Basic/Advanced Revival now supports cumulative future
physical total/normal/elite returns. Only actual ordinary returns after the
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index fe99d118..70668d5c 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,40 @@
# Dune implementation status
+## 26 October 2026 — Earlier-turn native Revival promises
+
+**Prototyped / Partial.** Ordinary cumulative physical return questions now
+support quiet phases0–4 with this turn's native Revival opportunity ahead.
+The shared owned preparation extracts existing Charity/opening-income and
+auction-liability witnesses; actual current Tanks/elite usage, held-card spend,
+future normal quota reset and earlier commitments remain. Phase-bound guides
+claim Charity, ready/pass or pay an owned ordinary invoice, then use actual
+native Revival order and returns. Yes retains its funding, No persists to the
+actual boundary, and ordinary bids cannot evade earlier Yes. Earlier Bid Yes
+also constrains later Revival answers. No start/module/publication gate opens.
+
+Root integration with disjoint early-native/control lanes and bounded quote
+review passes **17 files,165/165**, types/lint/build and **six native paths**,
+47 GameTable SSR renders/20 actual guide callbacks. Original H/A/E post-Storm
+Basic41/Advanced42-action sources add21/24 steps through real intervening phases
+and return3 for1free+4paid. Poor Basic48/Advanced52 sources really bribe4 away;
+16 more actions earn actual Charity2 and later return2 for2. Four minimal
+policies per poor source submit legal Charity. Progress stays0 during preparation.
+
+A66-action real turn3 Storm source retains prior ordinary return1;26 actions
+use the actual next quota reset and new return2 for2. A59-action original
+dealt-Karama source adds17: real unfunded bid5, its owned payment guide, one
+spent Karama,4 preserved and native return3/payment4. All33 cards remain; no
+played hand, purse, force/Tanks, clock or promise is installed. Fourteen native
+and seven early-control cases cover No, native later prevention, phase/actor
+replay and joint bid/revival funding in both answer orders. Controlled Richese
+zero/pass callbacks retain actual event/owner; a dedicated original Revival
+journey and escrow-withdrawal/retained-card witness remain due. Frozen proof:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-early-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-revival-early-runtime/`.
+No saved-room/D1/server/reset/restart/deployment or assurance campaign.
+Browser/mobile/deployed, broader compositions and complete rules remain open.
+[Contract](TRUTHTRANCE_REVIVAL_PROMISES.md#earlier-turn-follow-up--26-october-2026).
+
## 26 October 2026 — Truthtrance native force-revival commitments
**Prototyped / Partial.** Cumulative future ordinary native force returns now
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index f25fac73..e65ba1df 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,21 @@
# Rule decision index
+## Earlier-turn native Revival promises — 26 October 2026
+
+Extend the existing general current-turn Truthtrance composition from phase4
+to quiet phases0–4 with an unused native Revival opportunity this turn. Keep
+current Tanks, actual elite usage and known owned resources; reset only normal
+Revival quotas at its real future boundary. Reuse native Charity/owned opening
+and auction-liability witnesses, carrying spent Karama into later quotations.
+Do not invent future casualties, draws, gifts or rival payments.
+
+This remains a provisional online composition of FAQp8 and supplied Advanced
+physical39, not a dedicated earlier-phase publisher ruling. Native ownership,
+original responses, phase-bound guided packets and current-turn expiry remain.
+Own voluntary actions preserve Yes; completed opposing outcomes may release it.
+Optional modules and broader revival claims remain required; no start gate opens.
+[Contract and evidence](TRUTHTRANCE_REVIVAL_PROMISES.md#earlier-turn-follow-up--26-october-2026).
+
## Truthtrance native force-revival commitments — 26 October 2026
Apply general current-turn Truthtrance authority (November2020 FAQ p8; supplied
diff --git a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md
index 1f61d8b0..766fc526 100644
--- a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md
+++ b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md
@@ -1,5 +1,71 @@
# Truthtrance native force-revival promises
+## Earlier-turn follow-up — 26 October 2026
+
+**Prototyped / Partial.** The same ordinary-return question now works in quiet
+phases0–4 while this turn's native Revival opportunity remains. The general
+current-turn authority below still applies. This extends the explicit future
+interval; it does not promise casualties or resources from unknown future play.
+Current physical Tanks and typed current-turn elite usage are retained. The
+future normal/free revival ledgers reset exactly as at real phase4 entry.
+
+The guide and answer search reuse the same owned Charity/opening-income and
+auction-liability preparation used by shipment. Actual Charity, an owned CHOAM
+opening's allowed fixed outcome, current winning invoices, paid invoices and
+unused outgoing pledge refunds stay distinct. A physical held Karama used for
+an owned ordinary invoice is removed from that witness before any later quote.
+Incoming allied bidding escrow does not become promised revival funding. The
+shared witness conservatively requires current cash for an unpaid invoice
+before adding refundable outgoing aid; a pre-invoice withdrawal that could
+retain a Karama instead of spending it remains an uncovered preparation route.
+It is not certified by a different optional-module fixture. No unknown draw,
+foreign gift or future rival payment/casualty is invented.
+
+Earlier answers remain visible through phase progression. The guide waits in
+Storm/Spice and for foreign actors; in Charity it can offer actual Charity or
+Ready, in Bidding an owned ordinary/native pass, Silent zero or actual ordinary payment
+choice, then in
+Revival its actual ordinary return/Finish. An existing binding bid Yes retains
+its separate real bid guide. The payload carries the current phase and original
+event; stale/foreign or modified guided packets reject. Manual packets keep
+their native shape. Normal Yes/No progress still records only actual ordinary
+returns and expires at the original opportunity boundary.
+
+A voluntary pass of needed Charity, unfunded bid, bribe or other spending cannot
+evade Yes. Completed opposing outcomes finish before impossible answers release.
+Basic No remains until phase close; Advanced retains the stamped current actor.
+Optional modules, grouped expressions and special/card revival claims remain
+required; no start, complete-mode or publication gate opens. Wider preservation,
+privacy/custody, browser and deployed acceptance remain due after all rules.
+
+The new original-source/runtime and early-control cases live in
+`tests/fixture-truth-revival-early.ts`, `tests/truth-revival-early-runtime.test.ts`
+and `tests/truth-revival-early-controls.test.tsx`. Native Richese zero/pass
+controls have controlled projection evidence; a dedicated original Richese
+revival journey remains due. Frozen proof passes **17files/165cases**, `npm run check:quick` and
+`npm run build`, with six native paths,47 GameTable SSR renders and20 actual
+hook-free callbacks. Original Basic41/Advanced42 post-Storm prefixes add21/24
+steps through actual phases and return3 for1free+4paid. Poor Basic48/Advanced52
+sources really bribe4 away, then16 steps earn Charity2 and return2 for2.
+Four minimal policies per poor source submit a legal Charity action. Progress
+remains0 through preparation. A66-action genuine turn3 Storm/prior-return1
+source adds26 actions through the actual reset and returns2 for2. An original
+dealt-Karama59-action source adds17: actual bid5/payment guide, one spent Karama,
+4 retained and ordinary return3/payment4. All33 original cards remain.
+Fourteen native/seven early-control cases retain No, native later prevention,
+stale phase/actor rejection and bid/revival funding in both answer orders.
+
+Report/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-early-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-revival-early-runtime/`.
+No played hand, force/Tanks, purse, clock or promise is installed in native
+sources. No saved-room/D1/server/reset/restart/deployment or assurance campaign.
+SSR/callbacks do not certify DOM/hydration, browser/mobile/deployed or complete
+rules/combinations.
+
+The following section records the preceding current-phase checkpoint; its
+phase4-only and earlier-phase absence statements are superseded by this section.
+
## Current-phase prototype — 26 October 2026
**Prototyped / Partial.** A structured question binds cumulative future ordinary
diff --git a/game/engine.ts b/game/engine.ts
index 60e76f7a..0ea7436c 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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(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, '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 !!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),
diff --git a/game/reference.ts b/game/reference.ts
index 57c051a5..8f5a9e33 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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.',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 0af580b6..75799120 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -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') {
diff --git a/tests/fixture-truth-revival-early.ts b/tests/fixture-truth-revival-early.ts
new file mode 100644
index 00000000..1a7b4690
--- /dev/null
+++ b/tests/fixture-truth-revival-early.ts
@@ -0,0 +1,124 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import type { RevivalClaim } from '../game/revival-promises';
+import {
+ actTruthRevival, answerTruthRevival, askTruthRevival, bindTruthRevival, createTruthRevivalFixture,
+ declareTruthRevival, nextTruthRevival, settleTruthRevival,
+ truthRevivalClean, truthRevivalInventory, truthRevivalPlayer, type TruthRevivalStep, type TruthRevivalTransition,
+} from './fixture-truth-revival';
+
+export type TruthRevivalEarlyStep = TruthRevivalStep;
+export const actTruthRevivalEarly = actTruthRevival;
+export const truthRevivalEarlyClean = truthRevivalClean;
+export const truthRevivalEarlyInventory = truthRevivalInventory;
+export const truthRevivalEarlyPlayer = truthRevivalPlayer;
+export const askTruthRevivalEarly = askTruthRevival;
+export const answerTruthRevivalEarly = answerTruthRevival;
+export const settleTruthRevivalEarly = settleTruthRevival;
+export function nextTruthRevivalEarly(game: Game): TruthRevivalEarlyStep | null {
+ if (game.truthtrance) return null;
+ for (const player of game.players) {
+ const view = viewGame(game, player.id);
+ if (game.phase === 3 && view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action };
+ const guide = view.revivalPromiseGuide;
+ if (guide && guide.phase === game.phase) return { actor: player.id, action: guide.action };
+ }
+ return nextTruthRevival(game);
+}
+export function advanceTruthRevivalEarly(game: Game, until: (state: Game) => boolean,
+ actions: TruthRevivalEarlyStep[] = []): Game {
+ for (let count = 0; count < 1500; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The earned early Revival source ended before its owned control');
+ const next = nextTruthRevivalEarly(game); assert.ok(next, 'Answer the actual original Truthtrance before continuing');
+ game = actTruthRevivalEarly(game, next.actor, next.action, actions);
+ }
+ throw Error('The earned early Revival source did not reach its finite boundary');
+}
+export function atTruthRevivalEarlyOpportunity(game: Game, target: string) {
+ return game.phase === 4 && truthRevivalEarlyClean(game) &&
+ (!game.revivalOrder || game.revivalOrder.remaining[0] === target);
+}
+export function reachTruthRevivalEarlyOpportunity(game: Game, target = 'emperor', actions: TruthRevivalEarlyStep[] = []) {
+ return advanceTruthRevivalEarly(game, state => atTruthRevivalEarlyOpportunity(state, target), actions);
+}
+export function finishTruthRevivalEarly(game: Game, actions: TruthRevivalEarlyStep[] = []): Game {
+ const turn = game.turn;
+ return advanceTruthRevivalEarly(game, state => state.turn === turn && state.phase === 5 && truthRevivalEarlyClean(state), actions);
+}
+
+/** Reuse only the actual post-Storm snapshot and its exact original action
+ * prefix. The already constructed later Revival suffix is excluded. Phase0
+ * additionally completes a genuine turn; its optional prior return uses a real
+ * free request and deliberately leaves that old ledger visible until next4. */
+export function createTruthRevivalEarlyFixture(options: {
+ advanced?: boolean; phase?: 0 | 1 | 2 | 3; poor?: boolean; stopper?: boolean; phase0PriorReturn?: boolean; targetKarama?: boolean;
+} = {}) {
+ const advanced = options.advanced ?? false, phase = options.phase ?? 1;
+ const base = createTruthRevivalFixture({ advanced, stopper: options.stopper, targetKarama: options.targetKarama, window: 'waiting' });
+ const prefixCount = base.sourceActionCount - base.arrival.steps.length;
+ const actions = structuredClone(base.sourceActions.slice(0, prefixCount));
+ assert.deepEqual(actions.at(-1), base.storm.steps.at(-1));
+ let game = structuredClone(base.storm.after);
+ const postStorm = structuredClone(game);
+ const act = (actor: string, action: Action) => { game = actTruthRevivalEarly(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthRevivalEarly(game, until, actions); };
+ const transition = (run: () => void): TruthRevivalTransition => {
+ const before = structuredClone(game), from = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
+ };
+ assert.equal(game.turn, 2); assert.equal(game.phase, 1); assert.equal(truthRevivalEarlyPlayer(game, base.target).tanks, 3);
+ let priorReturn: TruthRevivalTransition | null = null;
+ if (phase === 0) {
+ if (options.phase0PriorReturn) {
+ reach(state => atTruthRevivalEarlyOpportunity(state, base.target));
+ priorReturn = transition(() => {
+ act(base.target, { type: 'revive', amount: 1, elite: 0 });
+ game = settleTruthRevivalEarly(game, actions);
+ });
+ }
+ reach(state => state.turn === 3 && state.phase === 0 && truthRevivalEarlyClean(state));
+ } else if (phase !== 1) reach(state => state.turn === 2 && state.phase === phase && truthRevivalEarlyClean(state));
+ assert.equal(game.phase, phase);
+ // These still-unopened ordinary cards keep the following early-phase path
+ // separate from an unrelated worm/alliance or Sandtrout programme.
+ const futureSpiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
+ if (phase === 0) for (const position of [0, 1]) {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.sector >= 7);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
+ futureSpiceOrder.push({ position, from, card: structuredClone(card) });
+ }
+ let funding: TruthRevivalTransition | null = null;
+ if (options.poor) {
+ assert.equal(phase, 2, 'The earned Charity witness begins at quiet actual Charity');
+ assert.equal(truthRevivalEarlyPlayer(game, base.target).spice, 4);
+ funding = transition(() => { act(base.target, { type: 'bribe', target: base.asker, amount: 4 }); });
+ assert.equal(truthRevivalEarlyPlayer(game, base.target).spice, 0);
+ }
+ assert.deepEqual(truthRevivalEarlyInventory(game), base.originalCards);
+ const question = { kind: 'revival' as const, target: base.target, turn: game.turn,
+ counter: 'total' as const, minimum: options.poor || options.phase0PriorReturn ? 2 : 3 };
+ return { ...base, game, sourceGame: structuredClone(game), base, advanced, phase,
+ originalPostStorm: postStorm, postStormActionCount: prefixCount, excludedArrivalSteps: base.arrival.steps,
+ priorReturn, futureSpiceOrder, funding, poor: options.poor ?? false,
+ question, questionAction: { type: 'truthAsk', question } as Action,
+ actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
+ staging: [] as string[], sourceDisclosure: 'Only the original earned Storm snapshot and its actual action prefix are reused; the later Revival suffix is removed from the source log. Phase0 completes a real extra turn and can earn a prior free return. Charity poverty uses an actual four-spice bribe before Truth. Questions, guides, bids, invoices, Charity and ordinary returns remain actual caller actions; no played state or promise is installed.' };
+}
+export type TruthRevivalEarlyFixture = ReturnType;
+export function declareTruthRevivalEarly(f: TruthRevivalEarlyFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
+ return declareTruthRevival(f, state, options);
+}
+export function bindTruthRevivalEarly(f: TruthRevivalEarlyFixture, answer: 'yes' | 'no' = 'yes',
+ minimum = f.question.minimum, counter: RevivalClaim['counter'] = 'total', state = f.game) {
+ return bindTruthRevival(f, answer, minimum, counter, state);
+}
+
+/** Every intervening phase follows native controls or its own exact projected
+ * commitment guide. Stop before the actual current ordinary return, not after
+ * an invented future action. */
+export function completeTruthRevivalEarly(f: TruthRevivalEarlyFixture, state = f.game) {
+ const actions: TruthRevivalEarlyStep[] = [];
+ const game = reachTruthRevivalEarlyOpportunity(state, f.target, actions);
+ return { game, before: structuredClone(state), actions, actionCount: actions.length };
+}
diff --git a/tests/fixture-truth-revival.ts b/tests/fixture-truth-revival.ts
index ae1ca91d..21277bd2 100644
--- a/tests/fixture-truth-revival.ts
+++ b/tests/fixture-truth-revival.ts
@@ -50,7 +50,7 @@ export function finishTruthRevival(game: Game, actions: TruthRevivalStep[] = [])
* Only unopened deal/Spice positions and scalar lotteries are selected; no
* played hand, purse, army, Tanks, clock, question or promise is installed. */
export function createTruthRevivalFixture(options: {
- advanced?: boolean; window?: 'active' | 'waiting'; targetGhola?: boolean; stopper?: boolean;
+ advanced?: boolean; window?: 'active' | 'waiting'; targetGhola?: boolean; targetKarama?: boolean; stopper?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'active', stopper = options.stopper ?? false;
assert.ok(!stopper || advanced, 'The native Tleilaxu special Karama witness uses its actual Advanced power');
@@ -73,8 +73,8 @@ export function createTruthRevivalFixture(options: {
const originalCards = truthRevivalInventory(game); assert.equal(originalCards.length, stopper ? 47 : 33);
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
- const targetCard = game.deck.find(card => options.targetGhola ? card.effect === 'ghola' : card.kind === 'shield');
- const karama = stopper ? game.deck.find(card => card.effect === 'karama') : null;
+ const targetCard = game.deck.find(card => options.targetGhola ? card.effect === 'ghola' : options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
+ const karama = stopper ? game.deck.find(card => card.effect === 'karama' && card.id !== targetCard?.id) : null;
assert.ok(filler && targetCard && (!stopper || karama));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(karama ? [karama.id] : [])];
const dealOrder = deal.map((id, position) => {
@@ -126,6 +126,7 @@ export function createTruthRevivalFixture(options: {
advanced, window, stopper: stopper ? 'tleilaxu' : null, asker, opponent, target, actor: asker,
truths: truths.map(card => card.id), truth: truths[0].id, card: truths[0].id,
ghola: options.targetGhola ? targetCard.id : null, karama: karama?.id ?? null, special,
+ auctionKarama: options.targetKarama ? targetCard.id : null,
destination: destination.id, sector, location, elite, shipmentAction, shipment, continuation, storm, arrival, opened,
quote, question, declarationAction: { type: 'card', card: truths[0].id } as Action,
questionAction: { type: 'truthAsk', question } as Action, revivalAction: { type: 'revive', amount: 3, elite } as Action,
diff --git a/tests/truth-revival-controls.test.tsx b/tests/truth-revival-controls.test.tsx
index 7894f9a9..853d0f78 100644
--- a/tests/truth-revival-controls.test.tsx
+++ b/tests/truth-revival-controls.test.tsx
@@ -61,7 +61,7 @@ function projected(action: 'revive' | 'ready' = 'revive', advanced = false): Gam
game.active = advanced ? 'p' : null;
game.revivalPromises = [{ event: 'controlled-revival-event', player: 'p', asker: 'a', turn: 2,
counter: 'total', minimum: 3, answer: action === 'revive', progress: 1 }];
- game.revivalPromiseGuide = { action: action === 'revive'
+ game.revivalPromiseGuide = { phase: 4, action: action === 'revive'
? { type: 'revive', amount: 2, elite: 0, freeElite: 0, revivalPromiseEvent: 'controlled-revival-event' }
: { type: 'ready', revivalPromiseEvent: 'controlled-revival-event' } };
return game;
@@ -129,7 +129,7 @@ void test('stale Revival guides guard readiness, current ordered actor, event, q
game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; },
game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; },
game => { game.revivalOrder = null; }, game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; },
- game => { game.revivalPromiseGuide = { action: { ...game.revivalPromiseGuide!.action } }; },
+ game => { game.revivalPromiseGuide = { phase: 4, action: { ...game.revivalPromiseGuide!.action } }; },
game => { game.revivalPromiseGuide = null; }, game => { game.revivalPromiseGuide!.action.amount = 3; },
game => { game.revivalPromises[0].event = 'another-event'; }, game => { game.revivalPromises[0].player = 'a'; },
game => { game.revivalPromises[0].fulfilled = true; }, game => { game.revivalPromises[0].released = true; },
diff --git a/tests/truth-revival-early-controls.test.tsx b/tests/truth-revival-early-controls.test.tsx
new file mode 100644
index 00000000..bd78f610
--- /dev/null
+++ b/tests/truth-revival-early-controls.test.tsx
@@ -0,0 +1,178 @@
+import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { RevivalPromises, RevivalPromiseGuideButton } from '../components/revival-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { createTechTokens } from '../game/tech-tokens';
+import {
+ actTruthRevivalEarly, advanceTruthRevivalEarly, bindTruthRevivalEarly, completeTruthRevivalEarly,
+ createTruthRevivalEarlyFixture, settleTruthRevivalEarly, truthRevivalEarlyClean, truthRevivalEarlyInventory,
+} from './fixture-truth-revival-early';
+
+type Props = Parameters[0];
+function guide(props: Props) { const button = RevivalPromiseGuideButton(props); assert.ok(button); return button; }
+const press = (button: ReturnType) => button.props.onClick();
+const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(RevivalPromises, { game, busy, send() {} }));
+
+/** Controlled personalized preparation quotes only. The native source below
+ * separately earns its Tanks, Truthtrance answer and preparation journey. */
+function projected(phase: number, type: 'charity' | 'ready' | 'passBid' | 'decision' = 'charity'): GameView {
+ const source = createGame('EARLYREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
+ joinGame(source, newPlayer('a', 'Asker', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = [];
+ game.revivalPromises = [{ event: 'early-revival-event', player: 'p', asker: 'a', turn: 2,
+ counter: 'total', minimum: 3, answer: true, progress: 0 }];
+ game.auction = phase === 3 ? { bid: 7, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p',
+ remaining: 2, inspectionBlocked: null, card: null } : null;
+ game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null;
+ game.revivalPromiseGuide = { phase, action: { type, revivalPromiseEvent: 'early-revival-event',
+ ...(type === 'decision' ? { karama: true, card: 'original-projected-karama' } : {}) } };
+ return game;
+}
+
+void test('early Revival guidance stays visible in quiet Storm and Spice without inventing an immediate action', () => {
+ for (const phase of [0, 1]) {
+ const game = projected(phase); game.revivalPromiseGuide = null;
+ const html = markup(game);
+ assert.match(html, /Your Revival promises/); assert.match(html, /Your Revival opportunity is still ahead/);
+ assert.match(html, /continue the current phase and keep the required spice and forces available/);
+ assert.match(html, /These steps preserve your later Revival; they do not count as returned forces/);
+ assert.equal(RevivalPromiseGuideButton({ game, busy: false, send() {} }), null);
+ // Even a stale supplied preparation cannot create a Storm/Spice action.
+ game.revivalPromiseGuide = { phase, action: { type: 'ready', revivalPromiseEvent: 'early-revival-event' } };
+ const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, []);
+ }
+});
+
+void test('actual phase-two Charity and Ready callbacks forward the current preparation quote and preserve zero return progress', () => {
+ for (const type of ['charity', 'ready'] as const) {
+ const game = projected(2, type), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); assert.equal(button.props.disabled, false); press(button);
+ assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
+ assert.equal(game.revivalPromises[0].progress, 0);
+ assert.match(markup(game), type === 'charity' ? /Claim Charity for your later Revival/ : /Finish Charity while preserving your Revival answers/);
+ press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
+ game.ready = ['p']; press(button); assert.equal(sent.length, 1);
+ }
+});
+
+void test('actual phase-three pass and owned auction-payment callbacks retain original funding and Karama packets', () => {
+ for (const type of ['passBid', 'decision'] as const) {
+ const game = projected(3, type), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); assert.equal(button.props.disabled, false); press(button);
+ assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
+ assert.equal(game.revivalPromises[0].progress, 0);
+ if (type === 'decision') {
+ assert.equal(game.auction!.active, 'a', 'The owned payment decision, rather than the next bidder, owns this action.');
+ assert.equal(sent[0].card, 'original-projected-karama'); assert.equal(sent[0].karama, true);
+ assert.match(markup(game), /Use the quoted Karama auction payment for your later Revival/);
+ assert.doesNotMatch(markup(game), /original-projected-karama/);
+ } else assert.match(markup(game), /Pass this bid while preserving your Revival answers/);
+ press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
+ }
+ const paid = projected(3, 'decision'); paid.revivalPromiseGuide!.action = {
+ type: 'decision', karama: false, revivalPromiseEvent: 'early-revival-event',
+ };
+ assert.match(markup(paid), /Pay the current auction price while preserving your Revival answers/);
+});
+
+void test('early guide callbacks reject changed phase, actor, parent payment, event, quote and interrupted windows', () => {
+ const common: ((game: GameView) => void)[] = [
+ game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; },
+ game => { game.revivalPromiseGuide!.phase = 4; },
+ game => { game.revivalPromiseGuide = { phase: game.phase, action: { ...game.revivalPromiseGuide!.action } }; },
+ game => { game.revivalPromiseGuide = null; }, game => { game.revivalPromises[0].event = 'expired'; },
+ game => { game.revivalPromises[0].fulfilled = true; }, game => { game.revivalPromises[0].released = true; },
+ game => { game.phaseOpening = { passed: [] }; },
+ game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
+ game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
+ game => { game.automaticContinuationPending = true; },
+ game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
+ game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.techTokens = createTechTokens(); },
+ ];
+ for (const type of ['passBid', 'decision'] as const) {
+ const specific: ((game: GameView) => void)[] = type === 'decision' ? [
+ game => { game.decision = null; }, game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
+ game => { game.decision = { kind: 'revivalStop', player: 'p', recipient: 'a', revival: 'forces' }; },
+ game => { game.revivalPromiseGuide!.action.card = 'another-card'; },
+ ] : [game => { game.auction!.active = 'a'; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }];
+ for (const change of [...common, ...specific]) {
+ const game = projected(3, type), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
+ }
+ }
+ const props = { game: projected(2), busy: false, send() { assert.fail('A now-busy preparation cannot dispatch'); } };
+ const button = guide(props); props.busy = true; press(button);
+ for (const [phase, type] of [[2, 'passBid'], [2, 'decision'], [3, 'ready']] as const) {
+ const game = projected(phase, type), sent: Action[] = [];
+ press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
+ }
+});
+
+void test('preparation guidance never opens private holdings or the unrevealed original auction card', () => {
+ for (const type of ['passBid', 'decision'] as const) {
+ const game = projected(3, type), sent: Action[] = [];
+ for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'tanks', 'reserves'])
+ Object.defineProperty(seat, key, { get() { throw Error(`No unneeded private ${key}`); }, configurable: true });
+ Object.defineProperty(game.auction!, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
+ assert.match(markup(game), /Your Revival promises/);
+ press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [game.revivalPromiseGuide!.action]);
+ }
+});
+
+void test('genuine Basic and Advanced Charity-funded early answers follow actual Charity, pass and native-return callbacks', () => {
+ for (const advanced of [false, true]) {
+ const f = createTruthRevivalEarlyFixture({ advanced, phase: 2, poor: true });
+ let game = bindTruthRevivalEarly(f).game;
+ const dispatch = (phase: number, type: string) => {
+ const view = viewGame(game, f.target), sent: Action[] = [], next = view.revivalPromiseGuide;
+ assert.ok(next); assert.equal(next.phase, phase); assert.equal(next.action.type, type);
+ press(guide({ game: view, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [next.action]); game = actTruthRevivalEarly(game, f.target, sent[0]);
+ };
+ assert.match(markup(viewGame(game, f.target)), /Claim Charity for your later Revival/);
+ dispatch(2, 'charity');
+ assert.equal(game.players.find(player => player.id === f.target)!.spice, 2);
+ assert.equal(game.revivalPromises!.find(promise => promise.player === f.target)!.progress, 0);
+ dispatch(2, 'ready');
+ game = advanceTruthRevivalEarly(game, state => state.phase === 3 && truthRevivalEarlyClean(state) && state.auction?.active === f.target);
+ assert.match(markup(viewGame(game, f.target)), /Pass this bid while preserving your Revival answers/);
+ dispatch(3, 'passBid');
+ assert.equal(game.revivalPromises!.find(promise => promise.player === f.target)!.progress, 0);
+ game = completeTruthRevivalEarly(f, game).game;
+ dispatch(4, 'revive'); game = settleTruthRevivalEarly(game);
+ const promise = game.revivalPromises!.find(promise => promise.player === f.target)!;
+ assert.equal(promise.progress, 2); assert.equal(promise.fulfilled, true);
+ assert.deepEqual(truthRevivalEarlyInventory(game), f.originalCards);
+ }
+});
+
+void test('controlled native Richese zero/pass guides retain their event and owned opportunity without reading sealed bids', () => {
+ for (const method of ['silent', 'onceAround'] as const) {
+ const game = projected(3, 'passBid');
+ const native = createRicheseAuction({ event: 'native-controlled-lot', cardId: 'controlled-card', owner: 'a',
+ method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
+ game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
+ ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
+ game.revivalPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
+ amount: method === 'silent' ? 0 : null, allyPayment: 0, revivalPromiseEvent: 'early-revival-event' } };
+ const sent: Action[] = [], props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); assert.equal(button.props.disabled, false); press(button);
+ assert.deepEqual(sent, [game.revivalPromiseGuide.action]);
+ assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
+ game.richeseAuction.event = 'later-lot'; press(button); assert.equal(sent.length, 1);
+ game.richeseAuction.event = native.event;
+ if (method === 'silent') game.richeseAuction.submitted.push('p'); else game.richeseAuction.active = 'a';
+ press(button); assert.equal(sent.length, 1);
+ }
+});
diff --git a/tests/truth-revival-early-runtime.test.ts b/tests/truth-revival-early-runtime.test.ts
new file mode 100644
index 00000000..7f358a7c
--- /dev/null
+++ b/tests/truth-revival-early-runtime.test.ts
@@ -0,0 +1,209 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actTruthRevivalEarly as act, advanceTruthRevivalEarly as advance, bindTruthRevivalEarly as bind,
+ answerTruthRevivalEarly as answer, askTruthRevivalEarly as ask, declareTruthRevivalEarly as declare,
+ completeTruthRevivalEarly as complete, createTruthRevivalEarlyFixture as fixture,
+ finishTruthRevivalEarly as finish, settleTruthRevivalEarly as settle,
+ truthRevivalEarlyClean as clean, truthRevivalEarlyInventory as inventory, truthRevivalEarlyPlayer as player,
+} from './fixture-truth-revival-early';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+const promise = (game: Game, target: string) => game.revivalPromises!.find(promise => promise.player === target)!;
+
+for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced typed' : 'Basic'} post-Storm Truth Revival Yes survives actual Spice Charity and Bidding`, () => {
+ const f = fixture({ advanced }); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 1);
+ assert.deepEqual(f.originalPostStorm, f.base.storm.after);
+ assert.deepEqual(f.sourceActions, f.base.sourceActions.slice(0, f.postStormActionCount));
+ assert.equal(f.sourceActionCount, f.sourceActions.length); assert.deepEqual(f.staging, []);
+ assert.equal(player(f.game, f.target).tanks, 3); assert.equal(player(f.game, f.target).spice, 4);
+ let game = bind(f).game; assert.equal(promise(game, f.target).progress, 0);
+ assert.equal(viewGame(game, f.target).revivalPromiseGuide, null, 'Quiet Spice is not a fabricated owned guide opportunity');
+ const actual = complete(f, game); game = actual.game;
+ assert.ok(actual.actions.some(step => step.action.type === 'passBid'));
+ assert.equal(game.phase, 4); assert.equal(promise(game, f.target).progress, 0);
+ if (advanced) assert.equal(game.revivalOrder!.remaining[0], f.target);
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
+ game = settle(act(game, f.target, guide.action));
+ assert.equal(promise(game, f.target).progress, 3); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
+ assert.equal(player(game, f.target).freeForcesRevived, 1);
+ assert.equal(player(game, f.target).elites?.revived ?? 0, advanced ? 1 : 0);
+ assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native Storm-phase promise uses the next real Revival reset and excludes a prior-turn ordinary return', () => {
+ const f = fixture({ phase: 0, phase0PriorReturn: true });
+ assert.equal(f.game.turn, 3); assert.equal(f.game.phase, 0); assert.ok(f.priorReturn);
+ assert.equal(player(f.game, f.target).tanks, 2); assert.equal(player(f.game, f.target).revived, 1);
+ assert.equal(player(f.game, f.target).freeForcesRevived, 1);
+ let game = bind(f).game; assert.equal(promise(game, f.target).progress, 0);
+ game = complete(f, game).game;
+ assert.equal(player(game, f.target).revived, 0); assert.equal(player(game, f.target).freeForcesRevived, 0);
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 2);
+ game = act(game, f.target, guide.action);
+ assert.equal(promise(game, f.target).progress, 2); assert.equal(player(game, f.target).spice, 2);
+ assert.equal(player(game, f.target).freeForcesRevived, 1); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native Charity-funded early Yes requires its actual two-spice claim before owned readiness or spending', () => {
+ const f = fixture({ phase: 2, poor: true }); assert.ok(f.funding);
+ assert.equal(f.funding.steps[0].action.type, 'bribe'); assert.equal(player(f.game, f.target).spice, 0);
+ assert.equal(player(f.game, f.asker).bribes, 4);
+ let game = bind(f).game;
+ rejected(game, f.target, { type: 'ready' });
+ const charity = viewGame(game, f.target).revivalPromiseGuide; assert.ok(charity); assert.equal(charity.phase, 2);
+ assert.equal(charity.action.type, 'charity'); game = act(game, f.target, charity.action);
+ assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).progress, 0);
+ rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
+ game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
+ rejected(game, f.target, { type: 'bid', amount: 1 });
+ const bidGuide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(bidGuide); assert.equal(bidGuide.phase, 3);
+ assert.equal(bidGuide.action.type, 'passBid'); game = act(game, f.target, bidGuide.action);
+ game = complete(f, game).game;
+ const returned = viewGame(game, f.target).revivalPromiseGuide; assert.ok(returned); assert.equal(returned.phase, 4);
+ game = act(game, f.target, returned.action);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 1);
+ assert.equal(promise(game, f.target).progress, 2); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native early No remains binding through Bidding and limits the actual later ordinary return', () => {
+ const f = fixture(); let game = bind(f, 'no', 2).game;
+ game = complete(f, game).game;
+ game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
+ assert.equal(promise(game, f.target).progress, 1);
+ rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready');
+ game = act(game, f.target, guide.action); game = finish(game);
+ assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).tanks, 2);
+ assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native early Yes reserves its paid return funding against a new owned auction bid', () => {
+ const f = fixture({ phase: 3 }); let game = bind(f).game;
+ game = advance(game, state => clean(state) && state.auction?.active === f.target);
+ assert.equal(player(game, f.target).spice, 4);
+ rejected(game, f.target, { type: 'bid', amount: 1 });
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
+ assert.equal(guide.phase, 3); assert.equal(guide.action.type, 'passBid');
+ game = act(game, f.target, guide.action); game = complete(f, game).game;
+ assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native existing winning bid and its real payment reserve the early Revival invoice once', () => {
+ const f = fixture({ phase: 3 }); let game = f.game;
+ game = advance(game, state => clean(state) && state.auction?.active === f.target);
+ const card = game.auction!.cards[game.auction!.index].id;
+ game = act(game, f.target, { type: 'bid', amount: 2 });
+ assert.equal(game.auction!.bidder, f.target); assert.equal(player(game, f.target).spice, 4);
+ game = bind(f, 'yes', 2, 'total', game).game;
+ assert.equal(promise(game, f.target).progress, 0);
+ game = complete(f, game).game;
+ assert.equal(player(game, f.target).spice, 2); assert.ok(player(game, f.target).hand.some(held => held.id === card));
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 2);
+ game = act(game, f.target, guide.action);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game, f.target).progress, 2);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native owned Karama auction-payment guide preserves the actual funds for the earlier Revival Yes', () => {
+ const f = fixture({ phase: 3, targetKarama: true }); assert.ok(f.auctionKarama);
+ let game = bind(f).game;
+ game = advance(game, state => clean(state) && state.auction?.active === f.target);
+ game = act(game, f.target, { type: 'bid', amount: 5 });
+ assert.equal(player(game, f.target).spice, 4);
+ game = advance(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'auctionPayment' && state.decision.player === f.target);
+ const payment = viewGame(game, f.target).revivalPromiseGuide; assert.ok(payment); assert.equal(payment.phase, 3);
+ assert.equal(payment.action.type, 'decision'); assert.equal(payment.action.karama, true);
+ assert.equal(payment.action.card, f.auctionKarama);
+ game = act(game, f.target, payment.action);
+ assert.equal(player(game, f.target).spice, 4); assert.equal(promise(game, f.target).progress, 0);
+ assert.equal(game.discard.filter(card => card.id === f.auctionKarama).length, 1);
+ game = complete(f, game).game;
+ const revival = viewGame(game, f.target).revivalPromiseGuide; assert.ok(revival); assert.equal(revival.phase, 4);
+ game = act(game, f.target, revival.action);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
+ assert.equal(promise(game, f.target).progress, 3); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native earlier-phase typed Yes releases only after a real later Tleilaxu prevention', () => {
+ const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game; assert.ok(f.stopper && f.special);
+ game = complete(f, game).game;
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
+ game = act(game, f.target, guide.action); assert.equal(game.decision?.kind, 'revivalStop');
+ assert.equal(promise(game, f.target).progress, 0);
+ game = settle(act(game, f.stopper, f.special));
+ assert.equal(promise(game, f.target).released, true); assert.equal(promise(game, f.target).progress, 0);
+ assert.equal(player(game, f.target).tanks, 3); assert.equal(player(game, f.target).spice, 4);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two genuine early Truth answers preserve both a funded bid and the later ordinary return', () => {
+ const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
+ let game = ask(declared.game, f.asker, f.target, 2); game = answer(game, f.target, 'yes');
+ game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
+ turn: game.turn, lot: game.auction!.index + 1, minimum: 1 } });
+ assert.ok(viewGame(game, f.target).truthBidAnswers!.includes('yes'));
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = advance(game, state => clean(state) && state.auction?.active === f.target);
+ assert.notEqual(viewGame(game, f.target).revivalPromiseGuide?.action.type, 'passBid');
+ const bid = viewGame(game, f.target).bidPromiseGuide; assert.ok(bid); assert.equal(bid.action.amount, 1);
+ game = act(game, f.target, bid.action); game = complete(f, game).game;
+ assert.equal(player(game, f.target).spice, 3); assert.equal(promise(game, f.target).progress, 0);
+ const revival = viewGame(game, f.target).revivalPromiseGuide; assert.ok(revival); assert.equal(revival.action.amount, 2);
+ game = act(game, f.target, revival.action);
+ assert.equal(player(game, f.target).spice, 1); assert.equal(promise(game, f.target).progress, 2);
+ assert.ok(game.bidPromises!.every(promise => promise.fulfilled || promise.released));
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('a genuine early Revival Yes cannot also promise an unfunded ordinary bid', () => {
+ const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
+ let game = ask(declared.game, f.asker, f.target, 3); game = answer(game, f.target, 'yes');
+ game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
+ turn: game.turn, lot: game.auction!.index + 1, minimum: 1 } });
+ assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
+ assert.equal(promise(game, f.target).answer, true); assert.equal(game.bidPromises![0].answer, false);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('a genuine earlier bid Yes excludes an ordinary Revival Yes that would consume its last bid funding', () => {
+ const f = fixture({ phase: 3 }), declared = declare(f, f.game, { copies: 2 });
+ let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
+ turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 1 } });
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = ask(game, f.asker, f.target, 3);
+ assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = answer(game, f.target, 'no');
+ assert.equal(promise(game, f.target).answer, false); assert.equal(game.bidPromises![0].answer, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native early guide acts only for its real Charity owner and cannot replay in a later phase', () => {
+ const f = fixture({ phase: 2, poor: true }); let game = bind(f).game;
+ const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 2);
+ assert.equal(viewGame(game, f.asker).revivalPromiseGuide, null);
+ rejected(game, f.asker, guide.action);
+ game = act(game, f.target, guide.action); game = complete(f, game).game;
+ rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('all four minimal policies submit the actual needed Charity before the early Revival return', () => {
+ const f = fixture({ phase: 2, poor: true }), game = bind(f).game;
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'charity'); assert.ok(action, difficulty);
+ const accepted = act(structuredClone(game), f.target, action);
+ assert.equal(player(accepted, f.target).spice, 2); assert.equal(promise(accepted, f.target).progress, 0);
+ assert.deepEqual(inventory(accepted), f.originalCards);
+ }
+});