From 660b0a0c258aeab6b3002a42a1f5ef6d527c1006 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sat, 24 Oct 2026 18:36:55 +1100
Subject: [PATCH] Open native advisor preparation before the first shipment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving
original phase/auction/readiness and native eligibility. Close a current-turn
receipt only on committed shipments, including marker and Sneak Attack entry.
Retain the legacy phase5 queue and qualify the per-territory prevention policy.
Connect persistent controls, minimal bot support and focused native evidence.
Complete modes and browser/deployed acceptance remain open.
---
components/advisor-preparation.tsx | 72 +++++++
components/game-table.tsx | 5 +
docs/ADVISOR_PREPARATION.md | 128 +++++++++++++
docs/CURRENT_STATUS.md | 27 ++-
docs/IMPLEMENTATION_STATUS.md | 26 +++
docs/RULE_DECISIONS.md | 15 ++
game/advisor-preparation-window.ts | 142 ++++++++++++++
game/bots.ts | 8 +
game/engine.ts | 89 ++++++++-
game/karama-context.ts | 4 +-
game/karama-movement-cancellation.ts | 2 +
game/movement-phase-quote.ts | 8 +-
game/reference.ts | 63 ++++++-
tests/advisor-preparation-controls.test.tsx | 158 ++++++++++++++++
tests/advisor-preparation-engine.test.ts | 113 +++++++++++
tests/advisor-preparation-window.test.ts | 198 ++++++++++++++++++++
tests/fixture-advisor-preparation.ts | 197 +++++++++++++++++++
tests/moritani-force-leader.test.ts | 27 +++
18 files changed, 1269 insertions(+), 13 deletions(-)
create mode 100644 components/advisor-preparation.tsx
create mode 100644 docs/ADVISOR_PREPARATION.md
create mode 100644 game/advisor-preparation-window.ts
create mode 100644 tests/advisor-preparation-controls.test.tsx
create mode 100644 tests/advisor-preparation-engine.test.ts
create mode 100644 tests/advisor-preparation-window.test.ts
create mode 100644 tests/fixture-advisor-preparation.ts
diff --git a/components/advisor-preparation.tsx b/components/advisor-preparation.tsx
new file mode 100644
index 00000000..cc4d0648
--- /dev/null
+++ b/components/advisor-preparation.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { ArrowRight } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { territory } from '@/game/board';
+import type { Action, GameView } from '@/game/engine';
+
+export function AdvisorPreparation({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const offer = game.advisorPreparation;
+ const me = game.players.find((player) => player.id === game.me);
+ if (!offer || game.status !== 'playing' || !game.advanced ||
+ game.phase < 2 || game.phase > 5 || me?.faction !== 'beneGesserit') return null;
+ const priorityPending = !!game.response || !!game.decision || !!game.phaseOpening;
+ const blocked = offer.blocked || (priorityPending
+ ? 'Finish the current response, decision or phase opening before preparing advisors.'
+ : null);
+ const disabled = busy || !!blocked || !!game.truthtrance || !!game.nexusTraitors?.pending;
+ return (
+
+ Prepare advisors for battle
+
+ After Spice Blow and any Nexus, you may prepare advisors until the first
+ actual shipment has occurred. This window includes Charity, Bidding, Revival
+ and the opening of Shipment and Movement.
+
+
+ Choose a territory to turn all your advisors there into fighters, across
+ every sector. Storm, ordinary allied occupation and fresh-advisor restrictions
+ still apply. The existing supported Ecaz co-side composition retains its
+ separate eligibility.
+
+
+ Provisional Karama policy: a canceled preparation blocks that territory
+ until this window ends. Other eligible territories remain available.
+ Intrusion, Nexus conversion and automatic advisor flips remain independent.
+
+
+ The ordinary advisor chooser during Shipment and Movement remains
+ available. Declining that choice does not close this preparation window.
+
+ {blocked && {blocked}
}
+ {!offer.territories.length && (
+ No advisor territory is currently eligible for preparation.
+ )}
+
+ {offer.territories.map((id) => (
+
{
+ if (!disabled) act({ type: 'prepareAdvisors', territory: id });
+ }}
+ >
+ Prepare advisors · {territory(id).name}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 77861b3e..ad0abfab 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -53,6 +53,7 @@ import { TerrorBoardMarkers } from './terror-board-markers';
import { AmbassadorBoardMarkers } from './ambassador-board-markers';
import { CharityClaim } from './charity-claim';
import { FremenRevivalGrant } from './fremen-revival-grant';
+import { AdvisorPreparation } from './advisor-preparation';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
@@ -1973,6 +1974,7 @@ export function GameTable({
{!reactionOwnsControls && <>
+
@@ -2548,6 +2550,9 @@ export function GameTable({
allow five forces.
)}
+ {g.response.kind === 'advisorFlip' && g.response.advisorResume === 'preparation' && (
+ Bene Gesserit declared native advisor preparation before the first shipment. Karama can prevent this territory’s flip. The current phase and its original auction, readiness or movement control continue afterward.
+ )}
{g.response.kind === 'fremenRevivalGrant' && (
Fremen offered their current ally the ordinary free-revival rate of three.
diff --git a/docs/ADVISOR_PREPARATION.md b/docs/ADVISOR_PREPARATION.md
new file mode 100644
index 00000000..07edc405
--- /dev/null
+++ b/docs/ADVISOR_PREPARATION.md
@@ -0,0 +1,128 @@
+# Native Bene Gesserit advisor preparation
+
+## Source timing and scope — 24 October 2026
+
+The authorized supplied revised rulebook physical p28/logical p26, Bene Gesserit
+G, permits the whole-territory advisor-to-fighter declaration after Spice Blow
+and Nexus and before any shipment has occurred. Its ordinary storm/allied
+restriction remains. The publisher base p18 also places the declaration after
+Spice Blow/Nexus and before shipment; November2020 FAQ p7 permits prevention of
+a token flip with Karama and says cancellation stops one use.
+[Publisher base](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf),
+[publisher FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf).
+The supplied source is used under its recorded authority, not republished or
+represented as a publisher erratum. Fresh indexed publisher passages corroborate
+the broad clock and cancellation. No new edition is adopted.
+
+The native control is now available in phases2–5 at a quiet priority boundary,
+including Charity, ordinary Bidding, Revival and after declining the initial
+Shipment/Movement advisor chooser. Mandatory responses/decisions, Truthtrance,
+phase openings and unfinished transactions retain their existing priority;
+preparation does not take over or reprice such a parent. Declining the ordinary
+chooser does not consume the printed window. Each chosen territory still flips
+all its native advisor forces across sectors; it is not a partial-force move.
+
+Reuse the existing native public-board quote for physical eligibility and
+mandatory alone-advisor releases. Current storm, fresh-advisor locks and ordinary
+allied-presence restrictions remain. The previously recorded supported Ecaz
+co-side composition retains its separate eligibility, per
+[the Advanced cutover](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
+No new Ecaz/allied exception is inferred from the timing change.
+
+Under the 14 October choose-recommended policy, a prevented territory stays
+unavailable for the remainder of this current pre-shipment window. Other eligible
+territories remain available. This retry scope is explicitly **provisional**:
+the general FAQ does not give a precise early-declaration/reopen protocol.
+Intrusion, Nexus conversion and automatic alone-advisor flips remain independent;
+this is not a whole-phase prohibition on the Bene Gesserit power.
+
+## Current-turn window and shipment commitment
+
+The optional Game receipt records current turn, first actual shipment identity/
+producer and denied territories. Actual entry into phase2 opens it once; phase5
+initialization must not reset it. A next-turn phase2 opening supplies a fresh
+window. The new control never infers an open legacy phase5 window from spent
+movement/shipment flags. An original pending native advisor chooser is itself
+an authoritative before-shipment control and can establish the receipt when
+its declaration is actually accepted; saved old responses retain their original
+continuation without a new source retrofit.
+
+Successful ordinary/No-Field/allied reserve shipment, native Guild transport,
+Homeworld shipment, Junction transport, Guild Ambassador physical/marker
+shipment and a positive Moritani Sneak Attack close the receipt after physical/payment validation and commitment,
+before income/arrival followups or movement-preparation early returns. Zero
+Sneak Attack forces do not close the window. A concealed
+zero No-Field is still a shipment. Proposals, prevention, ending an unshipped
+turn and ground movement do not close it. A worm-phase independent shipment
+before the end of Spice Blow/Nexus precedes this window. No stop, skipped
+allowance, card refund, force return or past shipment is invented by this clock.
+
+## Connected continuation and controls
+
+`game/advisor-preparation-window.ts` wraps the original offer with current
+source/window validation. The direct `prepareAdvisors` action saves native
+owner/turn/territory in the existing advisorFlip response. It validates before
+Karama cost and either outcome. The distinct preparation continuation leaves
+phase, readiness, auction and current movement control intact. It never calls
+movementTurn from an earlier phase. The original phase5 queue keeps its own
+continuation and shares current closed/denied filtering.
+
+`components/advisor-preparation.tsx` provides persistent owner-only guidance,
+territory buttons and blocked reasons without private balances/cards/counts.
+The normal response names the announced territory/flip. Bots reuse their
+existing native preparation preference against this same projected eligibility;
+strategy and difficulty calibration remain deferred. Receipt metadata does not
+open a start mode or require another acknowledgement.
+
+## Verification and remaining work
+
+Frozen proof passes **16 affected offline files229/229**, types/lint and build.
+Twelve actual new-control callbacks/gameplay/GameTable SSR journeys cover direct
+phase2/3/4 allow/cancel, phase5 decline/reopen, original phase5 declaration,
+the first actual paid shipment and ending a native turn without shipping.
+The original phase/auction/readiness/movement frame remains through direct
+preparation; the next genuine native control still works. Sixteen existing-
+profile own-view paths finish the response using16 legal proposals. Bots retain
+their existing preparation preference against a weak original advisor; no
+proactive strategy tuning, strength calibration or full games are claimed.
+
+The public three-seat Advanced BG/Atreides/Emperor chronology uses native
+prediction, Traitor choices and actual advisorSetup at occupied Arrakeen10.
+Only original scalar shuffle order, three unopened Treachery positions and two
+unopened ordinary Spice positions are selected. Emperor retains its originally
+dealt Karama. Native first Storm and both Spice blows lead to23/29/32/38/39
+source actions for the selected phase. No played phase, wallet, army, stance,
+clock or response receipt is assigned. Card/force quantities are preserved.
+
+Review identified the extra off-planet Sneak Attack producer. A separate
+controlled unit board uses actual ground entry, Terror reveal and amount0/1:
+the positive case first reproduces a missing closure, then passes with the hook
+before BG accompaniment; zero remains open. This is not an unstaged full-game
+Moritani source. Pure receipt cases cover all six producer tags; related native
+transport/marker cases retain their existing evidence. No all-combination clock
+certificate follows from those narrower cases.
+
+Feedback corrects an invalid zero-force ship payload by exercising real
+endMovement without shipment instead. The first frozen run passes228/229 and
+catches a real premature filter of saved future declaration choices. Restoring
+the original queue fixes the focused case: future choices are checked only when
+taken, without pre-accepting them or advancing movement. The second union passes
+229/229; types find a misspelled pending-transaction field and a missing test
+assertion. The final frozen batch passes every step after repair. Bounded
+source/continuation review otherwise finds no concrete blocker.
+
+Private artifacts:
+`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-preparation/`,
+including `frozen-integrated-3/report.json`, `runtime-smoke.json`, HTML and feedback.
+Verified base `0e3d6a250efec1cb85c1543e1501d8e726d78384`, source tree
+`82d0afb2a40105dd7a66ed7629aba580c680b70b2c48bfc20e0c79e5487fd2fb`
+(2706 nonignored files, identical before/after checks). Only checkpoint
+prose, reference strings and evidence paths follow proof; delivery records that
+reviewed overlay and pushed revision. The supplied PDF stays untracked.
+
+Framework aliases/CSS stubs mean callback/gameplay/SSR evidence, not visual
+browser or deployed acceptance. Complete modes, broader priority/optional and
+forced-flip cancellation interactions, comprehensive rules/recovery/privacy/
+custody assurance and final deployed acceptance remain unfinished. The denied-
+territory retry scope remains provisional. No server, saved-room/D1/reset/restart
+or deployment operation occurs here.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index dc7c12f8..fb9c7896 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,29 @@ open mode gates.
## Current checkpoint and work
+**Native advisor preparation — 24 October. Prototyped / Partial.**
+Advanced Bene Gesserit can now declare preparation at quiet boundaries in
+Charity, Bidding, Revival and before the first actual shipment, including after
+declining the initial phase5 chooser. The original stance quote and current
+owner/territory/turn source retain storm, fresh-advisor and ordinary allied
+restrictions plus the recorded Ecaz co-side composition. A turn receipt closes
+only on committed shipments, including zero No-Field markers and positive Sneak
+Attack. Proposals, prevention and unshipped turns do not close it. Cancellation
+provisionally denies only that territory for this remaining window; the direct
+continuation retains phase, auction, readiness and active movement controls.
+[Source, clock and bounded proof](ADVISOR_PREPARATION.md).
+
+Disjoint helper/source/controls lanes and bounded review pass **16 files229/229**,
+types/lint/build, twelve actual control/gameplay/GameTable SSR journeys and
+sixteen existing-profile response paths (16 accepted proposals). Genuine
+23/29/32/38/39-action public Advanced setup sources reach the selected controls.
+A controlled Moritani ground-entry case reproduces/fixes the missed positive
+Sneak Attack closure. Feedback replaces an invalid zero-shipment test with real
+endMovement, restores the saved declaration queue, and repairs two type issues.
+Complete modes, broader/forced-flip combinations, comprehensive/browser/deployed
+acceptance and full AI remain unfinished. No saved-room/D1/reset/restart/deployment
+operation.
+
**Fremen allied free revival — 24 October. Prototyped / Partial.**
The original owner/ally/turn grant now resolves Karama before commitment.
Prevention provisionally denies only that grant for the current Revival;
@@ -32,8 +55,8 @@ Genuine75–77 Basic/92–94 Advanced action sources earn Tanks in an actual bat
and form the alliance at a real Nexus. Feedback repairs guidance expectations,
two exhaustive kind registries and a fixture response label. No full-mode,
strategy, comprehensive/browser/deployed certificate or saved-room/D1/reset/
-restart/deployment operation. The next mapped native candidate is Advanced
-Bene Gesserit advisor preparation timing before the first shipment.
+restart/deployment operation. The native advisor follow-up above supersedes the
+previously mapped preparation timing gap.
**CHOAM Charity funding — 24 October. Prototyped / Partial.**
CHOAM's own eligible poverty claim now uses the Spice Bank; E2 names other
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 78b2f862..8adf31e3 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,31 @@
# Dune implementation status
+## 24 October 2026 — Native advisor preparation before first shipment
+
+Expose the supplied Advanced BG after-Spice/Nexus window in phases2–5 at quiet
+priority boundaries, with original public-board eligibility and owner/turn/
+territory-bound Karama validation. A separate continuation preserves phase,
+auction, readiness and active movement. Native phase5 declarations retain their
+saved future-choice queue and actual-choice validation; declining permits later
+manual preparation while the window remains open. Successful shipment commits
+close a turn receipt, including markers, cross/return/World/Junction transport,
+Ambassador shipping and positive Sneak Attack. Stops/skips/ground movement do
+not. Denied-territory retry scope stays provisional; existing Ecaz composition,
+Intrusion, Nexus and mandatory alone-advisor behavior remain separate.
+
+Disjoint helper/source/controls and bounded review pass16 files229/229,
+types/lint/build, twelve actual control/gameplay/GameTable SSR journeys and
+sixteen existing-profile response paths accepting16 proposals. Public Advanced
+BG/Atreides/Emperor setup with original advisor/Karama and native Storm/Spice
+supplies23/29/32/38/39 source actions. Controlled Moritani ground entry supplies
+separate positive/zero Sneak Attack evidence. Feedback corrects an invalid test
+payload, a real premature queue-filter regression, a priority-field typo and a
+fixture assertion. No rule is loosened to fit a fixture; full modes, broader
+priority/optional/forced-flip and comprehensive/browser/deployed gates remain.
+[Source, exact proof and limits](ADVISOR_PREPARATION.md).
+No strategy/calibration or saved-room/D1/reset/restart/deployment operation.
+Private proof:20261024-advisor-preparation/frozen-integrated-3.
+
## 24 October 2026 — Fremen allied revival response and late Recruits
Replace direct permission commitment with an original owner/ally/turn-bound
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index df59be51..c97fe15f 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,20 @@
# Rule decision index
+## Native advisor preparation window — 24 October 2026
+
+Apply supplied physical28 G and publisher base18 after-Spice/Nexus timing
+through the first actual shipment, rather than only phase5 initialization.
+A current-turn receipt closes at physical shipment commitment, including native
+transport, independent Ambassador shipping and positive Sneak Attack; proposals,
+stops and skipped
+allowances do not close it. A direct quiet-boundary preparation uses an original
+native owner/territory/turn Karama response without advancing the current phase.
+The 14 October selected retry policy denies only the prevented territory for
+this remaining window; Intrusion, Nexus and automatic flips remain separate.
+This retry scope is provisional. Preserve the already recorded supported Ecaz
+co-side composition rather than silently changing native eligibility.
+[Source, clock, continuation and limits](ADVISOR_PREPARATION.md#source-timing-and-scope--24-october-2026).
+
## Fremen allied revival response and later Recruits grant — 24 October 2026
Compose the base Fremen allied free rate with the general FAQ's cancelable
diff --git a/game/advisor-preparation-window.ts b/game/advisor-preparation-window.ts
new file mode 100644
index 00000000..9ee6889a
--- /dev/null
+++ b/game/advisor-preparation-window.ts
@@ -0,0 +1,142 @@
+import type { Game } from './engine';
+import type { AdvisorRelease } from './board-resolution-quote';
+import { quoteAdvisorBattleOffer } from './movement-phase-quote';
+
+export type AdvisorPreparationWindow = {
+ turn: number;
+ firstShipment: null | {
+ player: string;
+ producer: 'ordinary' | 'guildTransport' | 'homeworld' | 'junction' | 'ambassador' | 'sneakAttack';
+ };
+ deniedTerritories: string[];
+};
+
+export type AdvisorPreparationSource = {
+ turn: number;
+ owner: string;
+ territory: string;
+};
+
+export type AdvisorPreparationContext = Pick<
+ Game,
+ | 'status'
+ | 'phase'
+ | 'turn'
+ | 'advanced'
+ | 'order'
+ | 'players'
+ | 'storm'
+ | 'mobileStronghold'
+ | 'homeworlds'
+ | 'advisorPreparation'
+>;
+
+export class AdvisorPreparationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'AdvisorPreparationError';
+ }
+}
+
+const id = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0;
+const count = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) >= 0;
+const producers = ['ordinary', 'guildTransport', 'homeworld', 'junction', 'ambassador', 'sneakAttack'];
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+
+function windowBlock(g: AdvisorPreparationContext, owner: string): string | null {
+ if (g.status !== 'playing' || g.advanced !== true ||
+ !count(g.turn) || g.turn < 1 || !count(g.phase) || g.phase < 2 || g.phase > 5)
+ return 'Prepare native advisors after Spice Blow and Nexus, before the first shipment.';
+ if (!id(owner) || !Array.isArray(g.players) ||
+ g.players.filter(player => player.id === owner && player.faction === 'beneGesserit').length !== 1 ||
+ g.players.filter(player => player.id === owner).length !== 1 ||
+ g.players.filter(player => player.faction === 'beneGesserit').length !== 1)
+ return 'Advisor preparation needs the current native Bene Gesserit owner.';
+ const window = g.advisorPreparation;
+ if (window === undefined)
+ return 'The current advisor-preparation window has not opened.';
+ if (!record(window) || !count(window.turn) || window.turn < 1 ||
+ window.turn !== g.turn || !Array.isArray(window.deniedTerritories) ||
+ window.deniedTerritories.some(territory => !id(territory)) ||
+ new Set(window.deniedTerritories).size !== window.deniedTerritories.length)
+ return 'The advisor-preparation window has no valid current-turn receipt.';
+ if (window.firstShipment !== null) {
+ const shipment = window.firstShipment;
+ if (!record(shipment) || !id(shipment.player) ||
+ g.players.filter(player => player.id === shipment.player).length !== 1 ||
+ !producers.includes(shipment.producer))
+ return 'The advisor-preparation window has an invalid shipment receipt.';
+ return 'The first shipment has closed advisor preparation for this turn.';
+ }
+ return null;
+}
+
+/** Source p28 G's clock wraps the existing native public-board eligibility.
+ * The caller owns live priority, receipt opening/closure and stance mutation.
+ * An absent receipt never infers a clock from movement or shipment flags. */
+export function quoteAdvisorPreparationWindow(
+ g: AdvisorPreparationContext,
+ owner: string,
+): { blocked: string | null; territories: string[]; released: AdvisorRelease[] } {
+ const blocked = windowBlock(g, owner);
+ if (blocked) return { blocked, territories: [], released: [] };
+ try {
+ const offer = quoteAdvisorBattleOffer(g);
+ const territories = offer.territories.filter(
+ territory => !g.advisorPreparation!.deniedTerritories.includes(territory),
+ );
+ return {
+ blocked: territories.length ? null : 'No eligible advisors can prepare for battle now.' +
+ (g.advisorPreparation!.deniedTerritories.length ? ' Earlier Karama prevention still applies to its territory.' : ''),
+ territories,
+ released: offer.released,
+ };
+ } catch (error) {
+ return {
+ blocked: error instanceof Error ? error.message : 'The advisor-preparation board is invalid.',
+ territories: [],
+ released: [],
+ };
+ }
+}
+
+export function quoteAdvisorPreparation(
+ g: AdvisorPreparationContext,
+ owner: string,
+ territory: string,
+): AdvisorPreparationSource {
+ const quote = quoteAdvisorPreparationWindow(g, owner);
+ if (quote.blocked) throw new AdvisorPreparationError(quote.blocked);
+ if (!id(territory) || !quote.territories.includes(territory))
+ throw new AdvisorPreparationError('These advisors cannot prepare for battle in the current window.');
+ return { turn: g.turn, owner, territory };
+}
+
+/** Recheck the same current-turn whole-territory declaration before cost or
+ * either outcome; no board, denial or original shipment record is changed. */
+export function validateAdvisorPreparation(
+ g: AdvisorPreparationContext,
+ source: AdvisorPreparationSource,
+): void {
+ if (!record(source) || Object.keys(source).sort().join(',') !== 'owner,territory,turn' ||
+ !count(source.turn) || source.turn !== g.turn || !id(source.owner) || !id(source.territory))
+ throw new AdvisorPreparationError('Advisor preparation has lost its original source.');
+ quoteAdvisorPreparation(g, source.owner, source.territory);
+}
+
+/** Native owner-only choices, with no force counts, cards, wallet or receipt
+ * payload. Opponents use the original public response when one is declared. */
+export function projectAdvisorPreparationWindow(
+ g: AdvisorPreparationContext,
+ owner: string,
+): { blocked: string | null; territories: string[] } | null {
+ if (g.advanced !== true || !id(owner) || !Array.isArray(g.players) ||
+ g.players.filter(player => player.id === owner && player.faction === 'beneGesserit').length !== 1 ||
+ g.players.filter(player => player.id === owner).length !== 1)
+ return null;
+ const { blocked, territories } = quoteAdvisorPreparationWindow(g, owner);
+ return { blocked, territories };
+}
diff --git a/game/bots.ts b/game/bots.ts
index 24711922..ebc262cb 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3084,6 +3084,14 @@ function policyActions(g: GameView): Action[] {
}
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
}
+ // Reuse the native advisor-decision preference; eligibility comes only from
+ // the shared current-window projection, including first actual shipment.
+ if (level > 0 && g.advisorPreparation && !g.advisorPreparation.blocked) {
+ const territory = g.advisorPreparation.territories.find(t => g.players
+ .filter(p => p.id !== me.id && p.id !== me.ally)
+ .every(p => fighterCount(p, t) < countAt(me, t)));
+ if (territory) return [{ type: 'prepareAdvisors', territory }];
+ }
if (
g.phase === 2 &&
!me.charityClaimed &&
diff --git a/game/engine.ts b/game/engine.ts
index 76802dbe..d621fdb2 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -532,6 +532,7 @@ import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homewo
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
import { resolveBattleWeapons } from './effective-weapons';
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
+import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window';
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
import {
charityAmount,
@@ -1061,6 +1062,7 @@ type SpiceBankerIncomePayment = {
};
export type ResponseWindow = {
+ advisorPreparationSource?: AdvisorPreparationSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
bureaucratPayment?: BureaucratPaymentSource;
@@ -1080,7 +1082,7 @@ export type ResponseWindow = {
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
guildRateEvent?: string;
guildTransportEvent?: string;
- advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
+ advisorResume?: 'preparation' | 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry' | 'planetologistGather';
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
@@ -1256,6 +1258,7 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
+ advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
guildBetrayalPreview?: boolean;
@@ -14542,6 +14545,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
try {
+ if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
return quoteMovementCancellation(g, response);
} catch (error) {
if (error instanceof MovementCancellationError)
@@ -14710,6 +14714,7 @@ function validateKaramaUse(
'Karama cancels another faction’s power.',
);
karamaCanceledSource(g, use.response);
+ currentAdvisorPreparationResponse(g, use.response);
if (use.response.kind === 'fremenRevivalGrant') currentFremenRevivalGrant(g, use.response);
validateAuctionContinuationCancellation(
g,
@@ -18141,6 +18146,7 @@ function commitAmbassadorMarker(g: Game, order: GuildAmbassadorShipment & {marke
supplier.spice -= marker.ownerPayment;
p.spice -= marker.recipientPayment;
observeOccupation(g);
+ closeAdvisorPreparationOnShipment(g, p.id, 'ambassador');
delete entry.guildMarkerRequest;
entry.stage = 'arrival';
const guild = byFaction(g,'guild');
@@ -18168,6 +18174,7 @@ function commitAmbassadorShipment(g: Game, shipment: PendingShipment) {
if (p.elites) p.elites.reserves -= order.elite;
place(p, order.territory, order.sector, order.amount, order.elite);
observeOccupation(g);
+ if (order.amount > 0) closeAdvisorPreparationOnShipment(g, p.id, 'ambassador');
if (order.advisors) (p.advisors ??= {})[order.territory] ??= {};
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
entry.stage = 'arrival';
@@ -20123,6 +20130,7 @@ function decideTerror(g: Game, p: Player, action: Action) {
withdrawNativeReserves(g, p, amount, 0);
place(p, entry.territory, entry.sector, amount);
observeOccupation(g);
+ closeAdvisorPreparationOnShipment(g, p.id, 'sneakAttack');
// A positive Sneak Attack transfers Moritani's expressly off-planet
// reserves onto the board, so it is classified as an off-planet
// shipment for the Heighliners token. The token itself enforces its
@@ -20824,6 +20832,49 @@ function movementPhaseQuote(calculate: () => T): T {
throw error;
}
}
+function advisorPreparationRule(quote: () => T): T {
+ try { return quote(); }
+ catch (error) {
+ if (error instanceof AdvisorPreparationError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function openAdvisorPreparation(g: Game) {
+ if (g.advanced && byFaction(g, 'beneGesserit') && g.advisorPreparation?.turn !== g.turn)
+ g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
+}
+function closeAdvisorPreparationOnShipment(g: Game, player: string,
+ producer: NonNullable['producer']) {
+ if (!g.advanced || !byFaction(g, 'beneGesserit') || g.phase < 2 || g.phase > 5) return;
+ if (g.advisorPreparation?.turn !== g.turn)
+ g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
+ if (!g.advisorPreparation!.firstShipment)
+ g.advisorPreparation!.firstShipment = { player, producer };
+}
+function advisorPreparationPriorityBlock(g: Game): string | null {
+ return recruitsInteractionPending(g) || pendingNexusTraitors(g) || g.pendingShipment ||
+ g.pendingHomeworldShipment || g.pendingGuildTransport || g.pendingTerrorEntry ||
+ g.pendingNexusHarkonnenBetrayal || g.pendingNexusIxianBetrayal || g.pendingNexusIxianReplacement ||
+ g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingNexusEmperorPayment ||
+ g.pendingNexusEmperorSellerPurchase || g.pendingChoamMarketGhola || g.bureaucratPayments?.pending
+ ? 'Finish the current transaction or priority window before preparing advisors.' : null;
+}
+function advisorPreparationView(g: Game, viewer: string) {
+ const offer = projectAdvisorPreparationWindow(g, viewer);
+ return offer ? { ...offer, blocked: offer.blocked ?? advisorPreparationPriorityBlock(g) } : null;
+}
+function currentAdvisorPreparationResponse(g: Game, response: ResponseWindow) {
+ if (response.kind !== 'advisorFlip' || !response.advisorPreparationSource) {
+ requireRule(response.advisorResume !== 'preparation', 'The advisor preparation lost its original source.');
+ return null;
+ }
+ requireRule((response.advisorResume === 'preparation' || response.advisorResume === 'declaration') &&
+ response.owner === response.advisorPreparationSource.owner &&
+ response.location === response.advisorPreparationSource.territory && response.advisors === false,
+ 'The advisor preparation lost its original owner or territory.');
+ advisorPreparationRule(() => validateAdvisorPreparation(g, response.advisorPreparationSource!));
+ return response.advisorPreparationSource;
+}
function advisorBattleOptions(g: Game) {
const quote = movementPhaseQuote(() => quoteAdvisorBattleOffer(g));
applyAdvisorReleases(g, quote.released);
@@ -20863,11 +20914,7 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) {
const bg = byFaction(g, 'beneGesserit');
if (bg && remaining.length)
- g.decision = {
- kind: 'advisorBattle',
- player: bg.id,
- territories: remaining,
- };
+ g.decision = { kind: 'advisorBattle', player: bg.id, territories: remaining };
else movementTurn(g);
}
function finishAdvisorReaction(
@@ -20884,6 +20931,7 @@ function finishAdvisorReaction(
if (details.advisorFollowup && spiritualAdvisorMaximum(g, bg.id) > 0)
g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup };
else if (details.advisorResume === 'wormRide') nextWormRide(g);
+ else if (details.advisorResume === 'preparation') return;
else if (details.advisorResume === 'declaration')
beginAdvisorBattle(g, details.advisorRemaining ?? []);
else if (details.advisorResume === 'ambassador')
@@ -21242,6 +21290,7 @@ function beginPhase(g: Game) {
g.auditorInsight = null;
retireNexusChoamInsight(g);
}
+ if (g.phase === 2) openAdvisorPreparation(g);
if (g.phase === 2 && g.choamCharity?.turn !== g.turn) {
const choam = byFaction(g, 'choam');
if (choam && charityMultiplier(g) === 0) {
@@ -27136,6 +27185,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
+ const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
@@ -28016,6 +28066,12 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
} else {
if (canceled) movementCancellationQuote(g, response);
const to = response.location!;
+ if (canceled && advisorPreparationSource) {
+ if (!g.advisorPreparation!.deniedTerritories.includes(to))
+ g.advisorPreparation!.deniedTerritories.push(to);
+ log(g, `${bg.name}'s advisor preparation in ${territory(to).name} was prevented for the remaining pre-shipment window. Other native flips and the current phase continue normally.`,
+ { faction: bg.faction, name: 'Advisor preparation prevented' });
+ }
if (!canceled) {
bg.advisors ??= {};
if (response.advisors) bg.advisors[to] = {};
@@ -28493,6 +28549,7 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
} else if (p.advisors) delete p.advisors[arrival.territory];
}
p.shipped = true;
+ closeAdvisorPreparationOnShipment(g, p.id, 'junction');
if (declaration.forfeitPurchasedRate) g.karamaShipping = null;
g.junctionOffer = null;
finishShipmentPromises(g, p, promise);
@@ -28641,6 +28698,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
}
observeOccupation(g);
p.shipped = true;
+ closeAdvisorPreparationOnShipment(g, p.id, 'homeworld');
g.pendingHomeworldShipment = null;
// The E3 FAQ explicitly includes Homeworld shipment, including Fremen.
techIncome(g, 'heighliners', p);
@@ -29420,6 +29478,7 @@ function commitGuildTransport(
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
finishGuildCunningShipment(g, p.id, 'guild', frame);
p.shipped = true;
+ closeAdvisorPreparationOnShipment(g, p.id, 'guildTransport');
g.karamaShipping = null;
if (movementPreparation) return;
log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
@@ -29613,6 +29672,8 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
p.shipped = true;
+ if (n > 0 || shipment.noField || shipment.alliedNoField || shipment.smugglerCompanion)
+ closeAdvisorPreparationOnShipment(g, p.id, 'ordinary');
if (movementPreparation) { g.karamaShipping = null; return; }
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
const guild = byFaction(g, 'guild');
@@ -32355,6 +32416,18 @@ function applyActionInner(
offerDiplomatDefense(g);
return g;
}
+ if (t === 'prepareAdvisors') {
+ requireRule(Object.keys(action).sort().join(',') === 'territory,type',
+ 'Choose only the territory for native advisor preparation.');
+ const blocked = advisorPreparationPriorityBlock(g);
+ requireRule(!blocked, blocked ?? 'Finish the current interaction before preparing advisors.');
+ const source = advisorPreparationRule(() => quoteAdvisorPreparation(g, id, stringField(action.territory)));
+ const offer = quoteAdvisorPreparationWindow(g, id);
+ applyAdvisorReleases(g, offer.released);
+ g.response = { kind: 'advisorFlip', owner: id, location: source.territory,
+ advisors: false, passed: [], advisorResume: 'preparation', advisorPreparationSource: source };
+ return g;
+ }
if (t === 'battlePreparationReady') {
const b = g.battle,
opportunity = b?.preLeader;
@@ -33770,6 +33843,8 @@ function applyActionInner(
advisorBattleOptions(g).includes(to),
'These advisors cannot prepare for battle.',
);
+ openAdvisorPreparation(g);
+ const source = advisorPreparationRule(() => quoteAdvisorPreparation(g, id, to));
g.response = {
kind: 'advisorFlip',
owner: id,
@@ -33777,6 +33852,7 @@ function applyActionInner(
advisors: false,
passed: [],
advisorResume: 'declaration',
+ advisorPreparationSource: source,
advisorRemaining: decision.territories.filter((t) => t !== to),
};
} else movementTurn(g);
@@ -36823,6 +36899,7 @@ export function viewGame(state: Game, id: string) {
freeRevival: g.freeRevival ?? [],
recruitsPreview: recruitsPreview(g, me),
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
+ advisorPreparation: advisorPreparationView(g, id),
revivalRules: {
...(g.revivalRules ?? newRevivalRules()),
earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(
diff --git a/game/karama-context.ts b/game/karama-context.ts
index 4956dcd4..a67fdc56 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -179,7 +179,9 @@ const cancellationSources = {
checks: g.battle?.powerChecks,
}),
advisorFlip: (g, response) =>
- response.advisorResume === 'ambassador'
+ response.advisorPreparationSource
+ ? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
+ : response.advisorResume === 'ambassador'
? {
entry: g.pendingAmbassador ?? null,
ambassadors: g.ecazAmbassadors ?? null,
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index 89687f1c..4862a651 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -304,6 +304,8 @@ export function quoteMovementCancellation(
g: Game,
response: ResponseWindow,
): MovementCancellationQuote | null {
+ // This native pre-shipment action has a phase-independent source validator.
+ if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
if (
![
'guildTiming',
diff --git a/game/movement-phase-quote.ts b/game/movement-phase-quote.ts
index 2efe6e5b..9a854bc2 100644
--- a/game/movement-phase-quote.ts
+++ b/game/movement-phase-quote.ts
@@ -28,8 +28,9 @@ type Context = Pick<
| 'spiceDeck'
| 'spiceDiscard'
| 'homeworlds'
+ | 'advisorPreparation'
>;
-type AdvisorContext = BoardContext & { turn: number };
+type AdvisorContext = BoardContext & { turn: number } & Pick;
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && Number(n) >= 0;
function boardSeats(g: BoardContext, released: AdvisorRelease[]): BoardSeat[] {
@@ -95,7 +96,10 @@ export function quoteAdvisorBattleOffer(g: AdvisorContext) {
b.territory === t && (b.attacker === bg.id || b.defender === bg.id || b.coalition?.includes(bg.id)),
);
});
- return { released: board.released, owner: bg.id, territories };
+ const window = g.advisorPreparation?.turn === g.turn ? g.advisorPreparation : undefined;
+ return { released: board.released, owner: bg.id,
+ territories: window?.firstShipment ? [] : territories.filter(t => !window?.deniedTerritories.includes(t)) };
+
} catch (error) {
if (error instanceof MovementPhaseQuoteError) throw error;
throw new MovementPhaseQuoteError(
diff --git a/game/reference.ts b/game/reference.ts
index 40751c1b..0c4a58a5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4251,6 +4251,7 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Bene Gesserit advisors',
category: 'Advanced & expansions',
coverage: 'Partial',
+ developmentStage: 'Prototyped',
summary:
'Peaceful occupation, matching token types, intrusion and preparing for battle.',
steps: [
@@ -4261,10 +4262,68 @@ export const RULE_TOPICS: RuleTopic[] = [
'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
- 'Before the first shipment, choose eligible advisor territories to prepare for battle. Each chosen flip has a Karama response. Advisors cannot prepare against an ally or where storm prevents the battle.',
- 'The engine and these choices have focused tests. Advanced preview is available for the six classic factions; the full advisor timing audit, forced-flip cancellation and expansion interactions remain unfinished.',
+ 'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
+ 'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or forced-flip cancellation behavior.',
],
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
+ checklist: [
+ {
+ "area": "Implementation",
+ "status": "Partial",
+ "detail": "Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
+ "evidence": [
+ "game/advisor-preparation-window.ts",
+ "game/engine.ts",
+ "game/movement-phase-quote.ts",
+ "game/karama-context.ts",
+ "game/karama-movement-cancellation.ts"
+ ]
+ },
+ {
+ "area": "Player controls",
+ "status": "Partial",
+ "detail": "Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
+ "evidence": [
+ "components/advisor-preparation.tsx",
+ "components/game-table.tsx",
+ "tests/advisor-preparation-controls.test.tsx"
+ ]
+ },
+ {
+ "area": "AI",
+ "status": "Partial",
+ "detail": "The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
+ "evidence": [
+ "game/bots.ts",
+ "docs/ADVISOR_PREPARATION.md"
+ ]
+ },
+ {
+ "area": "Documentation",
+ "status": "Partial",
+ "detail": "Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
+ "evidence": [
+ "docs/ADVISOR_PREPARATION.md",
+ "docs/RULE_DECISIONS.md",
+ "docs/ECAZ_OCCUPY_RULES.md"
+ ]
+ },
+ {
+ "area": "Verification",
+ "status": "Partial",
+ "detail": "16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
+ "evidence": [
+ "tests/fixture-advisor-preparation.ts",
+ "tests/advisor-preparation-engine.test.ts",
+ "tests/advisor-preparation-window.test.ts",
+ "tests/advisor-preparation-controls.test.tsx",
+ "tests/moritani-force-leader.test.ts",
+ "tests/advisors.test.ts",
+ "tests/movement-phase-quote.test.ts",
+ "tests/karama-movement-cancellation.test.ts"
+ ]
+ }
+],
},
{
id: 'homeworlds',
diff --git a/tests/advisor-preparation-controls.test.tsx b/tests/advisor-preparation-controls.test.tsx
new file mode 100644
index 00000000..d334d2a1
--- /dev/null
+++ b/tests/advisor-preparation-controls.test.tsx
@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { AdvisorPreparation } from '../components/advisor-preparation';
+import {
+ createGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type GameView,
+} from '../game/engine';
+
+// Client-only public projections isolate persistent controls. The source suite
+// exercises actual advisor groups, window closure and Karama continuations.
+function projected(): GameView {
+ const game = createGame('ADVISORPREPARATIONCONTROLS', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), true);
+ game.players.push(newPlayer('a', 'Atreides', 'atreides'));
+ Object.assign(game, {
+ status: 'playing', phase: 2, turn: 3, order: ['b', 'a'],
+ });
+ const view = viewGame(game, 'b');
+ view.advisorPreparation = { blocked: null, territories: ['arrakeen', 'carthag'] };
+ return view;
+}
+
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(AdvisorPreparation, {
+ game, busy, act() {},
+ }));
+}
+
+function click(node: ReactNode, place: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{
+ children?: ReactNode;
+ onClick?: () => void;
+ 'data-testid'?: string;
+ }>(child)) continue;
+ if (child.props['data-testid'] === `advisor-preparation-${place}` && child.props.onClick) {
+ child.props.onClick();
+ return true;
+ }
+ if (click(child.props.children, place)) return true;
+ }
+ return false;
+}
+
+function prepare(game: GameView, place = 'arrakeen', busy = false): Action[] {
+ const actions: Action[] = [];
+ click(AdvisorPreparation({ game, busy, act(action) { actions.push(action); } }), place);
+ return actions;
+}
+
+void test('the native Advanced owner can prepare each projected territory with its exact original action', () => {
+ const game = projected();
+ const html = markup(game);
+ assert.match(html, /aria-label="Bene Gesserit advisor preparation"/);
+ assert.match(html, /Prepare advisors · Arrakeen/);
+ assert.match(html, /Prepare advisors · Carthag/);
+ assert.doesNotMatch(html, /disabled=""/);
+ assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
+ assert.deepEqual(prepare(game, 'carthag'), [{ type: 'prepareAdvisors', territory: 'carthag' }]);
+ assert.deepEqual(prepare(game, 'sietch_tabr'), []);
+});
+
+void test('the persistent guide names the printed window and keeps native choices and independent flips separate', () => {
+ const html = markup(projected());
+ assert.match(html, /After Spice Blow and any Nexus/);
+ assert.match(html, /until the first actual shipment has occurred/);
+ assert.match(html, /Charity, Bidding, Revival and the opening of Shipment and Movement/);
+ assert.match(html, /turn all your advisors there into fighters, across every sector/);
+ assert.match(html, /Storm, ordinary allied occupation and fresh-advisor restrictions\s*still apply/);
+ assert.match(html, /Provisional Karama policy: a canceled preparation blocks that territory until this window ends/);
+ assert.match(html, /Other eligible territories remain available/);
+ assert.match(html, /Intrusion, Nexus conversion and automatic advisor flips remain independent/);
+ assert.match(html, /ordinary advisor chooser during Shipment and Movement remains available/);
+ assert.match(html, /Declining that choice does not close this preparation window/);
+});
+
+void test('busy, Truthtrance and pending Nexus block the actual buttons without removing their guide', () => {
+ const game = projected();
+ assert.deepEqual(prepare(game, 'arrakeen', true), []);
+ assert.equal((markup(game, true).match(/]*\sdisabled=""/g) ?? []).length, 2);
+ const truthtrance = structuredClone(game);
+ truthtrance.truthtrance = {} as NonNullable;
+ assert.deepEqual(prepare(truthtrance), []);
+ const nexus = structuredClone(game);
+ nexus.nexusTraitors = {
+ offer: null,
+ pending: {} as NonNullable['pending']>,
+ };
+ assert.deepEqual(prepare(nexus), []);
+ assert.match(markup(nexus), /Provisional Karama policy/);
+});
+
+void test('current response, decision and phase-opening priority defensively prevent preparation', () => {
+ for (const field of ['response', 'decision', 'phaseOpening'] as const) {
+ const game = projected();
+ if (field === 'response') game.response = {} as NonNullable;
+ if (field === 'decision') game.decision = {} as NonNullable;
+ if (field === 'phaseOpening') game.phaseOpening = {} as NonNullable;
+ assert.deepEqual(prepare(game), [], field);
+ const html = markup(game);
+ assert.equal((html.match(/]*\sdisabled=""/g) ?? []).length, 2, field);
+ assert.match(html, /Finish the current response, decision or phase opening before preparing advisors/, field);
+ }
+});
+
+void test('projected blocks remain readable and authoritative across renders while unavailable territories never gain buttons', () => {
+ const game = projected();
+ game.advisorPreparation!.blocked = 'Finish the pending revival before preparing advisors.';
+ for (let render = 0; render < 3; render++) {
+ const html = markup(game);
+ assert.match(html, /Finish the pending revival before preparing advisors/);
+ assert.match(html, /aria-describedby="advisor-preparation-unavailable"/);
+ assert.match(html, /Provisional Karama policy/);
+ assert.deepEqual(prepare(game), []);
+ }
+ game.advisorPreparation = { blocked: null, territories: [] };
+ assert.match(markup(game), /No advisor territory is currently eligible for preparation/);
+ assert.doesNotMatch(markup(game), / {
+ for (const phase of [2, 3, 4, 5]) {
+ const game = projected();
+ game.phase = phase;
+ assert.match(markup(game), /Prepare advisors for battle/);
+ assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
+ }
+ for (const field of ['basic', 'viewer', 'faction', 'before', 'after', 'closed', 'status'] as const) {
+ const game = projected();
+ if (field === 'basic') game.advanced = false;
+ if (field === 'viewer') game.me = 'a';
+ if (field === 'faction') game.players[0].faction = 'atreides';
+ if (field === 'before') game.phase = 1;
+ if (field === 'after') game.phase = 6;
+ if (field === 'closed') game.advisorPreparation = null;
+ if (field === 'status') game.status = 'lobby';
+ assert.equal(markup(game), '', field);
+ assert.deepEqual(prepare(game), [], field);
+ }
+});
+
+void test('preparation controls never inspect either seat’s private wallets, cards or force counts', () => {
+ const game = projected();
+ for (const player of game.players) {
+ for (const field of ['spice', 'hand', 'forces', 'elites', 'advisors', 'tanks']) {
+ Object.defineProperty(player, field, {
+ get() { throw new Error(`Preparation read ${player.id} private ${field}`); },
+ });
+ }
+ }
+ assert.match(markup(game), /Prepare advisors · Arrakeen/);
+ assert.deepEqual(prepare(game), [{ type: 'prepareAdvisors', territory: 'arrakeen' }]);
+});
diff --git a/tests/advisor-preparation-engine.test.ts b/tests/advisor-preparation-engine.test.ts
new file mode 100644
index 00000000..3b1561ea
--- /dev/null
+++ b/tests/advisor-preparation-engine.test.ts
@@ -0,0 +1,113 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { viewGame } from '../game/engine';
+import { isAdvisor } from '../game/advisors';
+import {
+ createAdvisorPreparationFixture as fixture,
+ actAdvisorPreparation as act,
+ passAdvisorPreparationResponses as pass,
+ advisorPreparationPlayer as player,
+ advisorPreparationInventory as inventory,
+ shipAdvisorPreparation as ship,
+} from './fixture-advisor-preparation';
+
+for (const phase of [2, 3, 4] as const) {
+ void test(`native phase${phase} advisor preparation preserves the original phase and controls after allowance`, () => {
+ const f = fixture({ phase }), before = structuredClone(f.game);
+ assert.equal(isAdvisor(player(f.game, f.bg), 'arrakeen'), true);
+ assert.ok(viewGame(f.game, f.bg).advisorPreparation?.territories.includes('arrakeen'));
+ let g = act(f.game, f.bg, f.preparation);
+ assert.equal(g.response?.kind, 'advisorFlip');
+ assert.equal(g.response?.advisorResume, 'preparation');
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
+ assert.ok(viewGame(g, f.observer).responseControls?.cancelCards.includes(f.karama));
+ g = pass(g);
+ assert.equal(g.phase, phase);
+ assert.equal(g.active, before.active);
+ assert.deepEqual(g.auction, before.auction);
+ assert.deepEqual(g.ready, before.ready);
+ assert.deepEqual(g.movementRemaining, before.movementRemaining);
+ assert.deepEqual(g.players.map(p => [p.id, p.forces, p.reserves, p.spice, p.shipped, p.moved]),
+ before.players.map(p => [p.id, p.forces, p.reserves, p.spice, p.shipped, p.moved]));
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
+ assert.deepEqual(inventory(g), f.originalInventory);
+ assert.deepEqual(f.game, before);
+ });
+ void test(`native phase${phase} Karama prevents only that preparation without moving the interrupted phase`, () => {
+ const f = fixture({ phase });
+ let g = act(f.game, f.bg, f.preparation);
+ g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.ok(!g.response);
+ assert.equal(g.phase, phase);
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
+ assert.deepEqual(g.advisorPreparation?.deniedTerritories, ['arrakeen']);
+ assert.deepEqual(g.auction, f.game.auction);
+ assert.deepEqual(g.ready, f.game.ready);
+ assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
+ const before = structuredClone(g);
+ assert.throws(() => act(g, f.bg, f.preparation), /eligible|prevent/i);
+ assert.deepEqual(g, before);
+ assert.match(viewGame(g, f.bg).advisorPreparation?.blocked ?? '', /Karama/);
+ assert.deepEqual(inventory(g), f.originalInventory);
+ });
+}
+void test('declining the native phase5 chooser leaves the printed window open for a later declaration', () => {
+ const f = fixture({ phase: 5, declineOffer: true });
+ assert.equal(f.game.decision, null);
+ const active = f.game.active;
+ assert.ok(active);
+ const g = pass(act(f.game, f.bg, f.preparation));
+ assert.equal(g.phase, 5);
+ assert.equal(g.active, active);
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
+ assert.equal(g.advisorPreparation?.firstShipment, null);
+ assert.deepEqual(g.movementRemaining, f.game.movementRemaining);
+});
+void test('the first actual native shipment closes preparation before its arrival followups', () => {
+ const f = fixture({ phase: 5, declineOffer: true });
+ const shipped = ship(f.game), g = shipped.after;
+ assert.deepEqual(g.advisorPreparation?.firstShipment, { player: f.game.active, producer: 'ordinary' });
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
+ const before = structuredClone(g);
+ assert.throws(() => act(g, f.bg, f.preparation), /first shipment/);
+ assert.deepEqual(g, before);
+ assert.deepEqual(inventory(g), f.originalInventory);
+});
+void test('native canceled phase5 preparation cannot be bypassed through the new manual control', () => {
+ const f = fixture({ phase: 5 });
+ let g = act(f.game, f.bg, { type: 'decision', accept: true, territory: 'arrakeen' });
+ assert.ok(g.response?.advisorPreparationSource);
+ g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.ok(!g.response);
+ assert.equal(g.phase, 5);
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), true);
+ assert.deepEqual(g.advisorPreparation?.deniedTerritories, ['arrakeen']);
+ assert.throws(() => act(g, f.bg, f.preparation), /eligible|prevent/i);
+});
+void test('ending an unshipped native turn leaves the printed preparation window open', () => {
+ const f = fixture({ phase: 5, declineOffer: true });
+ assert.ok(f.game.active);
+ let g = act(f.game, f.game.active, { type: 'endMovement' });
+ assert.equal(g.phase, 5);
+ assert.equal(g.movementRemaining!.includes(f.game.active), false);
+ assert.equal(g.advisorPreparation?.firstShipment, null);
+ const active = g.active;
+ g = pass(act(g, f.bg, f.preparation));
+ assert.equal(g.active, active);
+ assert.equal(isAdvisor(player(g, f.bg), 'arrakeen'), false);
+});
+void test('a changed preparation source rejects before a real Karama is spent', () => {
+ const f = fixture();
+ for (const change of ['turn', 'territory', 'stance']) {
+ const g = act(f.game, f.bg, f.preparation);
+ assert.ok(g.response?.advisorPreparationSource);
+ if (change === 'turn') g.response.advisorPreparationSource.turn++;
+ if (change === 'territory') g.response.advisorPreparationSource.territory = 'carthag';
+ if (change === 'stance') g.response.advisors = true;
+ const before = structuredClone(g);
+ assert.throws(() => act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' }), /source|owner|territory/);
+ assert.deepEqual(g, before);
+ assert.ok(player(g, f.observer).hand.some(c => c.id === f.karama));
+ assert.equal(g.discard.some(c => c.id === f.karama), false);
+ }
+});
diff --git a/tests/advisor-preparation-window.test.ts b/tests/advisor-preparation-window.test.ts
new file mode 100644
index 00000000..51b91411
--- /dev/null
+++ b/tests/advisor-preparation-window.test.ts
@@ -0,0 +1,198 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, newPlayer, type Game } from '../game/engine';
+import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
+import {
+ AdvisorPreparationError,
+ projectAdvisorPreparationWindow,
+ quoteAdvisorPreparation,
+ quoteAdvisorPreparationWindow,
+ validateAdvisorPreparation,
+ type AdvisorPreparationSource,
+ type AdvisorPreparationWindow,
+} from '../game/advisor-preparation-window';
+
+const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
+function fixture(): Game {
+ const g = createGame('ADVISORWINDOW', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), true);
+ g.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'));
+ Object.assign(g, { status: 'playing', phase: 2, turn: 3, storm: 18, order: ['h', 'b', 'a'] });
+ for (const p of g.players) p.forces = {};
+ player(g, 'b').forces = { 'arrakeen:10': 2, 'carthag:11': 1, 'sietch_tabr:14': 1 };
+ player(g, 'b').advisors = { arrakeen: {}, carthag: {}, sietch_tabr: {} };
+ player(g, 'a').forces = { 'arrakeen:10': 2, 'carthag:11': 2 };
+ g.advisorPreparation = { turn: 3, firstShipment: null, deniedTerritories: [] };
+ return g;
+}
+
+function blocked(g: Game, owner = 'b') {
+ const before = structuredClone(g), quote = quoteAdvisorPreparationWindow(g, owner);
+ assert.equal(typeof quote.blocked, 'string');
+ assert.deepEqual(quote.territories, []);
+ assert.throws(() => quoteAdvisorPreparation(g, owner, 'arrakeen'), AdvisorPreparationError);
+ assert.deepEqual(g, before);
+}
+
+void test('each phase after Spice Blow through the first-shipment deadline reuses native eligibility and releases', () => {
+ for (const phase of [2, 3, 4, 5]) {
+ const g = fixture(); g.phase = phase;
+ const before = structuredClone(g), native = quoteAdvisorBattleOffer(g);
+ assert.deepEqual(quoteAdvisorPreparationWindow(g, 'b'), {
+ blocked: null, territories: native.territories, released: native.released,
+ });
+ assert.deepEqual(quoteAdvisorPreparation(g, 'b', 'arrakeen'), { turn: 3, owner: 'b', territory: 'arrakeen' });
+ assert.deepEqual(native.territories, ['arrakeen', 'carthag']);
+ assert.deepEqual(native.released, [{ player: 'b', territory: 'sietch_tabr' }]);
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('receipt absence, prior turns and stages outside the native Advanced window cannot infer a clock', () => {
+ for (const change of [
+ (g: Game) => { delete g.advisorPreparation; },
+ (g: Game) => { g.advisorPreparation!.turn--; },
+ (g: Game) => { g.advisorPreparation!.turn++; },
+ (g: Game) => { g.status = 'lobby'; },
+ (g: Game) => { g.advanced = false; },
+ (g: Game) => { g.phase = 1; },
+ (g: Game) => { g.phase = 6; },
+ (g: Game) => { g.phase = 2.5; },
+ (g: Game) => { g.turn = 0; },
+ ]) {
+ const g = fixture(); change(g); blocked(g);
+ }
+});
+
+void test('each actual shipment producer closes the window without consulting player movement or skip flags', () => {
+ const producers: NonNullable['producer'][] = [
+ 'ordinary', 'guildTransport', 'homeworld', 'junction', 'ambassador', 'sneakAttack',
+ ];
+ for (const producer of producers) {
+ const g = fixture();
+ g.advisorPreparation!.firstShipment = { player: 'h', producer };
+ blocked(g);
+ assert.match(quoteAdvisorPreparationWindow(g, 'b').blocked!, /first shipment/);
+ }
+ const g = fixture();
+ for (const p of g.players) { p.shipped = true; p.moved = 2; }
+ assert.equal(quoteAdvisorPreparationWindow(g, 'b').blocked, null);
+});
+
+void test('malformed current receipts cannot create or reopen preparation', () => {
+ const original = fixture();
+ for (const receipt of [
+ null, [], {}, { turn: 3, firstShipment: null },
+ { turn: 3, firstShipment: null, deniedTerritories: ['arrakeen', 'arrakeen'] },
+ { turn: 3, firstShipment: null, deniedTerritories: [''] },
+ { turn: 3, firstShipment: undefined, deniedTerritories: [] },
+ { turn: 3, firstShipment: { player: 'missing', producer: 'ordinary' }, deniedTerritories: [] },
+ { turn: 3, firstShipment: { player: 'h', producer: 'skip' }, deniedTerritories: [] },
+ ]) {
+ const g = structuredClone(original);
+ g.advisorPreparation = receipt as AdvisorPreparationWindow;
+ blocked(g);
+ }
+});
+
+void test('only the unique native Bene Gesserit owner receives preparation choices', () => {
+ const g = fixture();
+ for (const owner of ['a', 'h', 'missing', '']) {
+ blocked(g, owner);
+ assert.equal(projectAdvisorPreparationWindow(g, owner), null);
+ }
+ const duplicate = fixture();
+ duplicate.players.push(structuredClone(player(duplicate, 'b')));
+ blocked(duplicate);
+ assert.equal(projectAdvisorPreparationWindow(duplicate, 'b'), null);
+});
+
+void test('a denied territory stays unavailable without suppressing another native preparation opportunity', () => {
+ const g = fixture();
+ g.advisorPreparation!.deniedTerritories = ['arrakeen'];
+ const before = structuredClone(g), quote = quoteAdvisorPreparationWindow(g, 'b');
+ assert.equal(quote.blocked, null);
+ assert.deepEqual(quote.territories, ['carthag']);
+ assert.throws(() => quoteAdvisorPreparation(g, 'b', 'arrakeen'), AdvisorPreparationError);
+ assert.deepEqual(quoteAdvisorPreparation(g, 'b', 'carthag'), { turn: 3, owner: 'b', territory: 'carthag' });
+ assert.deepEqual(g, before);
+ g.advisorPreparation!.deniedTerritories.push('carthag');
+ blocked(g);
+});
+
+void test('storm, allied presence and fresh-advisor locks retain the native quote’s exclusions', () => {
+ for (const change of [
+ (g: Game) => { g.storm = 10; },
+ (g: Game) => { player(g, 'b').advisors!.arrakeen = { lockedTurn: g.turn }; },
+ (g: Game) => {
+ player(g, 'b').ally = 'h'; player(g, 'h').ally = 'b';
+ player(g, 'h').forces = { 'arrakeen:10': 1 };
+ },
+ ]) {
+ const g = fixture(); change(g);
+ const before = structuredClone(g), native = quoteAdvisorBattleOffer(g);
+ assert.deepEqual(quoteAdvisorPreparationWindow(g, 'b'), {
+ blocked: null, territories: ['carthag'], released: native.released,
+ });
+ assert.throws(() => quoteAdvisorPreparation(g, 'b', 'arrakeen'), AdvisorPreparationError);
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('source validation binds the original owner, territory, turn and still-current board eligibility', () => {
+ const original = fixture(), source = quoteAdvisorPreparation(original, 'b', 'arrakeen');
+ validateAdvisorPreparation(original, source);
+ for (const change of [
+ (g: Game) => { g.turn++; },
+ (g: Game) => { g.advisorPreparation!.firstShipment = { player: 'a', producer: 'ordinary' }; },
+ (g: Game) => { g.advisorPreparation!.deniedTerritories.push('arrakeen'); },
+ (g: Game) => { g.storm = 10; },
+ (g: Game) => { delete player(g, 'b').advisors!.arrakeen; },
+ (g: Game) => { player(g, 'a').forces = { 'carthag:11': 2 }; },
+ ]) {
+ const g = structuredClone(original); change(g);
+ const before = structuredClone(g);
+ assert.throws(() => validateAdvisorPreparation(g, source), AdvisorPreparationError);
+ assert.deepEqual(g, before);
+ }
+ assert.throws(() => validateAdvisorPreparation(original, { ...source, owner: 'a' }), AdvisorPreparationError);
+ assert.throws(() => validateAdvisorPreparation(original, { ...source, territory: 'sietch_tabr' }), AdvisorPreparationError);
+});
+
+void test('malformed declarations fail with the focused error before any mutation', () => {
+ const g = fixture(), source = quoteAdvisorPreparation(g, 'b', 'arrakeen'), before = structuredClone(g);
+ for (const malformed of [
+ null, [], {}, { ...source, turn: -1 }, { ...source, owner: '' },
+ { ...source, territory: '' }, { ...source, amount: 1 },
+ ]) assert.throws(() => validateAdvisorPreparation(g, malformed as AdvisorPreparationSource), AdvisorPreparationError);
+ assert.deepEqual(g, before);
+});
+
+void test('public-board failures stay in the focused quote without returning partial choices', () => {
+ const g = fixture(); g.order = ['b', 'b', 'h'];
+ blocked(g);
+ const malformed = fixture();
+ player(malformed, 'b').advisors!.arrakeen = { lockedTurn: -1 };
+ blocked(malformed);
+});
+
+void test('the pure source quote does not gate its own response on live priority or reset an invoice', () => {
+ const g = fixture(), source = quoteAdvisorPreparation(g, 'b', 'arrakeen');
+ g.response = { kind: 'advisorFlip', owner: 'b', location: 'arrakeen', advisors: false, passed: [] };
+ const before = structuredClone(g);
+ validateAdvisorPreparation(g, source);
+ assert.deepEqual(g, before);
+});
+
+void test('owner projection contains no counts, private resources, receipt payload or unrelated seat choices', () => {
+ const g = fixture();
+ for (const p of g.players)
+ for (const key of ['spice', 'tanks', 'hand', 'leaders'] as const)
+ Object.defineProperty(p, key, { get() { throw new Error(`Preparation read private ${key}`); } });
+ assert.deepEqual(projectAdvisorPreparationWindow(g, 'b'), {
+ blocked: null, territories: ['arrakeen', 'carthag'],
+ });
+ assert.deepEqual(Object.keys(projectAdvisorPreparationWindow(g, 'b')!).sort(), ['blocked', 'territories']);
+ assert.equal(projectAdvisorPreparationWindow(g, 'a'), null);
+ g.advanced = false;
+ assert.equal(projectAdvisorPreparationWindow(g, 'b'), null);
+});
diff --git a/tests/fixture-advisor-preparation.ts b/tests/fixture-advisor-preparation.ts
new file mode 100644
index 00000000..3dff558b
--- /dev/null
+++ b/tests/fixture-advisor-preparation.ts
@@ -0,0 +1,197 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+
+export type AdvisorPreparationStep = { actor: string; action: Action };
+export type AdvisorPreparationTransition = {
+ before: Game; steps: AdvisorPreparationStep[]; after: Game;
+};
+export const advisorPreparationPlayer = (game: Game, id: string) => {
+ const player = game.players.find(player => player.id === id); assert.ok(player); return player;
+};
+export const advisorPreparationClean = (game: Game) => !game.phaseOpening && !game.response &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
+export const advisorPreparationInventory = (game: Game) => {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ const ids = cards.map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
+ return ids;
+};
+
+/** Select original scalar shuffle order only. UUIDs and other byte entropy stay
+ * native; no played hand, army, advisor stance, phase or resource is replaced. */
+function originalScalarOrder(run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const original = crypto.getRandomValues.bind(crypto);
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
+ else Reflect.apply(original, crypto, [array]);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+export function actAdvisorPreparation(game: Game, actor: string, action: Action,
+ actions: AdvisorPreparationStep[] = []): Game {
+ const next = originalScalarOrder(() => applyAction(game, actor, action));
+ actions.push({ actor, action: structuredClone(action) }); return next;
+}
+/** Genuine native continuation. The original preparation offer is caller-owned;
+ * setup and later accompaniment choices never fabricate a stance. */
+export function nextAdvisorPreparationStep(game: Game): AdvisorPreparationStep | null {
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
+ if (game.decision?.kind === 'advisorBattle') return null;
+ if (game.status === 'setup' && game.setupStage === 'prediction')
+ return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
+ if (game.status === 'setup' && game.setupStage === 'forces' &&
+ !advisorPreparationPlayer(game, 'beneGesserit').advisorSetup)
+ return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
+ if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
+ return { actor: game.decision.player, action: { type: 'decision', accept: false } };
+ }
+ return nextRicheseStrongholdsNativeStep(game);
+}
+export function advanceAdvisorPreparation(game: Game, until: (state: Game) => boolean,
+ actions: AdvisorPreparationStep[] = []): Game {
+ for (let count = 0; count < 1400; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The genuine advisor chronology ended before its requested boundary');
+ const next = nextAdvisorPreparationStep(game);
+ assert.ok(next, `Choose the original advisor control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actAdvisorPreparation(game, next.actor, next.action, actions);
+ }
+ throw Error('The native advisor chronology did not reach its finite boundary');
+}
+export function passAdvisorPreparationResponses(game: Game,
+ actions: AdvisorPreparationStep[] = []): Game {
+ for (let count = 0; game.response && count < 60; count++) {
+ const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
+ game = actAdvisorPreparation(game, actor.id, { type: 'passResponse' }, actions);
+ }
+ assert.ok(!game.response); return game;
+}
+export function cloneAdvisorPreparation(game: Game): Game {
+ assert.ok(!game.phaseOpening && !game.response && !game.truthtrance && !game.pendingTreacheryDiscard,
+ 'Clone an original available advisor choice boundary');
+ return structuredClone(game);
+}
+
+/** Fresh public Advanced setup: prediction, private Traitors, and the actual
+ * advisorSetup at occupied Arrakeen. Native first Storm, dual Spice and original
+ * later phase controls execute. Stop before any direct preparation or shipment;
+ * only original unopened card/Spice positions and scalar shuffles are chosen. */
+export function createAdvisorPreparationFixture(options: {
+ phase?: 2 | 3 | 4 | 5; declineOffer?: boolean;
+} = {}) {
+ const phase = options.phase ?? 2, bg = 'beneGesserit', atreides = 'atreides', emperor = 'emperor';
+ let game = createGame('ADVISORPREPARATION', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
+ joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
+ joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: AdvisorPreparationStep[] = [];
+ const act = (actor: string, action: Action) => { game = actAdvisorPreparation(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorPreparation(game, until, actions); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ const originalInventory = advisorPreparationInventory(game);
+ const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
+ const karama = game.deck.find(card => card.effect === 'karama');
+ assert.equal(ordinary.length, 2); assert.ok(karama);
+ const startingCards = [ordinary[0], ordinary[1], karama];
+ const dealOrder = startingCards.map((card, position) => {
+ const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: card.id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ let advisorSetup: AdvisorPreparationTransition | null = null;
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const next = nextAdvisorPreparationStep(game); assert.ok(next);
+ const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
+ act(next.actor, next.action);
+ if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
+ }
+ assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
+ assert.equal(advisorPreparationPlayer(game, bg).forces['arrakeen:10'], 1);
+ assert.ok(advisorPreparationPlayer(game, bg).advisors?.arrakeen);
+ assert.equal(advisorPreparationPlayer(game, atreides).forces['arrakeen:10'], 10);
+ assert.ok(advisorPreparationPlayer(game, emperor).hand.some(card => card.id === karama.id));
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === 2 && advisorPreparationClean(state));
+ const afterSpice = structuredClone(game);
+ if (phase === 3) reach(state => state.phase === 3 && advisorPreparationClean(state) && !!state.auction);
+ if (phase === 4) reach(state => state.phase === 4 && advisorPreparationClean(state));
+ let openingOffer: Game | null = null, decline: AdvisorPreparationTransition | null = null;
+ if (phase === 5) {
+ reach(state => state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
+ openingOffer = structuredClone(game);
+ if (options.declineOffer) {
+ const before = structuredClone(game), start = actions.length;
+ act(bg, { type: 'decision', accept: false });
+ reach(state => state.phase === 5 && advisorPreparationClean(state) && !!state.active);
+ decline = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ }
+ }
+ assert.equal(game.turn, 1); assert.equal(game.phase, phase);
+ assert.equal(advisorPreparationPlayer(game, bg).forces['arrakeen:10'], 1);
+ assert.ok(advisorPreparationPlayer(game, bg).advisors?.arrakeen);
+ assert.equal(advisorPreparationPlayer(game, atreides).forces['arrakeen:10'], 10);
+ assert.ok(game.players.every(player => !player.shipped && player.moved === 0));
+ assert.ok(advisorPreparationPlayer(game, emperor).hand.some(card => card.id === karama.id));
+ assert.deepEqual(advisorPreparationInventory(game), originalInventory);
+ const preparation: Action = { type: 'prepareAdvisors', territory: 'arrakeen' };
+ const shipmentAction: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
+ return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, afterSpice,
+ openingOffer, decline, bg, owner: bg, atreides, emperor, observer: emperor, counterOwner: emperor,
+ karama: karama.id, phase, territory: 'arrakeen', sector: 10, location: 'arrakeen:10', preparation,
+ preparationStep: { actor: bg, action: preparation } satisfies AdvisorPreparationStep,
+ declineOffer: { type: 'decision', accept: false } satisfies Action,
+ shipmentAction, shipmentStep: game.active ? { actor: game.active, action: shipmentAction } satisfies AdvisorPreparationStep : null,
+ endMovement: { type: 'endMovement' } satisfies Action,
+ actions, sourceActionCount: actions.length, originalInventory, originalSpice, dealOrder, spiceOrder,
+ staging: [
+ 'Actual public three-seat Advanced Bene Gesserit/Atreides/Emperor ready/start, prediction and private Traitor choices. Starting advisorSetup really puts one BG counter into occupied Arrakeen10, so its native advisor stance remains.',
+ 'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Emperor is actually dealt its original printed Karama; no later held-card acquisition or hand replacement is staged.',
+ 'Actually complete native first Storm and both first-turn Spice blows. Requested Charity, first native auction, Revival or initial Shipment controls are reached by their original readiness, peek and auction responses. No force, wallet, played phase, stance, clock or receipt is assigned.',
+ options.declineOffer && phase === 5
+ ? 'Actually decline the original first Shipment advisorBattle offer, then stop at an active native turn whose shipment and movement remain unused.'
+ : 'Stop before the requested preparation or first actual shipment; an original phase5 advisorBattle offer remains caller-owned when requested.',
+ 'The direct preparation and positive shipment actions are returned without being executed. The separate shipAdvisorPreparation helper submits one real paid positive shipment from the current actor, then declines any native accompaniment through actual controls.',
+ ],
+ };
+}
+export type AdvisorPreparationFixture = ReturnType;
+
+/** A native skip/end is separate from this real positive shipment. The caller
+ * first resolves preparation and reaches an actual unshipped active turn. */
+export function shipAdvisorPreparation(source: Game,
+ actions: AdvisorPreparationStep[] = []): AdvisorPreparationTransition {
+ assert.equal(source.phase, 5); assert.ok(advisorPreparationClean(source) && source.active);
+ const actor = source.active; assert.equal(advisorPreparationPlayer(source, actor).shipped, false);
+ const before = structuredClone(source), start = actions.length;
+ const action: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 };
+ let game = actAdvisorPreparation(source, actor, action, actions);
+ game = advanceAdvisorPreparation(game, advisorPreparationClean, actions);
+ assert.equal(advisorPreparationPlayer(game, actor).forces['polar_sink:0'],
+ (advisorPreparationPlayer(before, actor).forces['polar_sink:0'] ?? 0) + 1);
+ assert.ok(advisorPreparationPlayer(game, actor).spice < advisorPreparationPlayer(before, actor).spice);
+ assert.equal(advisorPreparationPlayer(game, actor).shipped, true);
+ return { before, steps: structuredClone(actions.slice(start)), after: game };
+}
diff --git a/tests/moritani-force-leader.test.ts b/tests/moritani-force-leader.test.ts
index fa52ce79..dc22ebd4 100644
--- a/tests/moritani-force-leader.test.ts
+++ b/tests/moritani-force-leader.test.ts
@@ -320,3 +320,30 @@ void test('declining the Bene Gesserit intrusion still offers the free advisor s
);
});
+
+void test('only a positive Sneak Attack after ground entry closes native advisor preparation', () => {
+ // Controlled native entry board; the separate advisor fixture owns public
+ // setup chronology. No shipment occurs before the actual ground/Terror path.
+ for (const amount of [0, 1]) {
+ const g = fixture('sneakAttack');
+ g.advanced = true;
+ g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
+ g.order.push('b');
+ g.movementRemaining!.push('b');
+ g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
+ player(g, 'e').forces = { 'imperial_basin:10': 1 };
+ player(g, 'e').reserves = 19;
+ const entered = applyAction(g, 'e', { type: 'move', from: 'imperial_basin:10',
+ territory: 'arrakeen', sector: 10, amount: 1 });
+ assert.equal(entered.advisorPreparation?.firstShipment, null);
+ assert.equal(player(entered, 'e').shipped, true, 'Ground movement spends the allowance without shipping');
+ const revealed = decide(entered, { reveal: true });
+ const done = decide(revealed, { amount });
+ assert.deepEqual(done.advisorPreparation?.firstShipment,
+ amount ? { player: 'm', producer: 'sneakAttack' } : null);
+ assert.equal(player(done, 'm').reserves, 20 - amount);
+ assert.equal(player(done, 'm').forces['arrakeen:10'] ?? 0, amount);
+ assert.equal(totalForces(done, 'm'), 20);
+ if (amount) assert.equal(done.decision?.kind, 'advisor', 'The clock closes before accompaniment');
+ }
+});