diff --git a/README.md b/README.md
index 97e55d66..5592441d 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,9 @@ uses one unused movement entitlement and retains Hajr's separate move.
The single physical Hajr can be played again after legitimate
[paid Box recovery](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026).
Advanced still requires a fresh post-movement opportunity.
+Advanced advisors may use both physical Ornithopter modes. Advisors do not grant
+city access; normal-range groups use current fighter occupancy. See the
+[advisor source cutover](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026).
Before starting, open **Configure** on an AI seat to change its difficulty,
faction or player circle without replacing it. Changes save immediately and
diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx
index 5e654cf9..7c7ab02f 100644
--- a/components/ornithopter-movement.tsx
+++ b/components/ornithopter-movement.tsx
@@ -7,7 +7,6 @@ import {
type OrnithopterMode,
} from '@/game/ornithopter';
import { location, splitLocation, gameTerritories } from '@/game/board';
-import { isAdvisor } from '@/game/advisors';
import { botGroundMoveAllowed } from '@/game/bot-mobility';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
@@ -31,6 +30,7 @@ export function OrnithopterMovement({
const card = game.ornithopter;
const me = game.players.find((p) => p.id === game.me);
if (!card || !me) return null;
+ const advisorGuidance = game.advanced && me.faction === 'beneGesserit';
const active = card.active;
const mode =
active?.mode ??
@@ -59,18 +59,6 @@ export function OrnithopterMovement({
location(String(move.territory), Number(move.sector)), 0)))
reason = 'The Fremen two-territory advantage is unavailable. Choose a reachable destination within the remaining normal range.';
}
- const physicalSource =
- typeof move?.from === 'string'
- ? move.from
- : move?.forces && typeof move.forces === 'object'
- ? Object.keys(move.forces)[0]
- : undefined;
- if (
- !reason &&
- physicalSource &&
- isAdvisor(me, splitLocation(physicalSource).territory)
- )
- reason = 'Advanced advisor use of the Ornithopter card awaits its ruling.';
if (!reason && active?.cohort && move) {
const forces =
(move.forces as Record | undefined) ??
@@ -194,6 +182,15 @@ export function OrnithopterMovement({
>
)}
+ {advisorGuidance && (
+
+ Advanced Bene Gesserit advisors can use both card modes. Advisors in
+ Arrakeen or Carthag do not grant the city’s movement range. This card’s
+ one-group mode independently gives range three. Two-group mode uses
+ your current normal range and different original forces. Normal entry
+ and advisor conversion still apply.
+
+ )}
{me.faction === 'choam' && game.choamMovementBonus > 0 && (
Provisional Kulon combination: the one-group card move stays capped
@@ -209,7 +206,10 @@ export function OrnithopterMovement({
{
if (busy || reason || !move || !mode) return;
const action = { ...move };
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 81bd195c..4ec1910b 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,31 @@ open mode gates.
## Current checkpoint and work
+**Advanced advisor / physical Ornithopter — 18 October. Prototyped / Partial.**
+The supplied source excludes advisors from granting city access, rather than
+using this physical card. Both modes now use native stance, arrival/locks and
+original-group accounting. Fixed range3 is independent; normal groups recompute
+current fighter-city access. Persistent controls explain the distinction, and
+minimal bots use shared advisor-aware routes and native entry checks.
+
+Two disjoint implementation lanes plus owner integration pass **10 files87/87**,
+types/lint/build and three original-action/rendered-table programmes. Each uses58
+source actions: original host/setup advisor, cache1 purchase, actual foreign
+shipment1/accompaniment and paid BG2 Polar Sink or1 Carthag reinforcement.
+Range3 uses2 continuations; advisor/fighter twoGroups and last-city-fighter
+variants use3 each. Empty advisor arrivals flip normally, moved forces cannot
+be reused, and remaining advisors grant no range after the last fighter leaves.
+Eight minimal policy proposals are legal; four controlled geometry and six
+isolated control cases remain separately labelled.
+
+Feedback corrected only an expected-error matcher and a nullable fixture
+annotation. No engine rule repair followed frozen checks. Rendering uses actual
+GameTable views plus accepted movement drafts in the isolated control; no
+select/submit browser click or visual/deployed acceptance. Wider combinations,
+full modes and final assurance remain open. No saved-room/database/reset/restart
+or deployment operation.
+[Source cutover and proof](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026).
+
**Advanced Kull effect scope — 18 October. Prototyped / Partial.** New
Advanced attempts adopt the supplied specific-effect wording. Basic and
unstamped older pending/settled restrictions remain all-purpose. Semantic keys
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 5001f48a..12dd0964 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,25 @@
# Dune implementation status
+## 18 October 2026 — Advanced advisor physical Ornithopter
+
+Both physical card modes now accept advisors under the supplied city-access
+wording. Advisors do not grant city range; eligible fighters do, recomputed per
+normal group. Existing stance, arrival/locks, route/entry and original physical
+cohorts remain authoritative. The isolated control gives persistent guidance;
+minimal bots use shared advisor-aware geometry and native entry.
+
+Two disjoint lanes plus owner integration pass10-file87/87, types/lint/build and
+three original-action/rendered-table programmes. Each uses58 source actions for
+original advisor setup, paid cache1, native shipment/accompaniment and BG2 Polar
+Sink or1 city reinforcement. Range3 uses2 continuations; ordinary twoGroups and
+last-city-fighter variants use3 each. Eight minimal policy proposals are legal.
+Four controlled geometry and six control cases are separately labelled.
+Feedback corrected an error matcher and nullable fixture annotation, without a
+subsequent rule repair. [Source cutover and exact proof](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026).
+SSR does not click controls or establish visual/browser/deployed acceptance.
+Wider combinations, complete modes and final assurance stay open. No saved-room/
+database/reset/restart/deployment operation.
+
## 18 October 2026 — Advanced Kull specific-effect scope
New Advanced attempts now restrict the semantic Karama effect named by the
diff --git a/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md b/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md
index e93c78aa..1a4b2e9c 100644
--- a/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md
+++ b/docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md
@@ -2,6 +2,64 @@
Audit: 2026-09-06. Document-only source and engine review. The canonical component is `richese-ornithopter`; this is distinct from ordinary Arrakeen/Carthag ornithopter access and the later Ecaz discovery token. No runtime effect or complete expansion compliance is claimed here.
+## Advanced advisor card movement — 18 October 2026
+
+**Prototyped / Partial.** The authorized supplied Advanced source,
+physical28/logical26, says advisors do not count as occupying a stronghold for
+ornithopter access. Physical10/logical8 likewise excludes advisors from granting
+city access, while permitting a player with eligible city forces to move forces
+elsewhere. Neither passage gives the prior blanket ban on an advisor using the
+separately named physical card. The photographed card supplies its own
+three-territory move or two different normal moves without a faction/stance
+exclusion. This cutover supersedes the older advisor guard and source assessment
+below; it is not a newly located publisher-specific card FAQ.
+
+Both modes now permit advisor groups. The range3 mode supplies its printed
+independent maximum. Two-group mode uses current normal range at each actual
+move: advisors alone in a city grant no access; fighters do. An advisor arriving
+alone automatically becomes a fighter and can therefore enable the second group.
+Leaving the last city fighter removes that access even if advisors remain in
+another city. Existing advisor/fighter arrival, new-advisor locks, same-territory
+stance, native response windows, storm/entry and distinct original force quotas
+continue to govern. This is no new shipment, stance privilege or free replay.
+
+The isolated control gives persistent Advanced BG guidance. Minimal bot card
+candidates use the shared advisor-aware route and native entry checks, alongside
+current fighter-city access and original group quotas. No strategy tuning.
+Two disjoint implementation lanes plus owner integration pass frozen **10
+files87/87**, types/lint/build and three original-action/rendered-table
+programmes. Each starts with58 actual source actions: original Advanced host,
+BG advisorSetup in native Atreides Arrakeen, cache1 purchase, Richese1 shipment,
+actual free accompaniment and BG2 Polar Sink or1 Carthag reinforcement. Only
+original scalar lotteries and unopened first-turn Spice positions are selected;
+no postdeal hand, wallet, force, stance or clock is installed.
+
+Range3 has2 continuation actions; advisor/fighter twoGroups and last-city-fighter
+variants each have3. The real advisor source cannot use an ordinary three-range
+move without city fighters. Card range3 succeeds and empty arrival flips.
+TwoGroups retains original advisor/fighter quotas after automatic face changes;
+leaving the last Carthag fighter recomputes the second advisor group to1 even
+while advisors remain in Arrakeen. Four minimal policies supply eight legal
+initial/second-group proposals. Four controlled rule cases separately exercise
+empty-city access, occupied-city nonaccess, existing fighter-city ordinary use
+and locked advisor entry among two other factions. Six isolated control cases
+cover modes, guidance, original cohorts and disabled states.
+
+Source-bound proof is outside Git at
+`~/.local/share/dune/checkpoints/20261018-ornithopter-advisors/frozen-integrated-2/report.json`:
+base `884a09fa92c8604c943979ef2a4c29990399c0fc`, source tree
+`62adeab78469339886640fa0e852a8532f7e03d80da052a687576fc4e7110c83`.
+The narrow native case corrected only its expected-error matcher; type feedback
+added a nonnull annotation for the controlled fixture’s known movement list.
+The frozen related union and required checks then passed on identical source.
+Only checkpoint prose/reference evidence changed afterward.
+
+GameTable snapshots use genuine views; isolated controls render the actual
+accepted first/second movement drafts. SSR does not click select/submit, and
+framework aliases/CSS stubs do not establish visual/browser/deployed acceptance.
+Other card/module combinations, full modes and comprehensive assurance remain
+due. No saved-room/database/reset/restart or deployment operation occurred.
+
## Recovered Hajr replay — 18 October 2026
**Prototyped / Partial.** The earlier multiple-physical-Hajr boundary had an
@@ -146,7 +204,7 @@ extra territory. The authorized supplied Advanced PDF repeats “one extra
territory during your Force Movement” on physical p29, but does not resolve
the separately named Richese card's maximum. Choosing the explicit card
cap avoids inventing a four-territory exception. Hajr/prior-move and
-Advanced advisor combinations remain guarded.
+Advanced advisor card movement now follows the later supplied-source cutover above; broader combinations remain open.
Changed-path proof: real Kulon activation followed by a three-territory
card move, four-territory rejection, both normal groups moving two
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 8c013642..05123b4b 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -874,6 +874,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
+| Advanced advisor / physical Ornithopter | Supplied physical28/logical26 and10/logical8 prevent advisors from granting city access, rather than banning this physical card. Both printed modes now accept advisors through normal stance/arrival/locks/cohort rules. Range3 is independent; two-group range uses current fighter city access at each group. Empty advisor arrival can flip and grant access; advisor-only city cannot. This source cutover supersedes the older guard, not a publisher card FAQ. Wider combinations and final acceptance remain due. | [Source cutover and scoped proof](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#advanced-advisor-card-movement--18-october-2026) |
| Ornithopter / Hajr | Standing choose-recommended provisionally replaces one unused ordinary/Hajr entitlement by range-three or two distinct normal groups, with no retroactive credit; a later Hajr may reuse those forces. Advanced adopts supplied physical39/logical37 immediately-after-play timing with explicit online/card-group interpretation; Basic retains broader face timing. The inventory has one physical Hajr, not several copies. A genuine paid Box retrieval now permits replaying that held card for another allowance because no once-per-turn cap is established. This is an explicit replay interpretation; other recovery producers and full combinations remain open. | [Original source cutover](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026) · [Physical replay and counted Cunning](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#recovered-hajr-replay--18-october-2026) |
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Same-plan Harass pays the original quote before returns; future returns cannot fund or change it. Native Advanced Occupy Stone/Mirror composition keeps the fee but ignores +2 normal score in physical-token comparison. These are product interpretations, not a new publisher ruling or user answer. Other profile/module gates remain; eligibility never depends on concealed Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) · [Stone composition](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026) |
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
diff --git a/game/bots.ts b/game/bots.ts
index 696aa540..a3b28a6c 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -95,6 +95,7 @@ import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
botEntryAllowed,
botGroundMoveAllowed,
+ groundMoveRouteDistance,
fremenReserveEntry,
guildTransportCost,
} from './bot-mobility';
@@ -479,13 +480,6 @@ function ornithopterMoves(g: GameView): Action[] {
? ['range3', 'twoGroups']
: ['twoGroups', 'range3'];
const result: Action[] = [];
- const pathsBlocked = (key: string) => {
- const loc = splitLocation(key);
- return (
- loc.sector === g.storm ||
- strongholdPathBlocked(g.players, me.id, loc.territory)
- );
- };
const targets = destinations(g).filter(
(to) =>
to.t !== MOBILE_STRONGHOLD &&
@@ -511,7 +505,7 @@ function ornithopterMoves(g: GameView): Action[] {
if (
source.from === to.key ||
origin.sector === g.storm ||
- isAdvisor(me, origin.territory) ||
+ !botEntryAllowed(g, me, to.t, to.s, 'move', origin.territory) ||
(origin.territory !== to.t &&
g.balisetRestrictions.some(
(b) => b.player === me.id && b.territory === to.t,
@@ -536,7 +530,7 @@ function ornithopterMoves(g: GameView): Action[] {
(me.faction === 'ixians' && elite > 0 && !me.ixMovementBlocked)
? 2
: 1) + (me.faction === 'choam' ? g.choamMovementBonus : 0);
- const distance = gameDistance(g, source.from, to.key, pathsBlocked);
+ const distance = groundMoveRouteDistance(g, me, source.from, to.key);
if (
distance > (mode === 'range3' ? 3 : normalRange) ||
(!active && mode === 'range3' && distance <= normalRange)
diff --git a/game/engine.ts b/game/engine.ts
index bc404be9..01447f6d 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -34176,10 +34176,6 @@ function applyActionInner(
action.ornithopter === undefined,
'No matching Ornithopter movement is active.',
);
- requireRule(
- !flight || !isAdvisor(p, origin),
- 'Advanced advisor use of the Ornithopter card awaits its ruling.',
- );
const move: MovementOrder = {
player: id,
group,
diff --git a/game/reference.ts b/game/reference.ts
index f64deedc..e11ebaa5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -902,7 +902,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A validated declaration moves the physical card from your hand to the public played-card area. It remains there through genuine CHOAM, Ixian or other arrival decisions and is discarded once when the chosen use ends. Random hand effects act on cards still in hand. Ending your movement closes any remaining card movement without undoing a completed group.',
'The saved event binds retries and the remaining original-group data. Refresh and reconnect preserve that event. After disposal, recovery preserves the completed forces and marker event before opening arrival decisions or advancing an early-ended turn; it does not move the group again. Missing saved group data cannot be reconstructed from merged forces and blocks the damaged continuation instead of allowing reuse.',
'Under the provisional composition, Ornithopter replaces one unused ordinary or Hajr movement. A prior move stays spent and cannot become the card’s first group retrospectively. One Hajr play plus a two-group flight permits three group movements; Hajr may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. The single physical Hajr may grant another move after legitimate recovery and a new legal play.',
- 'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advisor/card use, Guild Cunning/card and wider optional combinations remain unfinished.',
+ 'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advanced advisors may use both physical card modes. They do not grant city access themselves; two-group mode uses current fighter city access at each move, while range3 is independent. Empty arrivals flip normally, and fresh-advisor locks and original-group limits still apply. Guild Cunning/card and wider optional combinations remain unfinished.',
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
],
related: [
@@ -916,19 +916,19 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Advisor composition and broader acceptance remain unfinished.',
+ 'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Each genuinely recovered Hajr replay has its own grant. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Both modes now accept Advanced advisors through native stance/entry and original-group rules. Advisors do not grant city access; current fighter-city range is recomputed for each normal group. Wider combinations and broader acceptance remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition and Advanced timing; automatic disposal adds no confirmation.',
+ 'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition, Advanced timing and advisor/city access; automatic disposal adds no confirmation.',
},
{
area: 'AI',
status: 'Partial',
detail:
- 'Four profiles use projected ranges, distinct group quotas and earned Hajr movement. Actual owned second-flight and extra-move candidates are legal; strategic tuning remains deferred.',
+ 'Four profiles use projected ranges, advisor-aware routes/native entry, distinct group quotas and earned Hajr movement. Actual advisor and owned second-group candidates are legal; strategic tuning remains deferred.',
},
{
area: 'Documentation',
@@ -940,9 +940,14 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Hajr checkpoint adds frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes: actual paid cards/shipments, Basic and Advanced timing, prior normal movement, typed groups, native Baliset, voluntary window closure/reopening and early end. Four minimal policies retain both continuations. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.',
+ '18 October advisor scope:10-file87/87, types/lint/build and three original-action/rendered-table programmes pass. Actual host/setup, paid cache1, native accompaniment and paid BG reinforcement cover advisor range3, distinct advisor/fighter groups and loss of city access. Eight minimal policy proposals are legal. Controlled geometry and isolated controls remain separately labelled; no select/submit browser click. Earlier Hajr evidence remains separate. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.',
evidence: [
'game/ornithopter.ts',
+ 'components/ornithopter-movement.tsx',
+ 'tests/ornithopter-advisor-rules.test.ts',
+ 'tests/ornithopter-advisors-engine.test.ts',
+ 'tests/ornithopter-advisors-controls.test.tsx',
+ 'tests/fixture-ornithopter-advisors.ts',
'game/card-availability.ts',
'tests/fixture-ornithopter-hajr.ts',
'tests/ornithopter-hajr-engine.test.ts',
diff --git a/tests/fixture-ornithopter-advisors.ts b/tests/fixture-ornithopter-advisors.ts
new file mode 100644
index 00000000..7d345b05
--- /dev/null
+++ b/tests/fixture-ornithopter-advisors.ts
@@ -0,0 +1,159 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { location, splitLocation, TERRITORIES } from '../game/board';
+import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
+import { richeseCards } from '../game/richese-cards';
+import type { OrnithopterMode } from '../game/ornithopter';
+import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+const canonicalCard = richeseCards().find(card => card.effect === 'ornithopter');
+assert.ok(canonicalCard);
+export const ADVISOR_ORNITHOPTER = canonicalCard.id;
+export type OrnithopterAdvisorStep = { actor: string; action: Action };
+export const ornithopterAdvisorPlayer = (game: Game, actor = 'beneGesserit') => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const ornithopterAdvisorClean = (game: Game) => !game.response && !game.phaseOpening &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function actOrnithopterAdvisor(game: Game, actor: string, action: Action,
+ actions: OrnithopterAdvisorStep[] = []): Game {
+ const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
+ actions.push({ actor, action: structuredClone(action) });
+ return next;
+}
+/** Actual native choices preserve the starting advisor stance. Accompaniment
+ * is selected explicitly by the source programme, never invented by a driver. */
+export function nextOrnithopterAdvisorStep(game: Game): OrnithopterAdvisorStep | null {
+ if (!game.phaseOpening && !game.response) {
+ if (game.status === 'setup' && game.setupStage === 'prediction')
+ return { actor: 'beneGesserit', action: { type: 'predict', faction: 'guild', turn: 10 } };
+ if (game.status === 'setup' && game.setupStage === 'forces' && !ornithopterAdvisorPlayer(game).advisorSetup)
+ return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
+ const decision = game.decision;
+ if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
+ return { actor: decision.player, action: { type: 'decision', accept: false } };
+ if (decision?.kind === 'richeseCache') return { actor: decision.player, action: {
+ type: 'decision', event: game.richeseBidding!.event, card: ADVISOR_ORNITHOPTER,
+ method: 'onceAround', direction: 'clockwise',
+ } };
+ if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
+ action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
+ }
+ if (ornithopterAdvisorClean(game) && game.richeseAuction?.cardId === ADVISOR_ORNITHOPTER &&
+ !game.richeseAuction.outcome && game.richeseAuction.active === 'beneGesserit' && game.richeseAuction.bid === 0)
+ return { actor: 'beneGesserit', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
+ return nextRicheseStrongholdsNativeStep(game);
+}
+export function advanceOrnithopterAdvisor(game: Game, until: (state: Game) => boolean,
+ actions: OrnithopterAdvisorStep[] = []): Game {
+ for (let count = 0; count < 2000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original advisor Ornithopter programme ended before its next control');
+ const step = nextOrnithopterAdvisorStep(game); assert.ok(step, 'Complete the original private plan before advancing');
+ game = actOrnithopterAdvisor(game, step.actor, step.action, actions);
+ }
+ throw Error('The original advisor Ornithopter programme did not reach its finite boundary');
+}
+
+/** Genuine Advanced host/setup and paid native cache card. Native Atreides
+ * occupies Arrakeen before BG chooses its original advisor placement. A real
+ * Richese shipment there supplies one free accompanying advisor; a paid BG
+ * shipment supplies the separate fighter. No dealt card, stance, clock,
+ * money, force or completed receipt is installed. */
+export function createOrnithopterAdvisorFixture(options: { fighterCity?: boolean } = {}) {
+ let game = createGame('ORNITHOPTERADVISORS', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
+ for (const faction of ['atreides', 'beneGesserit', 'guild'] as const)
+ joinGame(game, newPlayer(faction, faction, faction));
+ const actions: OrnithopterAdvisorStep[] = [];
+ const act = (actor: string, action: Action) => { game = actOrnithopterAdvisor(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterAdvisor(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);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
+ for (let count = 0; count < 2; count++) {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
+ front.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ game.spiceDeck.unshift(...front);
+ reach(state => ornithopterAdvisorPlayer(state).hand.some(card => card.id === ADVISOR_ORNITHOPTER));
+ const acquired = structuredClone(game);
+ reach(state => state.phase === 5 && state.active === 'richese' && ornithopterAdvisorClean(state));
+ const beforeForeignShipment = structuredClone(game), foreignShipment: OrnithopterAdvisorStep = {
+ actor: 'richese', action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 },
+ };
+ act(foreignShipment.actor, foreignShipment.action);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
+ const beforeAccompaniment = structuredClone(game), accompany: OrnithopterAdvisorStep = {
+ actor: 'beneGesserit', action: { type: 'decision', accept: true, accompany: true, sector: 10, amount: 1 },
+ };
+ act(accompany.actor, accompany.action); reach(ornithopterAdvisorClean);
+ const afterAccompaniment = structuredClone(game);
+ act('richese', { type: 'endMovement' });
+ reach(state => state.phase === 5 && state.active === 'beneGesserit' && ornithopterAdvisorClean(state));
+ const fighterSource = options.fighterCity ? 'carthag:11' : 'polar_sink:0';
+ const beforeReinforcement = structuredClone(game), reinforcement: OrnithopterAdvisorStep = {
+ actor: 'beneGesserit', action: { type: 'ship', ...splitLocation(fighterSource), amount: 1, elite: 0, allyPayment: 0 },
+ };
+ act(reinforcement.actor, reinforcement.action); reach(ornithopterAdvisorClean);
+ return { game, lobby, setup, afterSetup, acquired, beforeForeignShipment, foreignShipment,
+ beforeAccompaniment, accompany, afterAccompaniment, beforeReinforcement, reinforcement,
+ afterReinforcement: structuredClone(game), owner: 'beneGesserit', card: ADVISOR_ORNITHOPTER,
+ advisorSource: 'arrakeen:10', fighterSource, fighterCity: options.fighterCity ?? false,
+ sourceActionCount: actions.length, actions,
+ staging: ['Only original scalar lotteries and unopened first-turn Spice positions are selected. The original advisorSetup, cache1 purchase, paid foreign shipment, accepted free accompaniment and paid BG reinforcement provide all physical cards, forces, stances and funding.'] };
+}
+export type OrnithopterAdvisorFixture = ReturnType;
+
+/** Choose an empty printed destination from the same advisor-aware route and
+ * entry quotes as the controls. Empty arrival retains the native automatic
+ * stance rule; the movement card does not force a fighter declaration. */
+export function ornithopterAdvisorMove(game: Game, from: string, options: {
+ range?: number; mode?: OrnithopterMode; to?: string;
+} = {}): Action {
+ const view = viewGame(game, 'beneGesserit'), own = view.players.find(player => player.id === view.me)!;
+ assert.ok((own.forces[from] ?? 0) > 0);
+ const range = options.range ?? 1, origin = splitLocation(from).territory;
+ const to = options.to ?? TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
+ .flatMap(place => place.sectors.map(sector => location(place.id, sector)))
+ .find(key => {
+ const target = splitLocation(key);
+ return game.players.every(player => Object.entries(player.forces).every(([source, count]) =>
+ count === 0 || splitLocation(source).territory !== target.territory)) &&
+ botEntryAllowed(view, own, target.territory, target.sector, 'move', origin) &&
+ groundMoveRouteDistance(view, own, from, key) === range;
+ });
+ assert.ok(to, `The original advisor programme needs an empty printed range-${range} destination`);
+ const active = view.ornithopter?.active;
+ return { type: 'move', from, amount: 1, elite: 0, ...splitLocation(to),
+ ...(options.mode ? { movementCard: ADVISOR_ORNITHOPTER, ornithopter: options.mode }
+ : active ? { ornithopterEvent: active.event } : {}) };
+}
+
+/** A normal two-group flight moves one original advisor and one original
+ * fighter. The city variant moves the last fighter out first, so its second
+ * advisor group must use newly recomputed ordinary range one. */
+export function completeOrnithopterAdvisors(fixture: OrnithopterAdvisorFixture,
+ mode: OrnithopterMode = 'twoGroups') {
+ let game = structuredClone(fixture.game);
+ const actions: OrnithopterAdvisorStep[] = [], moves: { before: Game; step: OrnithopterAdvisorStep; after: Game }[] = [];
+ const move = (from: string, settings: Parameters[2] = {}) => {
+ const before = structuredClone(game), step: OrnithopterAdvisorStep = {
+ actor: fixture.owner, action: ornithopterAdvisorMove(game, from, settings),
+ };
+ game = actOrnithopterAdvisor(game, step.actor, step.action, actions);
+ game = advanceOrnithopterAdvisor(game, ornithopterAdvisorClean, actions);
+ moves.push({ before, step, after: structuredClone(game) });
+ return location(String(step.action.territory), Number(step.action.sector));
+ };
+ const firstSource = mode === 'twoGroups' && fixture.fighterCity ? fixture.fighterSource : fixture.advisorSource;
+ const firstDestination = move(firstSource, { mode, range: mode === 'range3' || fixture.fighterCity ? 3 : 1 });
+ const first = structuredClone(game);
+ if (mode === 'twoGroups') move(fixture.fighterCity ? fixture.advisorSource : fixture.fighterSource);
+ const completed = structuredClone(game);
+ game = actOrnithopterAdvisor(game, fixture.owner, { type: 'endMovement' }, actions);
+ return { game, completed, first, firstDestination, firstSource, moves, actions, mode };
+}
diff --git a/tests/ornithopter-advisor-rules.test.ts b/tests/ornithopter-advisor-rules.test.ts
new file mode 100644
index 00000000..8640730b
--- /dev/null
+++ b/tests/ornithopter-advisor-rules.test.ts
@@ -0,0 +1,94 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {applyAction, createGame, newPlayer, type Game, type Action} from '../game/engine';
+import {baseDeck} from '../game/cards';
+import {richeseCards} from '../game/richese-cards';
+import {TERRITORIES, gameDistance, splitLocation} from '../game/board';
+import {fighterCount, isAdvisor} from '../game/advisors';
+
+const card = 'richese-ornithopter', remote = 'red_chasm:7', near = 'imperial_basin:10';
+/** Controlled rule geometry, separate from the original-action acquisition fixture. */
+function fixture() {
+ const g = createGame('ADVISORRULES', newPlayer('p','Bene Gesserit','beneGesserit'), true, ['choam']);
+ g.players.push(newPlayer('q','Atreides','atreides'));
+ Object.assign(g, {status:'playing',turn:2,phase:5,active:'p',order:['p','q'],
+ movementRemaining:['p','q'],storm:18,deck:baseDeck(),richeseCache:richeseCards()});
+ const p = g.players[0], q = g.players[1];
+ p.hand = [g.richeseCache!.splice(g.richeseCache!.findIndex(c => c.id === card),1)[0]];
+ p.forces = {[near]:1,[remote]:2}; p.reserves = 17; p.shipped = true;
+ p.advisors = {imperial_basin:{},red_chasm:{}};
+ q.forces = {[near]:1,[remote]:1}; q.reserves = 18;
+ return g;
+}
+function atDistance(g: Game, from: string, steps: number): string {
+ const target = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`)).find(key =>
+ splitLocation(key).sector !== g.storm &&
+ !g.players.some(p => p.forces[key]) &&
+ gameDistance(g,from,key,key => splitLocation(key).sector === g.storm) === steps);
+ assert.ok(target); return target;
+}
+function move(g: Game, from: string, to: string, extra: Partial = {}) {
+ return applyAction(g,'p',{type:'move',from,amount:1,...splitLocation(to),...extra});
+}
+
+void test('advisors do not grant city access, while a separate fighter city permits normal advisor movement', () => {
+ const g = fixture();
+ g.players[0].forces['arrakeen:10'] = 1; g.players[0].reserves--;
+ g.players[0].advisors!.arrakeen = {};
+ g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
+ const target = atDistance(g,remote,3), before = structuredClone(g);
+ assert.equal(fighterCount(g.players[0],'arrakeen'),0);
+ assert.throws(() => move(g,remote,target), /more than 1/);
+ assert.throws(() => move(g,remote,target,{movementCard:card,ornithopter:'twoGroups'}), /more than 1/);
+ assert.deepEqual(g,before);
+ g.players[0].forces['carthag:11'] = 1; g.players[0].reserves--;
+ const done = move(g,remote,target);
+ assert.equal(done.players[0].forces[target],1);
+ assert.equal(done.players[0].moved,1);
+ assert.ok(done.players[0].hand.some(c => c.id === card), 'City access does not play the physical card');
+});
+
+void test('a card group arriving as a fighter in an empty city enables current normal range for the second advisor group', () => {
+ const g = fixture();
+ const first = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'twoGroups'});
+ assert.equal(isAdvisor(first.players[0],'arrakeen'),false);
+ assert.equal(fighterCount(first.players[0],'arrakeen'),1);
+ assert.equal(first.ornithopter!.cohort!.forces[remote],2);
+ const target = atDistance(first,remote,3);
+ const done = move(first,remote,target,{ornithopterEvent:first.ornithopter!.event});
+ assert.equal(done.players[0].moved,2);
+ assert.equal(done.players[0].forces[target],1);
+ assert.equal(done.discard.filter(c => c.id === card).length,1);
+});
+
+void test('a first card group remaining advisors in an occupied city grants no range to the second group', () => {
+ const g = fixture();
+ g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
+ const first = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'twoGroups'});
+ assert.equal(isAdvisor(first.players[0],'arrakeen'),true);
+ assert.equal(fighterCount(first.players[0],'arrakeen'),0);
+ const before = structuredClone(first);
+ assert.throws(() => move(first,remote,atDistance(first,remote,3),
+ {ornithopterEvent:first.ornithopter!.event}), /more than 1/);
+ assert.deepEqual(first,before);
+ const done = move(first,remote,atDistance(first,remote,1),{ornithopterEvent:first.ornithopter!.event});
+ assert.equal(done.players[0].moved,2);
+ assert.equal(done.discard.filter(c => c.id === card).length,1);
+});
+
+void test('card range cannot bypass a new advisor fighter lock or the native two-faction entry distinction', () => {
+ const g = fixture();
+ g.players[0].advisors!.imperial_basin = {lockedTurn:g.turn};
+ g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
+ const third = newPlayer('h','Harkonnen','harkonnen');
+ third.forces = {'arrakeen:10':1}; third.reserves = 19;
+ g.players.push(third); g.order.push('h'); g.movementRemaining!.push('h');
+ const before = structuredClone(g);
+ assert.throws(() => move(g,near,'arrakeen:10',{fighters:true,movementCard:card,ornithopter:'range3'}), /cannot flip/);
+ assert.deepEqual(g,before);
+ const done = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'range3'});
+ assert.equal(isAdvisor(done.players[0],'arrakeen'),true);
+ assert.equal(done.players[0].advisors!.arrakeen.lockedTurn,g.turn);
+ assert.equal(fighterCount(done.players[0],'arrakeen'),0);
+ assert.equal(done.discard.filter(c => c.id === card).length,1);
+});
diff --git a/tests/ornithopter-advisors-controls.test.tsx b/tests/ornithopter-advisors-controls.test.tsx
new file mode 100644
index 00000000..1e501b88
--- /dev/null
+++ b/tests/ornithopter-advisors-controls.test.tsx
@@ -0,0 +1,109 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { OrnithopterMovement } from '../components/ornithopter-movement';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { richeseCards } from '../game/richese-cards';
+import { botMovementRange } from '../game/bot-mobility';
+
+const firstSource = 'arrakeen:10', secondSource = 'carthag:11';
+const move = (from = firstSource, amount = 1): Action => ({
+ type: 'move', from, amount, elite: 0, territory: 'polar_sink', sector: 0,
+});
+
+/** Controlled personalized UI projection, not an earned game history or native
+ * runtime proof. Genuine acquisition, advisor entry/flip and movement are
+ * verified by the separate original-action integration fixtures. */
+function controlledView(active = false): GameView {
+ const source = createGame('ORNITHOPTERADVISORUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['choam']);
+ joinGame(source, newPlayer('emperor', 'Emperor', 'emperor'));
+ const game = viewGame(source, 'bg'), card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card);
+ game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = 'bg'; game.storm = 18;
+ const own = game.players.find(player => player.id === 'bg')!;
+ own.forces = { [firstSource]: 2, [secondSource]: 2 };
+ own.advisors = { arrakeen: {}, carthag: {} };
+ game.players.find(player => player.id === 'emperor')!.forces = { [firstSource]: 1, [secondSource]: 1 };
+ game.ornithopter = { card, blocked: null,
+ modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }],
+ active: active ? { event: 'original-flight-event', mode: 'twoGroups', completed: 1, remaining: 1,
+ cohort: { forces: { [secondSource]: 2 }, elites: {} } } : null };
+ return game;
+}
+function markup(game: GameView, draft: Action | null = move(), busy = false, unavailableReason?: string): string {
+ return renderToStaticMarkup(createElement(OrnithopterMovement, {
+ game, move: draft, busy, unavailableReason,
+ act() { assert.fail('Static controls must not submit a movement or start a countdown'); },
+ }));
+}
+function movementButton(html: string): string {
+ const button = /]*>(?:Play Ornithopter and move selected group|Move next selected group)<\/button>/.exec(html);
+ assert.ok(button, html); return button[0];
+}
+
+void test('initial Advanced advisor controls offer both printed modes with persistent guidance and an enabled selected group', () => {
+ const html = markup(controlledView());
+ assert.match(html, /Use Ornithopter for this movement/);
+ assert.match(html, /]*value="range3"[^>]*>One group, up to three territories/);
+ assert.match(html, / ]*value="twoGroups"[^>]*>Two different groups, normal movement/);
+ assert.doesNotMatch(html, /awaits.*ruling|advisor use.*unavailable/i);
+ assert.doesNotMatch(movementButton(html), /\sdisabled=/);
+ assert.match(html, /Advanced Bene Gesserit advisors can use both card modes/);
+ assert.match(html, /one-group mode independently gives range three/);
+ assert.match(html, /Two-group mode uses your current normal range and different original forces/);
+ assert.match(html, /Normal entry and advisor conversion still apply/);
+ assert.match(movementButton(html), /aria-describedby="[^"]+-advisor-guidance"/);
+});
+
+void test('the active two-group advisor control retains the original distinct unmoved cohort', () => {
+ const html = markup(controlledView(true), move(secondSource, 2));
+ assert.match(html, /Ornithopter movement in progress/);
+ assert.match(html, /1 group moved · 1 remaining/);
+ assert.match(html, /The next group must use different original forces/);
+ assert.match(html, /Original unmoved forces available for selection/);
+ assert.match(html, /Carthag, sector 11: 2 forces/);
+ assert.doesNotMatch(movementButton(html), /\sdisabled=/);
+ assert.match(html, /Advanced Bene Gesserit advisors can use both card modes/);
+});
+
+void test('busy and unavailable advisor movement remain disabled with a readable explanation', () => {
+ assert.match(movementButton(markup(controlledView(), move(), true)), /\sdisabled=""/);
+ const unavailable = markup(controlledView(), move(), false, 'The original territory entry is blocked.');
+ assert.match(unavailable, /The original territory entry is blocked/);
+ assert.match(movementButton(unavailable), /\sdisabled=""/);
+ assert.match(movementButton(unavailable), /aria-describedby="[^"]+-advisor-guidance [^"]+-reason"/);
+ const blocked = controlledView(); blocked.ornithopter!.blocked = 'Finish the original response first.';
+ assert.match(markup(blocked), /Finish the original response first/);
+ assert.match(movementButton(markup(blocked)), /\sdisabled=""/);
+ assert.match(movementButton(markup(controlledView(), null)), /\sdisabled=""/);
+});
+
+void test('already moved and stale physical advisor cohorts cannot be submitted again', () => {
+ const reused = markup(controlledView(true), move(firstSource));
+ assert.match(reused, /Choose forces that have not moved with this card. The first group cannot move again/);
+ assert.match(movementButton(reused), /\sdisabled=""/);
+ const stale = controlledView(true);
+ stale.players.find(player => player.id === 'bg')!.forces[secondSource] = 0;
+ const lost = markup(stale, move(secondSource, 2));
+ assert.match(lost, /selected force counts are no longer available/i);
+ assert.match(movementButton(lost), /\sdisabled=""/);
+});
+
+void test('advisors alone in either city grant no city range even while the independent card control is available', () => {
+ const game = controlledView(), own = game.players.find(player => player.id === 'bg')!;
+ assert.equal(botMovementRange(game, own, 0), 1);
+ const html = markup(game);
+ assert.match(html, /Advisors in Arrakeen or Carthag do not grant the city’s movement range/);
+ assert.doesNotMatch(movementButton(html), /\sdisabled=/);
+ own.advisors = {};
+ assert.equal(botMovementRange(game, own, 0), 3, 'The native public range helper still distinguishes city fighters');
+});
+
+void test('Advanced BG guidance does not appear for Basic or another faction, and absent offers stay absent', () => {
+ const basic = controlledView(); basic.advanced = false;
+ assert.doesNotMatch(markup(basic), /Advanced Bene Gesserit advisors/);
+ const other = controlledView(); other.players.find(player => player.id === 'bg')!.faction = 'emperor';
+ assert.doesNotMatch(markup(other), /Advanced Bene Gesserit advisors/);
+ const absent = controlledView(); absent.ornithopter = null;
+ assert.equal(markup(absent), '');
+});
diff --git a/tests/ornithopter-advisors-engine.test.ts b/tests/ornithopter-advisors-engine.test.ts
new file mode 100644
index 00000000..e38f5c5e
--- /dev/null
+++ b/tests/ornithopter-advisors-engine.test.ts
@@ -0,0 +1,135 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { viewGame, type Game } from '../game/engine';
+import { fighterCount, isAdvisor } from '../game/advisors';
+import { botMovementRange, groundMoveRouteDistance } from '../game/bot-mobility';
+import { botActions } from '../game/bots';
+import { splitLocation } from '../game/board';
+import {
+ actOrnithopterAdvisor, completeOrnithopterAdvisors, createOrnithopterAdvisorFixture,
+ ornithopterAdvisorMove, ornithopterAdvisorPlayer as player, type OrnithopterAdvisorFixture,
+} from './fixture-ornithopter-advisors';
+
+const own = (game: Game) => viewGame(game, 'beneGesserit').players.find(player => player.id === 'beneGesserit')!;
+const normalRange = (game: Game) => botMovementRange(viewGame(game, 'beneGesserit'), own(game), 0);
+const total = (game: Game) => Object.values(player(game).forces).reduce((sum, count) => sum + count, 0);
+function source(f: OrnithopterAdvisorFixture) {
+ assert.equal(f.game.advanced, true);
+ assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' && step.action.territory === 'arrakeen'));
+ assert.equal(player(f.afterSetup).forces[f.advisorSource], 1);
+ assert.equal(isAdvisor(player(f.afterSetup), 'arrakeen'), true);
+ assert.equal(player(f.afterSetup, 'atreides').forces[f.advisorSource], 10);
+ assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
+ assert.ok(player(f.acquired).hand.some(card => card.id === f.card));
+ assert.equal(player(f.beforeForeignShipment, 'richese').spice - player(f.afterAccompaniment, 'richese').spice, 1);
+ assert.equal(f.beforeAccompaniment.decision?.kind, 'advisor');
+ assert.equal(player(f.beforeAccompaniment).reserves - player(f.afterAccompaniment).reserves, 1);
+ assert.equal(player(f.beforeAccompaniment).spice, player(f.afterAccompaniment).spice);
+ assert.equal(player(f.game).forces[f.advisorSource], 2);
+ assert.equal(player(f.game).advisors!.arrakeen.lockedTurn, f.game.turn);
+ assert.equal(player(f.beforeReinforcement).spice - player(f.afterReinforcement).spice, f.fighterCity ? 1 : 2);
+ assert.equal(player(f.game).forces[f.fighterSource], 1);
+ assert.equal(isAdvisor(player(f.game), splitLocation(f.fighterSource).territory), false);
+ assert.equal(total(f.game), 3);
+ assert.equal(fighterCount(player(f.game), 'arrakeen'), 0);
+ assert.equal(normalRange(f.game), f.fighterCity ? 3 : 1);
+ assert.equal(f.sourceActionCount, f.actions.length);
+ assert.equal(viewGame(f.game, f.owner).ornithopter!.blocked, null);
+}
+function settled(f: OrnithopterAdvisorFixture, result: ReturnType, count: number) {
+ const done = result.completed;
+ assert.equal(player(done).moved, count);
+ assert.equal(own(done).movesAllowed, count);
+ assert.equal(done.ornithopter ?? null, null);
+ assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
+ assert.equal(player(done).hand.some(card => card.id === f.card), false);
+ assert.equal(total(done), total(f.game));
+ assert.equal(player(done).spice, player(f.game).spice);
+ assert.equal(player(done).reserves, player(f.game).reserves);
+ assert.equal(player(done).tanks, player(f.game).tanks);
+ assert.equal(result.actions.at(-1)!.action.type, 'endMovement');
+}
+
+void test('genuine Advanced advisors use paid physical Ornithopter range three without fighter city access', () => {
+ const f = createOrnithopterAdvisorFixture();
+ source(f);
+ const proposed = ornithopterAdvisorMove(f.game, f.advisorSource, { mode: 'range3', range: 3 });
+ const ordinary = { ...proposed };
+ delete ordinary.movementCard;
+ delete ordinary.ornithopter;
+ assert.throws(() => actOrnithopterAdvisor(f.game, f.owner, ordinary), /far|range|route|long|more than/i,
+ 'Advisors occupying Arrakeen do not supply ordinary three-territory access');
+ const result = completeOrnithopterAdvisors(f, 'range3');
+ assert.equal(groundMoveRouteDistance(viewGame(f.game, f.owner), own(f.game), f.advisorSource, result.firstDestination), 3);
+ assert.equal(isAdvisor(player(result.completed), 'arrakeen'), true);
+ assert.equal(player(result.completed).forces[f.advisorSource], 1);
+ assert.equal(isAdvisor(player(result.completed), splitLocation(result.firstDestination).territory), false);
+ assert.equal(fighterCount(player(result.completed), splitLocation(result.firstDestination).territory), 1,
+ 'An advisor arriving alone in an empty territory automatically becomes a fighter');
+ assert.equal(normalRange(result.completed), 1);
+ settled(f, result, 1);
+});
+
+void test('genuine Advanced advisor and fighter groups remain distinct within one physical two-group flight', () => {
+ const f = createOrnithopterAdvisorFixture();
+ source(f);
+ const result = completeOrnithopterAdvisors(f);
+ assert.equal(result.moves[0].step.action.from, f.advisorSource);
+ assert.equal(result.moves[1].step.action.from, f.fighterSource);
+ assert.equal(isAdvisor(player(result.moves[0].before), 'arrakeen'), true);
+ assert.equal(isAdvisor(player(result.moves[1].before), splitLocation(f.fighterSource).territory), false);
+ const active = viewGame(result.first, f.owner).ornithopter!.active!;
+ assert.equal(active.completed, 1);
+ assert.equal(active.cohort!.forces[f.advisorSource], 1);
+ assert.equal(active.cohort!.forces[f.fighterSource], 1);
+ assert.equal(active.cohort!.forces[result.firstDestination] ?? 0, 0);
+ assert.equal(isAdvisor(player(result.first), splitLocation(result.firstDestination).territory), false);
+ const repeated = ornithopterAdvisorMove(result.first, result.firstDestination);
+ assert.throws(() => actOrnithopterAdvisor(result.first, f.owner, repeated), /cohort|unmoved|original|force/i,
+ 'The first advisor group cannot be reused merely because its empty arrival changed its stance');
+ assert.equal(result.moves[1].step.action.ornithopterEvent, active.event);
+ assert.equal(player(result.completed).forces[f.advisorSource], 1);
+ assert.equal(isAdvisor(player(result.completed), 'arrakeen'), true);
+ settled(f, result, 2);
+});
+
+void test('genuine two-group flight recomputes normal advisor range after the last city fighter leaves', () => {
+ const f = createOrnithopterAdvisorFixture({ fighterCity: true });
+ source(f);
+ const result = completeOrnithopterAdvisors(f);
+ assert.equal(result.moves[0].step.action.from, 'carthag:11');
+ assert.equal(groundMoveRouteDistance(viewGame(f.game, f.owner), own(f.game), f.fighterSource, result.firstDestination), 3);
+ assert.equal(fighterCount(player(result.first), 'carthag'), 0);
+ assert.equal(fighterCount(player(result.first), 'arrakeen'), 0);
+ assert.equal(player(result.first).forces[f.advisorSource], 2);
+ assert.equal(normalRange(result.first), 1);
+ const tooFar = ornithopterAdvisorMove(result.first, f.advisorSource, { range: 3 });
+ assert.throws(() => actOrnithopterAdvisor(result.first, f.owner, tooFar), /far|range|route|long|more than/i,
+ 'Two-group mode uses current normal range rather than retaining the first group\'s city access');
+ assert.equal(result.moves[1].step.action.from, f.advisorSource);
+ assert.equal(result.moves[1].step.action.movementCard, undefined);
+ assert.equal(typeof result.moves[1].step.action.ornithopterEvent, 'string');
+ assert.equal(normalRange(result.completed), 1);
+ settled(f, result, 2);
+});
+
+void test('all four minimal policies retain legal original advisor card and unmoved-group continuations', () => {
+ const f = createOrnithopterAdvisorFixture();
+ const { first } = completeOrnithopterAdvisors(f);
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const startView = viewGame(f.game, f.owner);
+ startView.players.find(player => player.id === f.owner)!.bot = difficulty;
+ const advisor = botActions(startView).find(action => action.type === 'move' &&
+ action.movementCard === f.card && action.from === f.advisorSource);
+ assert.ok(advisor, `${difficulty} retains a physical-card move from the actual advisor source`);
+ const started = actOrnithopterAdvisor(structuredClone(f.game), f.owner, advisor);
+ assert.ok(player(started).moved === 1 || started.response || started.decision);
+ const pendingView = viewGame(first, f.owner);
+ pendingView.players.find(player => player.id === f.owner)!.bot = difficulty;
+ const second = botActions(pendingView).find(action => action.type === 'move' &&
+ action.ornithopterEvent === pendingView.ornithopter!.active!.event);
+ assert.ok(second, `${difficulty} retains a distinct original second group`);
+ const completed = actOrnithopterAdvisor(structuredClone(first), f.owner, second);
+ assert.ok(player(completed).moved === 2 || completed.response || completed.decision);
+ }
+});
diff --git a/tests/ornithopter-engine.test.ts b/tests/ornithopter-engine.test.ts
index bb71510a..5db0f700 100644
--- a/tests/ornithopter-engine.test.ts
+++ b/tests/ornithopter-engine.test.ts
@@ -67,12 +67,11 @@ void test('played card escrow, owner-only remaining quotas and ended movement su
assert.equal(ended.active,'q');
});
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
- for(const change of ['range','storm','prior','advisor','gift','wrongMode'] as const){
- const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
+ for(const change of ['range','storm','prior','gift','wrongMode'] as const){
+ const g=fixture();
let key=destination(g,origin,3);const extra:Partial={movementCard:card,ornithopter:'range3'};
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
if(change==='prior')g.players[0].moved=1;
- if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
if(change==='wrongMode')extra.ornithopter='both';
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
const before=structuredClone(g);assert.throws(()=>move(g,key,extra),change);assert.deepEqual(g,before);