From f6e6671ac5a80b020e3da0405e98cbcc85035d69 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Mon, 26 Oct 2026 09:30:02 +1100
Subject: [PATCH] Support Planetologist range for concealed Richese No-Fields
---
components/planetologist-movement-action.tsx | 190 ++++++++++++
components/planetologist-movement.tsx | 208 +++++--------
docs/CURRENT_STATUS.md | 48 ++-
docs/IMPLEMENTATION_STATUS.md | 31 ++
docs/PLANETOLOGIST_RULES.md | 50 ++-
docs/RICHESE_NO_FIELD_RULES.md | 13 +
docs/RULE_DECISIONS.md | 11 +
game/bots.ts | 33 +-
game/engine.ts | 8 +-
game/reference.ts | 22 +-
tests/fixture-planetologist-no-field.ts | 148 +++++++++
tests/native-richese-skills.test.ts | 1 +
.../planetologist-no-field-controls.test.tsx | 285 ++++++++++++++++++
tests/planetologist-no-field-engine.test.ts | 174 +++++++++++
14 files changed, 1041 insertions(+), 181 deletions(-)
create mode 100644 components/planetologist-movement-action.tsx
create mode 100644 tests/fixture-planetologist-no-field.ts
create mode 100644 tests/planetologist-no-field-controls.test.tsx
create mode 100644 tests/planetologist-no-field-engine.test.ts
diff --git a/components/planetologist-movement-action.tsx b/components/planetologist-movement-action.tsx
new file mode 100644
index 00000000..83165596
--- /dev/null
+++ b/components/planetologist-movement-action.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import {
+ planetologistLeader,
+ planetologistMovementModeSupported,
+ selectedOriginElites,
+ planetologistGatherAdvisorEntry,
+ type PlanetologistMoveMode,
+} from '@/game/planetologist-movement';
+import { botGroundMoveAllowed } from '@/game/bot-mobility';
+import { isAdvisor } from '@/game/advisors';
+import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
+import { location, splitLocation, validGameLocation } from '@/game/board';
+import { nexusGuildMovementAvailable } from '@/game/nexus-guild-cunning-options';
+import { Button } from './ui/button';
+
+type Counts = Record;
+type Draft = { action: Action | null; blocked: string | null };
+const record = (value: unknown): value is Counts =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+
+/** Only the owning seat's current deployed identity is a selectable marker. */
+export function planetologistNoFieldMarker(game: GameView) {
+ const me = game.players.find(player => player.id === game.me);
+ const info = game.richeseNoField;
+ if (!me || me.faction !== 'richese' || info?.owner !== me.id) return null;
+ const state = info.private;
+ const marker = state?.deployed;
+ const publicMarker = info.public.deployed;
+ if (!state || !marker || marker.controller !== me.id ||
+ !publicMarker || publicMarker.controller !== me.id ||
+ marker.location.territory !== publicMarker.location.territory ||
+ marker.location.sector !== publicMarker.location.sector ||
+ marker.tokenId !== state.lastShipped ||
+ !state.tokens.some(token => token.id === marker.tokenId) ||
+ typeof info.event !== 'string' || !info.event.trim() ||
+ !Number.isSafeInteger(marker.location.sector) ||
+ !validGameLocation(game, marker.location.territory, marker.location.sector))
+ return null;
+ return { tokenId: marker.tokenId, event: info.event, location: { ...marker.location } };
+}
+
+export function planetologistMoveDraft(
+ game: GameView,
+ mode: PlanetologistMoveMode,
+ destination: string,
+ sector: number,
+ forces: Counts,
+ elites: Counts,
+ includeNoField = false,
+): Draft {
+ const me = game.players.find((player) => player.id === game.me);
+ if (!me || !planetologistLeader(game, me.id))
+ return { action: null, blocked: 'A living, uncaptured Planetologist is required.' };
+ if (!planetologistMovementModeSupported(game))
+ return {
+ action: null,
+ blocked: 'Planetologist movement with expansion factions awaits its movement audit.',
+ };
+ if (mode !== 'range' && mode !== 'gather')
+ return { action: null, blocked: 'Choose a Planetologist movement mode.' };
+ if (!Number.isSafeInteger(sector) || !validGameLocation(game, destination, sector))
+ return { action: null, blocked: 'Choose an available destination and sector.' };
+ if (!record(forces) || !record(elites))
+ return { action: null, blocked: 'Choose available whole force counts.' };
+ const entered = Object.entries(forces);
+ if (entered.some(([from, count]) => {
+ const source = splitLocation(from);
+ return !Number.isSafeInteger(count) || count < 0 || count > (me.forces[from] ?? 0) ||
+ !validGameLocation(game, source.territory, source.sector) ||
+ from !== location(source.territory, source.sector);
+ })) return { action: null, blocked: 'Choose available whole force counts at exact board locations.' };
+ const selected = entered.filter(([, count]) => count > 0);
+ for (const [from, count] of selected) {
+ const owned = me.forces[from] ?? 0;
+ const elite = elites[from] ?? 0;
+ const ownedElite = me.elites?.forces[from] ?? 0;
+ const minimumElite = Math.max(0, count - (owned - ownedElite));
+ if (!Number.isSafeInteger(elite) || elite < minimumElite || elite > Math.min(count, ownedElite))
+ return { action: null, blocked: 'Choose an available normal and elite mix for every source.' };
+ }
+ const marker = includeNoField ? planetologistNoFieldMarker(game) : null;
+ if (includeNoField) {
+ if (mode !== 'range')
+ return { action: null, blocked: 'A concealed No-Field can use extra range, but cannot join a Planetologist gather.' };
+ if (!marker)
+ return { action: null, blocked: 'Choose your current deployed No-Field.' };
+ if (game.homeworldMobility?.noFieldMovementBlocked)
+ return { action: null, blocked: game.homeworldMobility.noFieldMovementBlocked };
+ if (selected.some(([from]) => splitLocation(from).territory !== marker.location.territory))
+ return { action: null, blocked: 'The selected No-Field and physical forces must share one origin territory.' };
+ }
+ const origins = new Set(selected.map(([from]) => splitLocation(from).territory));
+ if (marker) origins.add(marker.location.territory);
+ if ((mode === 'range' && origins.size !== 1) || (mode === 'gather' && origins.size !== 2))
+ return {
+ action: null,
+ blocked: mode === 'range'
+ ? 'Select forces from one territory, or your deployed No-Field.'
+ : 'Select forces from exactly two different territories.',
+ };
+ const entry = planetologistGatherAdvisorEntry(game, me, [...origins], destination);
+ if (entry.blocked) return { action: null, blocked: entry.blocked };
+ const target = location(destination, sector);
+ const sources = [...selected.map(([from]) => from), ...(marker ? [location(marker.location.territory, marker.location.sector)] : [])];
+ if (sources.some(from => !botGroundMoveAllowed(game, me, from, target,
+ selectedOriginElites(selected, elites, splitLocation(from).territory), mode)))
+ return {
+ action: null,
+ blocked: mode === 'range'
+ ? 'The selected route is beyond the boosted range or is blocked.'
+ : 'Each selected source must reach the destination using its normal range.',
+ };
+ if (mode === 'gather') {
+ try {
+ quoteAdvisorGatherMatching(game, { owner: me.id, move: me.moved ?? 0, destination, sector,
+ advisors: isAdvisor(me, destination), group: Object.fromEntries(selected) });
+ } catch (error) {
+ if (!(error instanceof AdvisorGatherMatchingError)) throw error;
+ return { action: null, blocked: error.message };
+ }
+ }
+ return {
+ action: {
+ type: 'move', planetologist: mode,
+ ...(entry.requiresFlip ? { fighters: true } : {}),
+ forces: Object.fromEntries(selected),
+ ...(me.elites ? { eliteForces: Object.fromEntries(selected.map(([from]) => [from, elites[from] ?? 0])) } : {}),
+ ...(marker ? { noField: marker.tokenId, event: marker.event } : {}),
+ territory: destination, sector,
+ },
+ blocked: null,
+ };
+}
+
+export function planetologistMovementBlocked(game: GameView, draft: Draft, unavailableReason?: string | null): string | null {
+ const me = game.players.find(player => player.id === game.me);
+ return unavailableReason ??
+ (!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id
+ ? 'Planetologist movement is available only during your movement turn.'
+ : game.ornithopter?.active
+ ? 'Finish the current Ornithopter movement first.'
+ : game.response || game.decision || game.phaseOpening || game.truthtrance ||
+ game.nexusTraitors?.pending || game.nexusCards?.waiting.length
+ ? 'Resolve the current response or decision first.'
+ : me.autopilot || game.automaticContinuationPending
+ ? 'Wait for the current automatic action to finish.'
+ : !nexusGuildMovementAvailable(game)
+ ? 'No ordinary movement remains this turn.'
+ : draft.blocked);
+}
+
+export interface PlanetologistMovementActionProps {
+ game: GameView;
+ mode: PlanetologistMoveMode;
+ destination: string;
+ sector: number;
+ forces: Counts;
+ elites: Counts;
+ includeNoField?: boolean;
+ busy: boolean;
+ unavailableReason?: string | null;
+ reasonId?: string;
+ act: (action: Action) => void;
+}
+
+/** Hook-free leaf owns the actual guarded submission callback. */
+export function PlanetologistMovementAction({ game, mode, destination, sector, forces, elites,
+ includeNoField = false, busy, unavailableReason, reasonId, act }: PlanetologistMovementActionProps) {
+ const draft = planetologistMoveDraft(game, mode, destination, sector, forces, elites, includeNoField);
+ const blocked = planetologistMovementBlocked(game, draft, unavailableReason);
+ const marker = includeNoField ? planetologistNoFieldMarker(game) : null;
+ return ;
+}
diff --git a/components/planetologist-movement.tsx b/components/planetologist-movement.tsx
index 735482f0..8c17407f 100644
--- a/components/planetologist-movement.tsx
+++ b/components/planetologist-movement.tsx
@@ -4,145 +4,61 @@ import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
planetologistLeader,
- planetologistMovementModeSupported,
- selectedOriginElites,
- planetologistGatherAdvisorEntry,
type PlanetologistMoveMode,
} from '@/game/planetologist-movement';
-import { botGroundMoveAllowed } from '@/game/bot-mobility';
-import { isAdvisor } from '@/game/advisors';
-import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
import {
gameTerritories,
- location,
splitLocation,
territory,
} from '@/game/board';
-import { Button } from './ui/button';
+import {
+ PlanetologistMovementAction,
+ planetologistMoveDraft,
+ planetologistMovementBlocked,
+ planetologistNoFieldMarker,
+} from './planetologist-movement-action';
import { Input } from './ui/input';
+export { planetologistMoveDraft } from './planetologist-movement-action';
+
type Counts = Record;
-export function planetologistMoveDraft(
- game: GameView,
- mode: PlanetologistMoveMode,
- destination: string,
- sector: number,
- forces: Counts,
- elites: Counts,
-): { action: Action | null; blocked: string | null } {
- const me = game.players.find((player) => player.id === game.me);
- if (!me || !planetologistLeader(game, me.id))
- return {
- action: null,
- blocked: 'A living, uncaptured Planetologist is required.',
- };
- if (
- !planetologistMovementModeSupported(game)
- )
- return {
- action: null,
- blocked:
- 'Planetologist movement with expansion factions awaits its movement audit.',
- };
- const selected = Object.entries(forces).filter(([, count]) => count > 0);
- for (const [from, count] of selected) {
- const owned = me.forces[from] ?? 0;
- if (!Number.isSafeInteger(count) || count < 1 || count > owned)
- return { action: null, blocked: 'Choose available whole force counts.' };
- const elite = elites[from] ?? 0;
- const ownedElite = me.elites?.forces[from] ?? 0;
- const minimumElite = Math.max(0, count - (owned - ownedElite));
- if (
- !Number.isSafeInteger(elite) ||
- elite < minimumElite ||
- elite > Math.min(count, ownedElite)
- )
- return {
- action: null,
- blocked: 'Choose an available normal and elite mix for every source.',
- };
- }
- const origins = new Set(
- selected.map(([from]) => splitLocation(from).territory),
- );
- if (
- (mode === 'range' && origins.size !== 1) ||
- (mode === 'gather' && origins.size !== 2)
- )
- return {
- action: null,
- blocked:
- mode === 'range'
- ? 'Select forces from one territory.'
- : 'Select forces from exactly two different territories.',
- };
- const entry = planetologistGatherAdvisorEntry(game, me, [...origins], destination);
- if (entry.blocked) return { action: null, blocked: entry.blocked };
- const target = location(destination, sector);
- if (
- selected.some(
- ([from]) =>
- !botGroundMoveAllowed(game, me, from, target,
- selectedOriginElites(selected, elites, splitLocation(from).territory), mode),
- )
- )
- return {
- action: null,
- blocked:
- mode === 'range'
- ? 'The selected route is beyond the boosted range or is blocked.'
- : 'Each selected source must reach the destination using its normal range.',
- };
- if(mode==='gather'){
- try{
- quoteAdvisorGatherMatching(game,{owner:me.id,move:me.moved??0,destination,sector,
- advisors:isAdvisor(me,destination),group:Object.fromEntries(selected)});
- }catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return {action:null,blocked:error.message};}
- }
- return {
- action: {
- type: 'move',
- planetologist: mode,
- ...(entry.requiresFlip ? { fighters: true } : {}),
- forces: Object.fromEntries(selected),
- ...(me.elites
- ? {
- eliteForces: Object.fromEntries(
- selected.map(([from]) => [from, elites[from] ?? 0]),
- ),
- }
- : {}),
- territory: destination,
- sector,
- },
- blocked: null,
- };
-}
-
-export function PlanetologistMovement({
- game,
- act,
- busy,
- destination,
- sector,
- unavailableReason,
-}: {
+type Props = {
game: GameView;
act: (action: Action) => void;
busy: boolean;
destination: string;
sector: number;
unavailableReason?: string | null;
-}) {
+};
+
+export function PlanetologistMovement(props: Props) {
+ const marker = planetologistNoFieldMarker(props.game);
+ const me = props.game.players.find(player => player.id === props.game.me);
+ return ;
+}
+
+function PlanetologistMovementDraft({
+ game,
+ act,
+ busy,
+ destination,
+ sector,
+ unavailableReason,
+}: Props) {
const id = useId();
const [mode, setMode] = useState('range');
const [forces, setForces] = useState({});
const [elites, setElites] = useState({});
+ const [includeNoField, setIncludeNoField] = useState(false);
const me = game.players.find((player) => player.id === game.me);
const leader = me && planetologistLeader(game, me.id);
if (!me || !leader) return null;
const sources = Object.entries(me.forces).filter(([, count]) => count > 0);
+ const marker = planetologistNoFieldMarker(game);
const draft = planetologistMoveDraft(
game,
mode,
@@ -150,18 +66,9 @@ export function PlanetologistMovement({
sector,
forces,
elites,
+ includeNoField,
);
- const blocked =
- unavailableReason ??
- (game.phase !== 5 || game.active !== me.id
- ? 'Planetologist movement is available only during your movement turn.'
- : game.ornithopter?.active
- ? 'Finish the current Ornithopter movement first.'
- : game.truthtrance || game.nexusTraitors?.pending
- ? 'Resolve the current private response first.'
- : me.autopilot || game.automaticContinuationPending
- ? 'Wait for the current automatic action to finish.'
- : draft.blocked);
+ const blocked = planetologistMovementBlocked(game, draft, unavailableReason);
const leaderName =
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
leader;
@@ -184,9 +91,11 @@ export function PlanetologistMovement({
id={`${id}-mode`}
value={mode}
disabled={busy}
- onChange={(event) =>
- setMode(event.target.value as PlanetologistMoveMode)
- }
+ onChange={(event) => {
+ const nextMode = event.target.value as PlanetologistMoveMode;
+ setMode(nextMode);
+ if (nextMode !== 'range') setIncludeNoField(false);
+ }}
>
@@ -195,6 +104,29 @@ export function PlanetologistMovement({
Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below.
+ {marker && (
+ <>
+
+
+ Provisional range composition: the concealed marker moves as one
+ force with Planetologist’s extra range, up to three territories.
+ Move it alone or with physical forces from its own territory; its
+ value stays concealed. No direct joint FAQ settles this combination.
+
Provisional mixed-gather policy: both groups arrive as fighters. The
@@ -258,25 +190,21 @@ export function PlanetologistMovement({
);
})
) : (
-
No board forces are available to move.
+
No physical board forces are available to move.
)}
{blocked && (
)}
-
+
- This does not combine with a No-Field or either Ornithopter movement.
+ Concealed No-Fields cannot join gather, either Ornithopter card movement
+ or Discovery flight.
Normal movement and arrival rules still apply.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 685e6fe7..df536b08 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,36 @@ open mode gates.
## Current checkpoint and work
+**Planetologist and Richese No-Field range — 26 October. Prototyped / Partial.**
+The owner's concealed marker can use the ordinary extra-range alternative alone
+or with selected same-territory physical forces. Shared range/entry quotes retain
+the three-territory cap, living trainer, original token/event and one movement.
+The marker remains concealed, including0, without materializing or withdrawing
+reserves. The form separates its checkbox from physical counters, labels the
+composition provisional and guards actual submission. Minimal policy retains
+legal boosted candidates by applying its limit after reachability. Existing
+action/state/mode shapes stay; gather and special transport remain separate.
+
+Two disjoint native/control lanes and root integration pass **8 files,60/60**,
+types/lint/build and **twelve native engine/GameTable SSR/actual selected-props
+submit-callback paths** on unchanged source. Genuine public R/A/E +choam/Skills
+sources train the original leader, earn an actual paid marker shipment and,
+for companions, an earlier paid physical shipment. Basic sources use37/74 actions;
+Advanced sources use41/79–80. Two-to-four continuation actions move distance two
+and finish same-turn Collection. Each retains45 Treachery,14 Skills and15 Traitor
+cards. Fifteen native cases and thirteen controlled cases cover sources,
+rejections, current guards and all four minimal profiles. Feedback fixes the AI
+candidate cutoff and an old Suk fixture's missing outcome acknowledgements;
+test typing and unused imports are corrected.
+
+Frozen v4 report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-no-field-proof-v4/`;
+journeys/HTML: adjacent `20261026-planetologist-no-field-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
+SSR/selected-props callbacks do not prove DOM/hydration, browser/mobile/deployed
+acceptance or complete modes/combinations. Final assurance remains open.
+[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-ordinary-range--26-october-2026).
+
**Typed Emperor Face Dance return — 26 October. Prototyped / Partial.**
Advanced/Homeworld returns from Arrakis and visitor battles now use existing
native reserve quotes: normal survivors to Kaitain, Sardaukar to Salusa. This
@@ -74,15 +104,15 @@ deployed acceptance. Full Truthtrance, modes/combinations and final assurance
remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
[shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
-Next scoped interaction: Planetologist's ordinary extra range for Richese's own
-concealed No-Field marker. The live combined-movement fence still rejects it;
-the printed marker-as-one-force and ordinary-range rules support a narrow
-composition, not a located joint FAQ. Start with marker-only ordinary movement,
-existing token/history and native trained-leader setup. Keep gather, flight,
-Ornithopter, Guild and Homeworld routes outside that first slice. See
-[No-Field source](RICHESE_NO_FIELD_RULES.md) and
-[Planetologist contract](PLANETOLOGIST_RULES.md). This scope does not establish
-Basic completion or authorize a combination/recovery campaign.
+Next scoped interaction: Planetologist's two-origin gather with Richese's own
+concealed marker. The normalized group already records its source and selected
+physical origins; the remaining gather fence, controls and native earned-source
+chronology need integration. Each origin must retain its ordinary range, with
+one shared destination and one movement. This prospective composition is not a
+located joint FAQ or permission to enable flight/Guild/Homeworld combinations.
+See [No-Field source](RICHESE_NO_FIELD_RULES.md) and
+[Planetologist contract](PLANETOLOGIST_RULES.md). Complete rules and final
+assurance remain unfinished.
**Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.**
The form now exposes two-through-eight-term AND/OR groups and nesting, with
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e4928289..c53d1d28 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,36 @@
# Dune implementation status
+## 26 October 2026 — Concealed No-Field Planetologist extra range
+
+**Prototyped / Partial.** Compose marker-as-one-force with Planetologist's
+ordinary +1 range/cap3 under the existing choose-recommended authority. Richese
+can select its own marker alone or with physical counters from the same origin.
+Current trainer/token/event, every selected sector's route, entry and one
+movement remain authoritative. The marker stays concealed, including0, without
+materializing or debiting reserves. Owner controls separate marker and counters,
+label the composition provisional and guard actual submit callbacks. Switching
+to gather clears marker selection. Minimal policy applies its candidate cap
+after reachability so legal boosted routes remain available. No strategic AI,
+new saved field/action, mode admission or transport combination is introduced.
+
+Frozen **8-file60/60**, types/lint/build and **twelve native engine/GameTable SSR/
+actual selected-props submit-callback paths** pass. Public R/A/E +choam/Skills
+sources earn actual leader training and paid shipments in both Basic/Advanced;
+all0/3/5 values travel alone or with one genuinely shipped companion. Sources
+use Basic37/74 and Advanced41/79–80 actions. Two-to-four continuation actions
+finish the original same-turn Collection, retaining45 Treachery,14 Skills and15
+Traitor cards. Fifteen native and thirteen controlled cases cover the slice,
+rejections, current guards and four minimal profiles. Feedback repairs premature
+AI candidate truncation, an existing Suk case's missing battle-outcome readiness,
+test typing and unused imports. Earlier failed reports remain available.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-no-field-proof-v4/`;
+journeys/HTML: adjacent `20261026-planetologist-no-field-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or recovery operation. SSR/
+selected-props callbacks do not prove DOM/hydration or browser/mobile/deployed
+acceptance. Gather, special transport, complete modes and final assurance remain
+open. [Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-ordinary-range--26-october-2026).
+
## 26 October 2026 — Typed Emperor Face Dance reserve return
**Prototyped / Partial.** Remove the Advanced/Homeworld assignment fence and
diff --git a/docs/PLANETOLOGIST_RULES.md b/docs/PLANETOLOGIST_RULES.md
index ffd35e7d..5c97f97c 100644
--- a/docs/PLANETOLOGIST_RULES.md
+++ b/docs/PLANETOLOGIST_RULES.md
@@ -1,9 +1,57 @@
# Planetologist rule contract
-Updated 24 October 2026. This note audits the movement and battle effects of the
+Updated 26 October 2026. This note audits the movement and battle effects of the
Planetologist Leader Skill against its physical card, the common Leader Skills
rules, the GF9 base battle rules and designer Jack Reda's walkthrough. The connected prototype is described below; other Leader Skill effects remain separate work.
+## Concealed No-Field ordinary range — 26 October 2026
+
+The printed No-Field rules (E2 pp5–6,10–12, recorded in the
+[token contract](RICHESE_NO_FIELD_RULES.md#verified-lifecycle)) let a concealed
+marker move as one force, including value0. Planetologist's physical normal band
+adds one to ordinary movement range, capped at three. Under the 14 October
+choose-recommended authority, compose these rules for Richese's own marker,
+alone or with selected physical forces from that same territory. This is an
+explicit provisional composition, not a located joint publisher FAQ.
+
+The existing `move` payload carries `planetologist:'range'`, the selected
+`forces`, and the owner's current `noField` identity/`event`. Normalized movement
+validates the living uncaptured trainer, current physical token and source,
+storm/path/entry and each selected sector using the unchanged range quote. The
+original marker moves once and remains concealed; no token value is converted
+to counters, no reserve is debited and no shipment payment is made. One movement
+is spent, its event rotates, and existing arrival/turn continuation remains.
+City ornithopters retain the maximum of three rather than granting four.
+
+The Planetologist form exposes the owner's marker separately from counters,
+including marker-only selection with an empty physical group. Same-origin
+companions remain ordinary physical counters. Actual callbacks retain movement,
+ownership, response, automatic-action and busy guards. Minimal policy adds only
+legal marker routes that need the extra range; no difficulty tuning occurs.
+
+The two-origin gather alternative, physical/Discovery Ornithopter, Guild and
+Homeworld combinations retain their separate boundaries. This change adds no
+saved field, new action kind, skill assignment, mode admission or publication
+acceptance. Full interactions and final browser/deployed assurance remain due.
+
+**Frozen evidence:** v4 passes8 files,60/60, types/lint/build and twelve native
+engine/GameTable SSR/actual selected-props submit-callback paths. Original public
+R/A/E +choam/Skills starts train the original leader and ship real0/3/5 markers;
+the companion was genuinely shipped on the previous turn. Basic sources use37/74
+actions and Advanced41/79–80. Two-to-four continuation actions move the original
+marker two territories and reach the same turn's Collection. Each retains45
+Treachery,14 Skills and15 Traitor cards. Fifteen native cases include all twelve
+sources plus rejections, controlled dead/captured eligibility supplements and
+four minimal profiles; thirteen controlled cases cover actual submission and
+current guards. Feedback corrects the candidate cutoff, old Suk readiness
+chronology, fixture typing and unused imports.
+
+Report/logs: private `20261026-planetologist-no-field-proof-v4/` under
+`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
+`20261026-planetologist-no-field-runtime/`. No saved-room/D1/server, reset/restart,
+deployment or recovery operation. SSR/selected-props callbacks do not certify
+DOM/hydration, browser/mobile/deployed acceptance or complete combinations/modes.
+
## Printed effect and legal card role
The physical Planetologist card instructs its owner to reveal one green
diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md
index 7739c15e..8e729014 100644
--- a/docs/RICHESE_NO_FIELD_RULES.md
+++ b/docs/RICHESE_NO_FIELD_RULES.md
@@ -2,6 +2,19 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
+## Planetologist ordinary extra range — 26 October 2026
+
+The own concealed marker can now use Planetologist's ordinary extra-range
+alternative, alone or with selected same-territory physical counters. The
+marker-as-one-force plus normal-range composition is explicitly provisional;
+no joint FAQ is claimed. Current token/event, living trainer, actual source,
+storm/path/entry and one movement remain authoritative. The marker stays
+concealed, including value0, without materializing or debiting reserves.
+Controls keep marker and physical counts distinct; minimal policy offers legal
+boosted marker routes without strategic refinement. Gather, flight, Guild and
+Homeworld combinations remain separate.
+[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-ordinary-range--26-october-2026).
+
## Owned movement-promise preparation — 25 October 2026
The [movement contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026)
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index e7af0d65..5f74e751 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,16 @@
# Rule decision index
+## Planetologist and concealed No-Field range — 26 October 2026
+
+Under 14 October choose-recommended authority, compose No-Field's printed
+movement as one force (E2 pp5–6,10–12) with Planetologist's ordinary +1 range,
+capped at3. Richese's own concealed marker, including0, may move alone or with
+selected same-origin physical forces without revealing/materializing its value.
+This is provisional composition, not a located joint publisher FAQ. Preserve
+current trainer/token/event, path/entry, one movement and existing arrival.
+Gather, flight, Guild and Homeworld combinations remain separate; no mode opens.
+[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-ordinary-range--26-october-2026).
+
## Typed Emperor Face Dance reserve return — 26 October 2026
Under 14 October choose-recommended authority, route Advanced/Homeworld
diff --git a/game/bots.ts b/game/bots.ts
index 7909e062..68ebf2d7 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -52,7 +52,7 @@ import {
} from './homeworld-options';
import { matchesPlanClaim, respectsBattlePromises } from './battle-promises';
import { ecazOccupancyRelation } from './ecaz-occupy';
-import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
+import { territoryEntryBlock } from './occupancy';
import { strongholdBattleEffects } from './stronghold-battle';
import {
eliteRevivalRemaining,
@@ -3471,36 +3471,27 @@ function policyActions(g: GameView): Action[] {
}
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
const from = location(marker.location.territory, marker.location.sector);
- const range =
- fighterCount(me, 'arrakeen') || fighterCount(me, 'carthag') ? 3 : 1;
+ const markerPlanetologist = !!planetologistLeader(g, me.id) &&
+ !g.ornithopter?.active && planetologistMovementModeSupported(g);
+ let markerMoves = 0;
if (marker.location.sector !== g.storm)
- for (const to of markerTargets.slice(0, 24)) {
+ for (const to of markerTargets) {
if (from === to.key || (materialized === 0 && to.enemy > 0)) continue;
- if (
- g.balisetRestrictions.some(
- (b) => b.player === me.id && b.territory === to.t,
- ) &&
- marker.location.territory !== to.t
- )
- continue;
- if (
- gameDistance(g, from, to.key, (key) => {
- const loc = splitLocation(key);
- return (
- loc.sector === g.storm ||
- strongholdPathBlocked(g.players, me.id, loc.territory)
- );
- }) > range
- )
- continue;
+ const ordinary = botGroundMoveAllowed(g, me, from, to.key, 0);
+ if (!ordinary && !(markerPlanetologist &&
+ botGroundMoveAllowed(g, me, from, to.key, 0, 'range'))) continue;
actions.push({
type: 'move',
+ ...(!ordinary ? { planetologist: 'range' } : {}),
forces: {},
noField: marker.tokenId,
event: noField.event,
territory: to.t,
sector: to.s,
});
+ // Bound legal choices after reachability, so unreachable high-ranked
+ // destinations cannot hide the only usable ordinary/skill route.
+ if (++markerMoves === 24) break;
}
}
// Generate paid Guild-source candidates before applying ordinary prices or Fremen range.
diff --git a/game/engine.ts b/game/engine.ts
index 4ef00867..25896c59 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -17364,8 +17364,12 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick !game.phaseOpening && !game.response &&
+ !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
+export function planetologistNoFieldPlayer(game: Game, actor = 'richese') {
+ const player = game.players.find(candidate => candidate.id === actor);
+ assert.ok(player, `The original ${actor} seat must remain present`);
+ return player;
+}
+export function planetologistNoFieldInventory(game: Game) {
+ assertSelectedDeckSkillsInventory(game);
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ return { treachery: cards.map(card => card.id).sort(),
+ skills: [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
+ ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort(),
+ traitors: [...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)].sort() };
+}
+/** The actual move spends no card, physical reserve or spice, including for0. */
+export function planetologistNoFieldPhysical(game: Game) {
+ return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
+ tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
+ leaders: structuredClone(player.leaders), hand: player.hand.map(card => card.id) }));
+}
+export function actPlanetologistNoField(game: Game, actor: string, action: Action,
+ actions: PlanetologistNoFieldStep[] = []): Game {
+ const next = applyAction(game, actor, action);
+ actions.push({ actor, action: structuredClone(action) }); return next;
+}
+export function advancePlanetologistNoField(game: Game, until: (state: Game) => boolean,
+ actions: PlanetologistNoFieldStep[] = []): Game {
+ for (let count = 0; count < 1600; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Planetologist/No-Field chronology ended before its owned boundary');
+ const step = nextChoamIndependentSkillsStep(game);
+ assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`);
+ game = actPlanetologistNoField(game, step.actor, step.action, actions);
+ }
+ throw Error('The native Planetologist/No-Field chronology did not reach its finite boundary');
+}
+
+function destinations(game: Game, actor: string, source: string) {
+ const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
+ const emptyRocks = gameTerritories(game).filter(place => place.type === 'rock' &&
+ place.id !== splitLocation(source).territory && !game.players.some(player => presenceAt(player, place.id)))
+ .flatMap(place => place.sectors.map(sector => location(place.id, sector)));
+ const targetLocation = emptyRocks.find(target => groundMoveRouteDistance(view, me, source, target) === 2 &&
+ botGroundMoveAllowed(view, me, source, target, 0, 'range') && !botGroundMoveAllowed(view, me, source, target, 0));
+ const tooFarLocation = emptyRocks.find(target => groundMoveRouteDistance(view, me, source, target) === 3 &&
+ botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(source).territory));
+ return targetLocation && tooFarLocation ? { targetLocation, tooFarLocation } : null;
+}
+
+/** Original public Basic/Advanced R/A/E +choam/Skills. The existing fixture selects only
+ * initial unopened scalar/skill lotteries; every later step is a native action.
+ * Optional companion1 is earned by an actual turn-one paid shipment, then the
+ * original marker is really shipped beside it on turn two. No state is installed. */
+export function createPlanetologistNoFieldFixture(options: { advanced?: boolean; value?: NoFieldValue; companions?: 0 | 1 } = {}) {
+ const advanced = options.advanced ?? false, value = options.value ?? 5, companions = options.companions ?? 0;
+ const setup = createSelectedDeckSkillsSetup({ advanced,
+ factions: ['richese', 'atreides', 'emperor'], expansions: ['choam'], skill: 'planetologist' });
+ const actor = setup.owner, actions: PlanetologistNoFieldStep[] = [...setup.actions];
+ let game = structuredClone(setup.afterSetup);
+ const act = (owner: string, action: Action) => { game = actPlanetologistNoField(game, owner, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistNoField(game, until, actions); };
+ const originalInventory = planetologistNoFieldInventory(game);
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistNoFieldClean(state));
+ let source: string | null = null;
+ for (const place of gameTerritories(game)) {
+ if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id))) continue;
+ for (const sector of place.sectors) {
+ if (sector < 6 || sector > 16 || sector === game.storm) continue;
+ const candidate = location(place.id, sector);
+ if (destinations(game, actor, candidate)) { source = candidate; break; }
+ }
+ if (source) break;
+ }
+ assert.ok(source, 'An empty non-city source must have ordinary legal distance-two and distance-three rock routes');
+ const sourceLocation = splitLocation(source);
+ let companionShipment: { before: Game; step: PlanetologistNoFieldStep; after: Game } | null = null;
+ if (companions) {
+ const before = structuredClone(game), step: PlanetologistNoFieldStep = { actor,
+ action: { type: 'ship', ...sourceLocation, amount: companions, elite: 0, allyPayment: 0 } };
+ act(actor, step.action); reach(planetologistNoFieldClean);
+ companionShipment = { before, step, after: structuredClone(game) };
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistNoFieldClean(state));
+ assert.equal(planetologistNoFieldPlayer(game, actor).forces[source], companions,
+ 'The actual earlier shipment, rather than a staged pool, supplies the companion');
+ }
+ const me = planetologistNoFieldPlayer(game, actor), token = me.noField!.tokens.find(candidate => candidate.value === value);
+ assert.ok(token); assert.equal(me.noField!.deployed, null);
+ assert.ok(!me.forces['arrakeen:10'] && !me.forces['carthag:11'], 'No city ornithopter supplies the extra range');
+ const beforeShipment = structuredClone(game), shipmentStep: PlanetologistNoFieldStep = { actor,
+ action: { type: 'ship', noField: token.id, event: me.noFieldEvent, ...sourceLocation } };
+ act(actor, shipmentStep.action); reach(planetologistNoFieldClean);
+ const afterShipment = structuredClone(game), routes = destinations(game, actor, source);
+ assert.ok(routes, 'The original post-shipment board retains the two bounded legal routes');
+ assert.equal(planetologistNoFieldPlayer(beforeShipment, actor).spice - planetologistNoFieldPlayer(game, actor).spice, 2);
+ assert.equal(planetologistNoFieldPlayer(game, actor).reserves, planetologistNoFieldPlayer(beforeShipment, actor).reserves);
+ assert.equal(planetologistNoFieldPlayer(game, actor).moved, 0);
+ assert.equal(planetologistNoFieldPlayer(game, actor).noField!.deployed!.tokenId, token.id);
+ const trainer = planetologistLeader(game, actor); assert.ok(trainer);
+ const target = splitLocation(routes.targetLocation), tooFar = splitLocation(routes.tooFarLocation);
+ const forces = companions ? { [source]: companions } : {};
+ const action: Action = { type: 'move', planetologist: 'range', forces, noField: token.id,
+ event: planetologistNoFieldPlayer(game, actor).noFieldEvent, ...target };
+ const ordinary: Action = { ...action }; delete ordinary.planetologist;
+ const tooFarAction: Action = { ...action, ...tooFar };
+ assert.equal(territory(sourceLocation.territory).type, 'rock');
+ assert.deepEqual(planetologistNoFieldInventory(game), originalInventory);
+ return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
+ offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, richese: actor,
+ observer: setup.seats.emperor!, opponent: setup.seats.atreides!, advanced, value, token: structuredClone(token), trainer,
+ source, sourceLocation, target, targetLocation: routes.targetLocation, tooFar, tooFarLocation: routes.tooFarLocation,
+ action, ordinary, tooFarAction, companions, forces, companionShipment, beforeShipment, shipmentStep, afterShipment,
+ originalInventory, originalPhysical: planetologistNoFieldPhysical(game),
+ originalNoField: structuredClone(planetologistNoFieldPlayer(game, actor).noField!),
+ originalEvent: planetologistNoFieldPlayer(game, actor).noFieldEvent!,
+ endMovement: { type: 'endMovement' } satisfies Action,
+ actions, sourceActionCount: actions.length,
+ staging: [...setup.staging,
+ 'Original public Basic/Advanced Richese/Atreides/Emperor +choam/Leader Skills module, ready/start, original Treachery/Traitor deals and real private skill training. Only the existing original unopened skill/scalar lotteries are selected; no played card, force, wallet, leader, marker, phase or receipt is installed. Advanced remains preview evidence.',
+ companions ? 'An actual first-turn paid one-force shipment creates the physical companion on empty non-city rock. Native end-turn, Storm, Spice, Charity, Bidding and Revival controls reach turn two, then an actual concealed No-Field shipment joins that same source.'
+ : 'Native first Storm, Spice, Charity, Bidding and Revival controls reach the first Richese movement turn. An actual paid concealed0/3/5 shipment parks its original token on empty non-city rock without withdrawing physical reserves.',
+ 'Stop before the actual distance-two Planetologist range action. The same empty board offers a distance-three rejection without city ornithopters. Token movement, event rotation, one movement allowance and later end-turn continuation remain caller-owned outcomes.',
+ ] };
+}
+export type PlanetologistNoFieldFixture = ReturnType;
+export function movePlanetologistNoField(fixture: PlanetologistNoFieldFixture,
+ game = structuredClone(fixture.game), actions: PlanetologistNoFieldStep[] = []) {
+ return actPlanetologistNoField(game, fixture.actor, fixture.action, actions);
+}
+export function finishPlanetologistNoField(fixture: PlanetologistNoFieldFixture, game: Game,
+ actions: PlanetologistNoFieldStep[] = []) {
+ assert.equal(game.turn, fixture.game.turn);
+ return advancePlanetologistNoField(game, state => state.turn === fixture.game.turn &&
+ state.phase === 7 && planetologistNoFieldClean(state), actions);
+}
diff --git a/tests/native-richese-skills.test.ts b/tests/native-richese-skills.test.ts
index 7863384e..ddb8df14 100644
--- a/tests/native-richese-skills.test.ts
+++ b/tests/native-richese-skills.test.ts
@@ -84,6 +84,7 @@ void test('scarce native No-Field five materializes only three after both plans
assert.equal(player(game, 'richese').forces['wind_pass:14'], 3);
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
+ game = finishAdvancedNativeSkillAftermath(game);
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Native real casualties must retain skilled rescue.');
const pending = game;
diff --git a/tests/planetologist-no-field-controls.test.tsx b/tests/planetologist-no-field-controls.test.tsx
new file mode 100644
index 00000000..96424ed1
--- /dev/null
+++ b/tests/planetologist-no-field-controls.test.tsx
@@ -0,0 +1,285 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement';
+import { PlanetologistMovementAction, planetologistNoFieldMarker, type PlanetologistMovementActionProps } from '../components/planetologist-movement-action';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
+import { gameTerritories, location, splitLocation } from '../game/board';
+import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
+
+const source = 'false_wall_west:16';
+const sameOrigin = 'false_wall_west:17';
+const destination = 'polar_sink';
+
+// Controlled personalized projections supplement the independent native trained
+// Richese source. No skill, token, army or pending receipt is installed in an
+// authoritative Game. React renders the stateful parent; only the hook-free
+// actual submit leaf is invoked directly.
+function projected(value: NoFieldValue = 0, physical = false, advanced = false): GameView {
+ const game = createGame('PLANETOLOGISTNOFIELDCONTROLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
+ joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
+ const view = viewGame(game, 'r');
+ view.status = 'playing'; view.phase = 5; view.turn = 2; view.storm = 18; view.active = 'r';
+ view.order = ['r', 'a']; view.movementRemaining = ['r', 'a'];
+ const me = view.players.find(player => player.id === 'r')!;
+ me.moved = 0; me.movesAllowed = 1; me.shipped = true;
+ me.forces = physical ? { [source]: 2, [sameOrigin]: 2 } : {};
+ const tokens = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']);
+ const marker = deployRicheseNoField(tokens, {
+ tokenId: tokens.tokens.find(token => token.value === value)!.id,
+ controller: me.id, location: splitLocation(source),
+ });
+ const publicMarker = projectRicheseNoField(marker, false);
+ me.noField = publicMarker;
+ view.richeseNoField = {
+ owner: me.id, event: 'controlled-current-no-field-event', public: publicMarker, private: marker,
+ } as NonNullable;
+ view.leaderSkills = {
+ assignments: [{ owner: me.id, controller: me.id, leader: me.leaders[0].id,
+ skill: 'planetologist', captured: false, faceUp: true }],
+ offer: null, eligibleLeaders: [], battleChoice: null,
+ };
+ return view;
+}
+function propsFor(game: GameView, forces: Record = {}): PlanetologistMovementActionProps {
+ return { game, mode: 'range', destination, sector: 0, forces, elites: {}, includeNoField: true,
+ busy: false, reasonId: 'controlled-planetologist-reason', act() {} };
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination, sector: 0, busy, act() {} }));
+}
+function button(html: string) {
+ const found = html.match(/