+ Provisional grouping: finish your force, leader and Kwisatz Haderach revival
+ choices together before ending your turn. Revival turns follow the printed
+ storm order for forces. Tleilaxu can still answer another player’s requests.
+
+ {blocked && }
+
+ ;
+}
diff --git a/docs/ADVANCED_REVIVAL_ORDER.md b/docs/ADVANCED_REVIVAL_ORDER.md
new file mode 100644
index 00000000..bc95c51d
--- /dev/null
+++ b/docs/ADVANCED_REVIVAL_ORDER.md
@@ -0,0 +1,89 @@
+# Advanced Revival player order
+
+26 October 2026. **Prototyped / Partial.** This is one core source cutover,
+not complete Advanced or optional-module certification.
+
+## Source and interpretation
+
+The user-authorized `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`,
+physical9/printed7, expressly starts **Force Revival** with the First Player
+and proceeds counterclockwise. It is the supplied unofficial Advanced authority
+bound by the [source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026),
+not a newly located publisher ruling. Reproduce the bounded read with:
+
+```sh
+pdftotext -f 9 -l 9 -layout UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf -
+```
+
+The adjacent Leader Revival paragraph says leader returns are in addition to
+force revival. This preview explicitly composes native force and leader/KH/
+foreign-Ghola choices into one participant opportunity. The source does not
+independently print a separate leader queue. Basic's publisher text supplies
+no matching Force Revival sequence, so Basic keeps concurrent readiness.
+
+## Connected behavior
+
+- A new Advanced Revival opens a version1 public order using the actual
+ First Player order already determined by Storm. Only its first remaining
+ participant can make ordinary self returns, negotiate/request/accept a native
+ leader return, or explicitly finish. Accepted native requests mark that
+ opportunity started; subsequent requests retain it.
+- Tleilaxu may quote or decline the current request and offer native limit/
+ discount grants outside its own opportunity. Fremen grants remain separate.
+ Emperor can pay for extra allied forces during the beneficiary's current
+ opportunity. The response remains owned by its original authorized actor.
+- A committed return and all required payment, income, card, placement and
+ arrival decisions finish before ordinary Finish. Answering a response does
+ not advance or consume the responder's own opportunity.
+- Finish withdraws any outstanding unaccepted own leader negotiation, adds
+ exactly that participant to the completed prefix and advances once. Completed
+ native turns cannot reopen through another return, Finish or an early
+ Truthtrance shipment/movement commitment.
+- Card effects, Nexus and special Karama retain their separate existing timing.
+ The guard targets native self actions rather than all actions in the phase.
+ Private future-shipment witnesses may still consider a later owned Revival
+ preparation; their policy waits for the real opportunity before proposing it.
+- Closing decisions retain the completed frame until the phase actually ends.
+ The next phase removes it. A phase already opened without the frame retains
+ its legacy readiness semantics; projection never manufactures a queue midway.
+
+`game/revival-order.ts` owns the immutable order calculation and validation;
+the engine owns phase lifecycle, transactions and projection. Controls use
+`components/revival-turn.tsx`, the existing native return buttons and commerce
+controls. Minimal AI follows the same projected availability. No action kind,
+database migration or saved-game conversion is added.
+
+## Evidence and remaining scope
+
+Focused cases are `tests/revival-order.test.ts`,
+`tests/advanced-revival-order-engine.test.ts` and
+`tests/advanced-revival-order-controls.test.tsx`. Their native source is
+`tests/fixture-advanced-revival-order.ts`: public E/A/T plus the original Ix
+deck, actual setup and first Revival; the return variant additionally earns
+Tanks through paid shipment and a later actual Storm. Controlled old-envelope
+and prior leader-death supplements are labelled separately.
+
+Frozen proof passes **12 files,105/105**, types/lint/build and **nine runtime/
+control paths** with13 GameTable SSR renders. Seven paths are native: first
+ordered Revival (41 source actions), played normal1 shipment for2 and actual
+later card-Storm casualty/free revival (99), Basic readiness (38), and four
+minimal policies with5 further actions each. Native47 Treachery identities,
+the returned ordinary counter and separate Tleilaxu bank1 remain. The other two
+paths are explicitly controlled old-envelope and leader-death supplements.
+
+Source-driver readiness and a stale Hajr test now use actual current Revival
+and immediate post-movement controls. Feedback also removes false later-Storm
+dials and mandatory uncontested-income acknowledgements, uses semantic status
+markup and narrows an optional test ledger. Application source is unchanged
+between successful `proof-v5` tests/types/lint/build and `proof-v6` runtime;
+only the private SSR loader needed its existing Vinext aliases/CSS mapping.
+Combined report and source manifest are in
+`/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`;
+journeys/HTML are adjacent `20261026-advanced-revival-order-runtime/`.
+This is SSR/selected-props callback evidence. No saved-room/D1/server, reset,
+restart, deployment or recovery operation occurs.
+
+Sapho's new Revival adapter, broader combinations, full rules review,
+save/recovery/privacy/custody assurance, browser/mobile and exact-revision
+deployed acceptance remain due. Existing games, the local PDF and hosting
+configuration are preserved. No mode/publication gate opens.
diff --git a/docs/BASE_ADVANCED_READINESS_20260907.md b/docs/BASE_ADVANCED_READINESS_20260907.md
index 49b9cc98..017d6db2 100644
--- a/docs/BASE_ADVANCED_READINESS_20260907.md
+++ b/docs/BASE_ADVANCED_READINESS_20260907.md
@@ -1,5 +1,12 @@
# Six-base-faction Advanced readiness audit
+**26 October follow-up:** supplied Advanced physical9/printed7 orders Force
+Revival from the First Player counterclockwise. A fresh-phase cutover now
+connects that native queue, controls and minimal policies, with explicit
+force/leader grouping and unchanged Basic/already-opened legacy timing.
+[Current contract and evidence](ADVANCED_REVIVAL_ORDER.md). This supersedes
+the original concurrent Revival behavior without certifying complete Advanced.
+
Read-only runtime audit, 7 September 2026. Scope is classic GF9 Advanced with any valid selection of the six original factions, two through six players, **no expansion and no optional module**. This document does not enable a mode or certify complete rules compliance. It reads the full active goal and preserves its wider twelve-faction scope.
## Finding
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index f35ad599..6c9bf60a 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,38 @@ open mode gates.
## Current checkpoint and work
+**Advanced Revival player order — 26 October. Prototyped / Partial.** Supplied
+Advanced physical9/printed7 explicitly orders Force Revival from the First
+Player counterclockwise. New Advanced phases now connect that native order,
+ordinary returns, negotiations and explicit Finish. Grouping native leader/KH/
+foreign-Ghola choices in the same opportunity is an explicit composition.
+Required responders remain authorized out of turn; Emperor-funded extras use
+the beneficiary's opportunity. Basic and an already-opened unstamped phase keep
+their readiness timing. Card/Nexus/special-Karama timing remains distinct.
+Persistent actor/remaining/completed controls and guarded Finish feed minimal
+policies, including a prevented Tleilaxu still answering another request.
+
+Three disjoint helper/native/control lanes and root integration pass **12 files,
+105/105**, types/lint/build and **nine runtime paths**,13 GameTable SSR renders
+and actual selected-props Finish callbacks. Seven paths are native: E/A/T +ix first
+Revival (41 source actions), actual paid normal1 shipment for2, next card Storm
+loss and turn-two free return (99), Basic readiness (38), and four minimal
+policies completing in5 further actions each. Native47 Treachery identities,
+ordinary return and separate Tleilaxu bank1 remain. Two labelled controlled
+old-envelope/leader-death paths supplement that played evidence.
+
+Feedback repairs stale source-driver readiness and Hajr timing, mistaken
+Storm-dial/compulsory-income-pause assumptions, semantic status markup and a
+nullable test ledger. Tests/types/lint/build pass in `proof-v5`; correcting only
+the private Vinext SSR loader produces runtime `proof-v6` on identical source.
+Combined report/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`;
+journeys/HTML: adjacent `20261026-advanced-revival-order-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
+SSR/callback proof does not establish browser/mobile/deployed or full-mode
+acceptance. Sapho's additional Revival adapter follows this native prerequisite;
+the full goal remains unfinished. [Source and contract](ADVANCED_REVIVAL_ORDER.md).
+
**Independent-effect delivery inventory — 26 October. Prototyped / Partial.**
A bounded read-only trace of the helpers, engine, owned controls and minimal
legal policies finds connected first versions for the following families:
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 6440921b..aa836b3f 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,35 @@
# Dune implementation status
+## 26 October 2026 — Advanced native Revival player order
+
+**Prototyped / Partial.** Adopt supplied Advanced physical9/printed7 First
+Player/counterclockwise Force Revival order for fresh phases. Native self
+returns, requester negotiations and explicit Finish use the current participant;
+leader/KH/foreign-Ghola grouping is visible implementation composition. Required
+support/response actors retain their permissions; Emperor-funded extras use the
+beneficiary's opportunity. Basic and already-opened unstamped phases retain
+readiness. Card/Nexus/special-Karama timing stays distinct. Controls show the
+public order and guard actual Finish; minimal policies preserve responder quotes
+even when their own returns are prevented. Early commitments cannot reopen
+completed native turns.
+
+Frozen **12-file105/105**, types/lint/build and **nine runtime/control paths**
+with13 GameTable SSR renders pass: seven native and two labelled controlled
+supplements. Genuine E/A/T +ix source programmes use41 first-Revival actions,
+99 through paid normal1 shipment2 and later card-Storm death/free return, and38
+for Basic. Four policies complete ordered Revival in5 further actions each.
+Native47 Treachery identities, one ordinary return and separate Tleilaxu bank1
+remain. Feedback repairs source-driver readiness, stale Hajr timing, source
+Storm/income assumptions, semantic markup and a nullable test ledger. The private
+SSR loader correction changes no application source; `proof-v5` checks and
+`proof-v6` runtime share the exact source tree.
+
+Combined report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-advanced-revival-order-proof-v6/`;
+journeys/HTML: adjacent `20261026-advanced-revival-order-runtime/`. No saved-room/
+D1/server, reset/restart, deployment or recovery operation. Browser/mobile,
+deployed and full-mode acceptance remain due; Sapho's additional adapter follows
+the native prerequisite. [Source and scope](ADVANCED_REVIVAL_ORDER.md).
+
## 26 October 2026 — Independent-effect delivery inventory
**Prototyped / Partial.** Three disjoint read-only traces inspect actual
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 837bad8a..3b1f56e1 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,22 @@
# Rule decision index
+## Advanced Revival player order — 26 October 2026
+
+Supplied authorized revision physical9/printed7 expressly orders **Force
+Revival** from the First Player counterclockwise. Newly opened Advanced
+Revival phases now use that source. Basic and an already-opened unstamped
+Advanced phase retain their prior readiness timing; no live conversion occurs.
+
+Keeping native leader/KH/foreign-Ghola choices and requester negotiations inside
+the same participant opportunity is an explicit implementation composition of
+the adjacent “in addition” leader paragraph. It is not separately printed
+whole-phase ordering. Native reactions/support remain with their authorized
+responder; Emperor-funded extra forces use the beneficiary's opportunity.
+Card effects, Nexus and special Karama retain distinct timing. Explicit Finish
+withdraws an outstanding own leader negotiation. Sapho's additional adapter
+follows separately; no full-mode gate opens.
+[Source, behavior and evidence](ADVANCED_REVIVAL_ORDER.md).
+
## Sandmaster flight and concealed-marker collection — 26 October 2026
Compose the moving-forces normal band with both physical Ornithopter modes,
diff --git a/game/bots.ts b/game/bots.ts
index 1e538605..264b25b0 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3189,8 +3189,6 @@ function policyActions(g: GameView): Action[] {
prevention.targets.some(choice => choice.player === player.id && !choice.blocked));
if (target) return [{ type: 'card', mode: 'special', card: prevention.cards[0], target: target.id }];
}
- if (g.revival.prevented)
- return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
const actions: Action[] = [];
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
const ordinaryLeaderAvailable = g.revival.leaders.some((l) => !l.early);
@@ -3365,13 +3363,21 @@ function policyActions(g: GameView): Action[] {
actions.push({ type: 'reviveLeader', leader: leader.id });
if (
!g.ready.includes(me.id) &&
- !(
+ (g.revival.prevented || !(
g.revivalRequests[me.id]?.price === null &&
!g.revivalRequests[me.id]?.declined
- )
+ ))
)
actions.push({ type: 'ready' });
- return actions;
+ return actions.filter(action => {
+ if (['revive','reviveLeader','reviveKwisatz','reviveForeignGhola','requestLeaderRevival',
+ 'acceptLeaderRevival','cancelLeaderRequest','ready'].includes(action.type))
+ return !g.revival.orderBlocked &&
+ (action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented);
+ if (action.type === 'emperorRevival' && g.revivalOrder)
+ return g.revivalOrder.remaining[0] === me.ally;
+ return true;
+ });
}
if (g.phase === 5) {
const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card));
diff --git a/game/engine.ts b/game/engine.ts
index c3b767eb..cbcc0116 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,4 +1,6 @@
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
+import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
+ finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order';
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
import { semutaAdvisorArrivalSupported } from './semuta-advisor-arrival';
@@ -1307,6 +1309,7 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
+ revivalOrder?: RevivalOrder;
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport;
pendingAutomaticAdvisorGamont?: { receipt: AutomaticAdvisorGamont; card: string; nexusEvent?: string };
@@ -17990,6 +17993,7 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
const current = completion.early?.find(step => step.phase === g.phase)?.action;
if (g.decision?.kind === 'auctionPayment')
return [current?.type === 'decision' ? current : { type: 'decision', karama: false }];
+ if (g.phase === 4 && nativeRevivalOrderBlock(g,player.id)) return [];
if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }];
return null;
}
@@ -17998,13 +18002,13 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
- const answerSource = g.phase === 2 || g.phase === 4 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
+ const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
- if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) {
+ if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
g.ready = g.ready.filter(id => id !== p.id);
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
}
@@ -23556,7 +23560,7 @@ function advancePhase(g: Game) {
requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.');
if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g));
commitPhaseResources(g, currentPhaseResources(g));
- g.ready = [];
+ if (!(g.phase === 4 && g.revivalOrder)) g.ready = [];
g.active = null;
const choam = byFaction(g, 'choam');
if (g.phase === 8 && choam) {
@@ -23595,6 +23599,10 @@ function finishEcazPlacement(g: Game) {
}
function completePhase(g: Game) {
if (closeNexusCards(g)) return;
+ if (g.phase === 4 && g.revivalOrder) {
+ delete g.revivalOrder;
+ g.ready = [];
+ }
if (g.phase === 6) {
delete g.battleOrder;
delete g.battleOrderUseEvents;
@@ -23632,6 +23640,33 @@ function openPhase(g: Game, initialize = true) {
if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize };
else if (initialize) beginPhase(g);
}
+function revivalOrderContext(g: Game) {
+ return {turn:g.turn,order:g.order,ready:g.ready};
+}
+function revivalOrderRule(calculate: () => T): T {
+ try { return calculate(); }
+ catch (error) {
+ if (error instanceof RevivalOrderError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function revivalOrderIntegrity(g: Game) {
+ if (!g.revivalOrder) return;
+ requireRule(g.advanced && g.status === 'playing' && g.phase === 4,
+ 'The ordered Revival belongs to its original Advanced phase.');
+ revivalOrderRule(() => validateRevivalOrder(revivalOrderContext(g),g.revivalOrder!));
+ requireRule(g.active === (g.revivalOrder.remaining[0] ?? null),
+ 'Revival must retain its current player through the complete opportunity.');
+}
+function nativeRevivalOrderBlock(g: Game, player: string): string | null {
+ return g.revivalOrder
+ ? revivalOrderRule(() => revivalOrderBlock(revivalOrderContext(g),g.revivalOrder!,player))
+ : null;
+}
+function startNativeRevivalOrder(g: Game, player: string) {
+ if (g.revivalOrder)
+ g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player));
+}
function beginPhase(g: Game) {
if (g.phase === 0) {
g.auditorInsight = null;
@@ -23661,6 +23696,11 @@ function beginPhase(g: Game) {
p.freeForcesRevived = 0;
p.leaderRevived = false;
}
+ if (g.advanced) {
+ g.revivalOrder = revivalOrderRule(() => createRevivalOrder(revivalOrderContext(g)));
+ g.active = g.revivalOrder.remaining[0] ?? null;
+ log(g, 'Advanced Force Revival proceeds from the First Player counterclockwise. Finish your Revival turn after your native force and leader choices; authorized responses and card timing remain available.');
+ }
}
if (g.phase === 3) setAuction(g);
if (g.phase === 5) {
@@ -26152,6 +26192,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
}
return revivalSeeds.map(seed => {
seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null;
+ delete seed.game.revivalOrder;
const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0;
return seed;
});
@@ -26356,6 +26397,7 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac
? [{type:'richeseBid',event:lot.event,amount:lot.method === 'silent' ? 0 : null,allyPayment:0}] : []};
return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] };
}
+ if (g.phase === 4 && nativeRevivalOrderBlock(g,p.id)) return {preparation:null,policy:[]};
if (g.phase === 2 || g.phase === 4)
return { preparation: current ? { actions: [current] } : null,
policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] };
@@ -26373,8 +26415,8 @@ function shipmentTruthAnswers(
...prior,
{ ...claim, turn: g.turn, player: p.id, asker: '', answer },
];
- // A new commitment reopens its respondent's owned Charity/Revival Ready.
- const answerSource = g.phase === 2 || g.phase === 4
+ // Concurrent legacy readiness can reopen; a completed ordered turn cannot.
+ const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
if (findShipmentCompletion(answerSource, p, promises))
answers.push(answer ? 'yes' : 'no');
@@ -26389,7 +26431,7 @@ function bindShipmentTruth(
asker: string,
mixed?: MixedShipmentExpression,
) {
- if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) {
+ if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
g.ready = g.ready.filter(id => id !== p.id);
log(g, `${p.name} has a new Truthtrance shipment commitment and must confirm readiness again after completing any needed preparation.`);
}
@@ -34289,6 +34331,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
if (state.spiceBankerIncomePreview) {
@@ -34541,6 +34584,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
arrivalOverlapIntegrity(g);
guildBetrayalIntegrity(g);
harkonnenNexusBetrayalIntegrity(g);
+ revivalOrderIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -37592,6 +37636,7 @@ function applyActionInner(
g.phase === 4,
'Extra revivals are available during Revival.',
);
+ if (execution === 'live') startNativeRevivalOrder(g,recipient.id);
}
const maximum =
t === 'emperorGift'
@@ -37700,6 +37745,8 @@ function applyActionInner(
'Tleilaxu leader negotiations require the Revival phase.',
);
g.revivalRequests ??= {};
+ if (execution === 'live' && t !== 'quoteLeaderRevival' && t !== 'declineLeaderRevival')
+ startNativeRevivalOrder(g,id);
if (t === 'cancelLeaderRequest') {
delete g.revivalRequests[id];
return g;
@@ -37783,6 +37830,7 @@ function applyActionInner(
g.advanced && g.phase === 4 && p.faction === 'tleilaxu',
'Foreign gholas require advanced Tleilaxu during Revival.',
);
+ if (execution === 'live') startNativeRevivalOrder(g,id);
requireRule(action.leader !== DUKE_VIDAL_ID,
'Only Ecaz may revive Duke Vidal, including with Ghola.');
const leader = g.players
@@ -37826,6 +37874,7 @@ function applyActionInner(
}
if (t === 'revive') {
requireRule(g.phase === 4, 'Revival is not the current phase.');
+ if (execution === 'live') startNativeRevivalOrder(g,id);
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
@@ -37876,6 +37925,7 @@ function applyActionInner(
g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'),
'Only one leader revival is permitted this phase.',
);
+ if (execution === 'live') startNativeRevivalOrder(g,id);
requireRule(
!revivalPrevented(g, p.id),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
@@ -38836,6 +38886,12 @@ function applyActionInner(
'Complete the current phase action first.',
);
requireRule(!g.ready.includes(id), 'You are already ready.');
+ if (g.phase === 4 && g.revivalOrder) {
+ g.revivalOrder = revivalOrderRule(() => finishRevivalOrder(revivalOrderContext(g),g.revivalOrder!,id));
+ g.active = g.revivalOrder.remaining[0] ?? null;
+ if (g.revivalRequests) delete g.revivalRequests[id];
+ log(g, `${p.name} finished their Revival turn.`,{faction:p.faction,name:'Revival turn complete'});
+ }
g.ready.push(id);
if (g.ready.length === g.players.length) {
if (g.phase === 0) startStormMovement(g, g.stormPending!);
@@ -38850,6 +38906,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
harkonnenNexusBetrayalIntegrity(state);
@@ -39711,7 +39768,9 @@ export function viewGame(state: Game, id: string) {
([player]) => player === id || me.faction === 'tleilaxu',
),
),
+ revivalOrder: g.revivalOrder ? structuredClone(g.revivalOrder) : null,
revival: {
+ orderBlocked: nativeRevivalOrderBlock(g,id),
pending: !!g.pendingRevival,
specialKaramaBlock: null,
leaders: leaderRevivals.leaders,
diff --git a/game/reference.ts b/game/reference.ts
index 6b0fb251..2cea850d 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -46,7 +46,7 @@ export const PHASE_HELP = [
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.',
'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
- 'Return eligible forces and leaders from the tanks to your reserves. Finish when your revival choices are complete.',
+ 'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases proceed from the First Player counterclockwise: finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.',
'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.',
'Forces collect spice in their own sector. Review the results, then continue to Mentat Pause.',
@@ -3776,6 +3776,30 @@ export const RULE_TOPICS: RuleTopic[] = [
'card-harvester',
],
},
+ {
+ id: 'advanced-revival-order',
+ title: 'Advanced Revival: player order',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Take native Revival choices from the First Player counterclockwise, then explicitly finish your turn.',
+ steps: [
+ 'A newly opened Advanced Revival follows the adopted revised rules: the First Player acts first, then the remaining players counterclockwise. The panel names the current player, remaining turns and completed turns.',
+ 'During your turn, choose ordinary forces, your eligible leader or Kwisatz Haderach, and any native leader negotiation. This preview groups native force and leader choices into one opportunity; the source explicitly orders Force Revival and describes leader returns in addition.',
+ 'Tleilaxu can answer another player’s request, grant a larger limit or offer an ally discount outside its own turn. Fremen can grant allied free revival. Emperor can fund its ally’s extra forces during that ally’s opportunity. Finish every accepted return and its required responses before ending the turn.',
+ 'Choose Finish Revival turn when done. An unanswered leader request is withdrawn by this explicit finish. Completed native turns stay finished. Card effects, Nexus cards, special Karama and their responses keep their separate timing; taking part in a response does not consume your own turn.',
+ 'Basic retains concurrent readiness. An already-opened older Advanced Revival finishes its saved readiness timing; the next new Advanced Revival uses player order. This does not redeal or restart a game.',
+ 'Sapho’s additional Revival-order adapter remains unfinished. Full Advanced games, optional combinations and final multiplayer/deployed acceptance remain open.',
+ ],
+ checklist: [
+ {area:'Implementation',status:'Partial',detail:'Fresh Advanced Revival has one current native opportunity, explicit completion and retained response ownership. Leader grouping is an explicit composition; Basic and unstamped current phases retain prior timing.',evidence:['game/revival-order.ts','game/engine.ts']},
+ {area:'Player controls',status:'Partial',detail:'Persistent current/remaining/completed order, guarded Finish and native-return/commerce availability use the projected opportunity.',evidence:['components/revival-turn.tsx','components/revival-commerce.tsx','components/game-table.tsx']},
+ {area:'AI',status:'Partial',detail:'Minimal policies wait for native self returns and Finish while retaining authorized responder and card actions. Strategy and calibration remain deferred.',evidence:['game/bots.ts']},
+ {area:'Documentation',status:'Partial',detail:'Supplied Advanced physical9/printed7 authority, explicit leader composition and fresh-phase cutover are recorded.',evidence:['docs/ADVANCED_REVIVAL_ORDER.md','docs/RULE_DECISIONS.md']},
+ {area:'Verification',status:'Partial',detail:'Focused helper, played native order/return and guarded control cases cover the changed path. Full-mode, browser/mobile, recovery and deployed acceptance remain due.',evidence:['tests/revival-order.test.ts','tests/advanced-revival-order-engine.test.ts','tests/advanced-revival-order-controls.test.tsx','tests/fixture-advanced-revival-order.ts']},
+ ],
+ related: ['advanced-storm-spice','tleilaxu-revival','choam-revival','juice-of-sapho'],
+ },
{
id: 'advanced-allied-separation',
title: 'Advanced alliances: ending shared occupation',
diff --git a/game/revival-order.ts b/game/revival-order.ts
new file mode 100644
index 00000000..9e68ab7a
--- /dev/null
+++ b/game/revival-order.ts
@@ -0,0 +1,91 @@
+/** One native Revival opportunity per faction in public First Player order.
+ * Leader decisions remain within that faction's opportunity; this grouping is
+ * an implementation composition of the supplied Advanced physical9/printed7. */
+export type RevivalOrder = {
+ version: 1;
+ turn: number;
+ original: string[];
+ remaining: string[];
+ completed: string[];
+ started: boolean;
+};
+export type RevivalOrderContext = {
+ turn: number;
+ order: readonly string[];
+ ready: readonly string[];
+};
+export class RevivalOrderError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RevivalOrderError';
+ }
+}
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value) &&
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
+const id = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
+const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(id);
+const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((value, index) => value === b[index]);
+function requireOrder(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new RevivalOrderError(message);
+}
+function validateContext(context: RevivalOrderContext): void {
+ requireOrder(record(context) && Number.isSafeInteger(context.turn) && context.turn > 0 &&
+ ids(context.order) && context.order.length >= 2 && context.order.length <= 6 &&
+ new Set(context.order).size === context.order.length,
+ 'Revival needs a current turn and two through six distinct factions in public order.');
+ requireOrder(ids(context.ready) && same(context.ready, context.order.slice(0, context.ready.length)),
+ 'Completed Revival factions must be the public-order prefix.');
+}
+function copy(frame: RevivalOrder): RevivalOrder {
+ return { version: 1, turn: frame.turn, original: [...frame.original],
+ remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started };
+}
+
+export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder {
+ validateContext(context);
+ return { version: 1, turn: context.turn, original: [...context.order],
+ remaining: context.order.slice(context.ready.length), completed: [...context.ready], started: false };
+}
+
+export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void {
+ validateContext(context);
+ requireOrder(record(frame) && Object.keys(frame).sort().join(',') ===
+ 'completed,original,remaining,started,turn,version' && frame.version === 1 &&
+ Number.isSafeInteger(frame.turn) && frame.turn === context.turn &&
+ ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean',
+ 'The current Revival order is malformed or belongs to another turn.');
+ requireOrder(same(frame.original, context.order) && same(frame.completed, context.ready) &&
+ same(frame.remaining, frame.original.slice(frame.completed.length)),
+ 'The Revival order must retain the original public order and its completed prefix.');
+ requireOrder(frame.remaining.length > 0 || !frame.started,
+ 'Completed Revival cannot retain a started opportunity.');
+}
+
+export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null {
+ validateRevivalOrder(context, frame);
+ requireOrder(id(player), 'Choose a valid Revival faction.');
+ if (!frame.original.includes(player)) return 'Only seated factions have a Revival opportunity.';
+ if (!frame.remaining.length) return 'Revival opportunities are complete.';
+ if (frame.completed.includes(player)) return 'Your Revival opportunity has finished.';
+ return frame.remaining[0] === player ? null : 'Wait for your Revival opportunity.';
+}
+
+/** Mark an accepted self request without consuming or reopening the opportunity.
+ * Further requests by the same current faction retain this idempotent stamp. */
+export function startRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder {
+ const blocked = revivalOrderBlock(context, frame, player);
+ requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.');
+ return { ...copy(frame), started: true };
+}
+
+/** Receives the pre-completion ready prefix. The caller installs this frame and
+ * appends the same current faction to ready in its one accepted transaction. */
+export function finishRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, player: string): RevivalOrder {
+ const blocked = revivalOrderBlock(context, frame, player);
+ requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.');
+ const next = { ...copy(frame), remaining: frame.remaining.slice(1),
+ completed: [...frame.completed, player], started: false };
+ validateRevivalOrder({ ...context, ready: [...context.ready, player] }, next);
+ return next;
+}
diff --git a/tests/advanced-revival-order-controls.test.tsx b/tests/advanced-revival-order-controls.test.tsx
new file mode 100644
index 00000000..46d68a92
--- /dev/null
+++ b/tests/advanced-revival-order-controls.test.tsx
@@ -0,0 +1,225 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { RevivalTurn, FinishRevivalTurn, finishRevivalTurnBlocked, revivalTurnOrderBlock } from '../components/revival-turn';
+import { RevivalCommerce } from '../components/revival-commerce';
+import { viewGame, type Action, type GameView } from '../game/engine';
+import { createAdvancedRevivalOrderFixture, actAdvancedRevivalOrder } from './fixture-advanced-revival-order';
+
+let original: ReturnType | undefined;
+function native() {
+ original ??= createAdvancedRevivalOrderFixture();
+ return { ...original, game: structuredClone(original.game) };
+}
+function projected() {
+ const source = native();
+ return viewGame(source.game, source.actor);
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(RevivalTurn, { game, busy, act() {} }));
+}
+function commerce(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(RevivalCommerce, { game, busy, act() {} }));
+}
+function button(html: string, label: string) {
+ const found = [...html.matchAll(/`));
+ assert.ok(found, `The ${label} button is visible`); return found;
+}
+function click(game: GameView, busy = false): Action[] {
+ const actions: Action[] = [];
+ FinishRevivalTurn({ game, busy, act(action) { actions.push(action); } }).props.onClick();
+ return actions;
+}
+function controlledCommerce(viewer: string, actor = viewer): GameView {
+ const source = native(), game = viewGame(source.game, viewer);
+ const originalOrder = [actor, ...game.players.map(player => player.id).filter(id => id !== actor)];
+ // Controlled personalized order/leader/negotiation cases supplement the
+ // native source below; they install no authoritative Game outcome or pieces.
+ game.revivalOrder = { version: 1, turn: game.turn, original: originalOrder,
+ remaining: [...originalOrder], completed: [], started: false };
+ game.active = actor;
+ game.revival.orderBlocked = actor === viewer ? null : 'Wait for the current Revival actor.';
+ return game;
+}
+
+void test('native first Revival renders persistent actor remaining completed and explicit force/leader grouping', () => {
+ const source = native(), game = viewGame(source.game, source.actor), before = structuredClone(game);
+ assert.deepEqual(game.revivalOrder!.original, source.originalOrder);
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.match(html, /aria-label="Revival turn order"/);
+ assert.ok(html.includes(`Current Revival actor: ${game.players.find(player => player.id === source.actor)!.name}.`));
+ assert.match(html, /Remaining Revival turns:/);
+ assert.match(html, /Completed Revival turns: None yet/);
+ for (const id of game.revivalOrder!.remaining) assert.ok(html.includes(game.players.find(player => player.id === id)!.name));
+ assert.match(html, /aria-label="Provisional Revival grouping"/);
+ assert.match(html, /force, leader and Kwisatz Haderach revival choices together/);
+ assert.match(html, /Tleilaxu can still answer another player’s requests/);
+ assert.doesNotMatch(button(html, 'Finish Revival turn'), /\sdisabled=/);
+ }
+ assert.deepEqual(game, before);
+});
+
+void test('the actual native Finish callback sends only ready and advances to the next original seat', () => {
+ const source = native(), game = viewGame(source.game, source.actor);
+ assert.deepEqual(click(game, true), []);
+ const actions = click(game);
+ assert.deepEqual(actions, [{ type: 'ready' }]);
+ const next = actAdvancedRevivalOrder(source.game, source.actor, actions[0]);
+ assert.equal(next.phase, 4);
+ assert.deepEqual(next.revivalOrder!.completed, [source.actor]);
+ assert.equal(next.active, next.revivalOrder!.remaining[0]);
+ const finished = viewGame(next, source.actor);
+ assert.match(markup(finished), /Your Revival turn is finished/);
+ assert.match(button(markup(finished), 'Finish Revival turn'), /\sdisabled=/);
+ assert.deepEqual(click(finished), []);
+ assert.deepEqual(click(viewGame(next, next.active!)), [{ type: 'ready' }]);
+});
+
+void test('each native seat can explicitly finish once and the last actual control leaves Revival', () => {
+ const source = native(); let game = source.game;
+ const actors: string[] = [];
+ while (game.phase === 4 && actors.length < source.originalOrder.length) {
+ const actor = game.revivalOrder!.remaining[0];
+ const actions = click(viewGame(game, actor));
+ assert.deepEqual(actions, [{ type: 'ready' }]);
+ actors.push(actor); game = actAdvancedRevivalOrder(game, actor, actions[0]);
+ }
+ assert.deepEqual(actors, source.originalOrder);
+ assert.equal(game.phase, 5);
+ assert.equal(markup(viewGame(game, source.owner)), '');
+});
+
+void test('genuine Basic and unstamped legacy Advanced views retain their existing controls without this component', () => {
+ const basic = createAdvancedRevivalOrderFixture({ advanced: false });
+ const basicView = viewGame(basic.game, basic.actor);
+ assert.equal(basicView.revivalOrder, null);
+ assert.equal(markup(basicView), '');
+ assert.deepEqual(click(basicView), []);
+ const legacy = projected(); legacy.revivalOrder = null;
+ assert.equal(markup(legacy), ''); assert.deepEqual(click(legacy), []);
+ assert.equal(revivalTurnOrderBlock(legacy), null);
+});
+
+void test('a different native viewer sees the current actor and a waiting reason while the actual callback is unavailable', () => {
+ const source = native(), game = viewGame(source.game, source.outOfTurn);
+ const html = markup(game);
+ assert.match(html, /Current Revival actor:/);
+ assert.ok(finishRevivalTurnBlocked(game));
+ assert.match(button(html, 'Finish Revival turn'), /\sdisabled=/);
+ assert.deepEqual(click(game), []);
+ game.revival.orderBlocked = '';
+ assert.deepEqual(click(game), [], 'Empty projected notices do not bypass the actual owner');
+});
+
+void test('busy finished and inconsistent current order projections guard the actual callback', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.revivalOrder!.completed.push(game.me); },
+ game => { game.revivalOrder!.remaining = []; },
+ game => { game.revivalOrder!.turn++; },
+ game => { game.turn++; }, game => { game.phase = 5; },
+ game => { game.status = 'finished'; }, game => { game.active = null; },
+ game => { game.revival.orderBlocked = 'The current native turn is unavailable.'; },
+ ];
+ for (const change of changes) {
+ const game = projected(); change(game);
+ assert.deepEqual(click(game), []);
+ assert.equal(FinishRevivalTurn({ game, busy: false, act() {} }).props.disabled, true);
+ }
+ assert.deepEqual(click(projected(), true), []);
+ const finished = projected(); finished.revivalOrder!.remaining = [];
+ assert.match(markup(finished), /All Revival turns are finished/);
+});
+
+void test('pending Revival response decision automatic action and owned autopilot prevent early finishing', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.response = { kind: 'revivalIncome', owner: game.me, passed: [] }; },
+ game => { game.decision = { kind: 'revivalStop', player: game.me, recipient: game.me, revival: 'forces' }; },
+ game => { game.truthtrance = {} as NonNullable; },
+ game => { game.phaseOpening = { passed: [] }; },
+ game => { game.revival.pending = true; },
+ game => { game.automaticContinuationPending = true; },
+ game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
+ ];
+ for (const change of changes) {
+ const game = projected(); change(game);
+ assert.deepEqual(click(game), []);
+ assert.match(button(markup(game), 'Finish Revival turn'), /\sdisabled=/);
+ }
+});
+
+void test('a stale actual callback cannot finish another actor or a new Revival round', () => {
+ for (const anotherRound of [false, true]) {
+ const game = projected(), actions: Action[] = [];
+ const leaf = FinishRevivalTurn({ game, busy: false, act(action) { actions.push(action); } });
+ assert.equal(leaf.props.disabled, false);
+ if (anotherRound) { game.turn++; game.revivalOrder!.turn++; }
+ else {
+ game.revivalOrder!.completed.push(game.me); game.revivalOrder!.remaining.shift();
+ game.active = game.revivalOrder!.remaining[0];
+ }
+ leaf.props.onClick(); assert.deepEqual(actions, []);
+ }
+});
+
+void test('an unaccepted negotiated request does not remove the current explicit Finish choice', () => {
+ const game = projected();
+ game.revivalRequests[game.me] = { leader: game.players.find(player => player.id === game.me)!.leaders[0].id, price: null };
+ assert.deepEqual(click(game), [{ type: 'ready' }]);
+ assert.doesNotMatch(button(markup(game), 'Finish Revival turn'), /\sdisabled=/);
+});
+
+void test('controlled native request accept and withdrawal controls follow own turn without disabling the entire commerce panel', () => {
+ const source = native();
+ for (const ownsTurn of [true, false]) {
+ const game = controlledCommerce(source.owner, ownsTurn ? source.owner : source.responder);
+ const me = game.players.find(player => player.id === game.me)!;
+ me.spice = 50; me.leaders[0].dead = true;
+ game.revivalRequests[game.me] = { leader: me.leaders[0].id, price: 2 };
+ const html = commerce(game);
+ for (const label of ['Accept revival price', 'Withdraw request']) {
+ if (ownsTurn) assert.doesNotMatch(button(html, label), /\sdisabled=/);
+ else assert.match(button(html, label), /\sdisabled=/);
+ }
+ delete game.revivalRequests[game.me];
+ const request = commerce(game);
+ const selector = request.match(/