Compose Planetologist movement with Ornithopter flights

This commit is contained in:
Quintine committed 2026-10-26 09:53:21 +11:00
1 parent 90c50736f2
commit 4af90e547c
24 files changed
+1607 -50

No files matched your search

+2 -2
View File
@@ -5048,9 +5048,9 @@ export function GameTable({
destination={selected}
sector={sector}
unavailableReason={
!movementAvailable
!movementAvailable && !g.ornithopter?.active
? 'No ordinary movement is currently available.'
: (me.moved ?? 0) >= (me.movesAllowed ?? 1)
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
? 'No ordinary movement remains this turn.'
: selectedDestinationInStorm
? 'Choose a destination outside the storm.'
+36 -11
View File
@@ -13,6 +13,8 @@ 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 { planetologistFlightSelection, type PlanetologistFlight } from '@/game/planetologist-flight';
import { validateCohortSelection } from '@/game/ornithopter';
import { Button } from './ui/button';
type Counts = Record<string, number>;
@@ -49,6 +51,7 @@ export function planetologistMoveDraft(
forces: Counts,
elites: Counts,
includeNoField = false,
flight: PlanetologistFlight = 'ordinary',
): Draft {
const me = game.players.find((player) => player.id === game.me);
if (!me || !planetologistLeader(game, me.id))
@@ -58,6 +61,8 @@ export function planetologistMoveDraft(
action: null,
blocked: 'Planetologist movement with expansion factions awaits its movement audit.',
};
const transport = planetologistFlightSelection(game, flight);
if (transport.blocked) return { action: null, blocked: transport.blocked };
if (mode !== 'range' && mode !== 'gather')
return { action: null, blocked: 'Choose a Planetologist movement mode.' };
if (!Number.isSafeInteger(sector) || !validGameLocation(game, destination, sector))
@@ -103,13 +108,25 @@ export function planetologistMoveDraft(
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)))
selectedOriginElites(selected, elites, splitLocation(from).territory), mode, transport.rangeOverride)))
return {
action: null,
blocked: mode === 'range'
blocked: transport.rangeOverride
? 'The selected route is blocked or more than three territories away.'
: 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 (transport.cohort) {
try {
validateCohortSelection(transport.cohort, me.forces, me.elites?.forces ?? {},
Object.fromEntries(selected), Object.fromEntries(selected.map(([from]) => [from, elites[from] ?? 0])),
marker ? { tokenId: marker.tokenId, event: marker.event,
from: location(marker.location.territory, marker.location.sector) } : undefined);
} catch {
return { action: null, blocked: 'Choose original forces and any unmoved marker from the current Ornithopter cohort.' };
}
}
if (mode === 'gather') {
try {
quoteAdvisorGatherMatching(game, { owner: me.id, move: me.moved ?? 0, destination, sector,
@@ -126,25 +143,29 @@ export function planetologistMoveDraft(
forces: Object.fromEntries(selected),
...(me.elites ? { eliteForces: Object.fromEntries(selected.map(([from]) => [from, elites[from] ?? 0])) } : {}),
...(marker ? { noField: marker.tokenId, event: marker.event } : {}),
...transport.fields,
territory: destination, sector,
},
blocked: null,
};
}
export function planetologistMovementBlocked(game: GameView, draft: Draft, unavailableReason?: string | null): string | null {
export function planetologistMovementBlocked(game: GameView, draft: Draft, unavailableReason?: string | null,
flight: PlanetologistFlight = 'ordinary'): string | null {
const me = game.players.find(player => player.id === game.me);
const transport = planetologistFlightSelection(game, flight);
const currentCard = !transport.blocked && typeof transport.fields.ornithopterEvent === 'string';
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.'
: transport.blocked
? transport.blocked
: 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)
: !currentCard && !nexusGuildMovementAvailable(game)
? 'No ordinary movement remains this turn.'
: draft.blocked);
}
@@ -157,6 +178,7 @@ export interface PlanetologistMovementActionProps {
forces: Counts;
elites: Counts;
includeNoField?: boolean;
flight?: PlanetologistFlight;
busy: boolean;
unavailableReason?: string | null;
reasonId?: string;
@@ -165,9 +187,10 @@ export interface PlanetologistMovementActionProps {
/** 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);
includeNoField = false, flight = 'ordinary', busy, unavailableReason, reasonId, act }: PlanetologistMovementActionProps) {
const draft = planetologistMoveDraft(game, mode, destination, sector, forces, elites, includeNoField, flight);
const blocked = planetologistMovementBlocked(game, draft, unavailableReason, flight);
const transport = planetologistFlightSelection(game, flight);
const marker = includeNoField ? planetologistNoFieldMarker(game) : null;
return <Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
@@ -176,8 +199,10 @@ export function PlanetologistMovementAction({ game, mode, destination, sector, f
aria-describedby={blocked ? reasonId : undefined}
onClick={() => {
if (busy || blocked || !draft.action) return;
const current = planetologistMoveDraft(game, mode, destination, sector, forces, elites, includeNoField);
if (planetologistMovementBlocked(game, current, unavailableReason) || !current.action) return;
const current = planetologistMoveDraft(game, mode, destination, sector, forces, elites, includeNoField, flight);
if (planetologistMovementBlocked(game, current, unavailableReason, flight) || !current.action) return;
const currentTransport = planetologistFlightSelection(game, flight);
if (JSON.stringify(currentTransport.fields) !== JSON.stringify(transport.fields)) return;
const currentMarker = includeNoField ? planetologistNoFieldMarker(game) : null;
if (includeNoField && (!marker || !currentMarker || marker.tokenId !== currentMarker.tokenId ||
marker.event !== currentMarker.event || marker.location.territory !== currentMarker.location.territory ||
+67 -10
View File
@@ -8,9 +8,11 @@ import {
} from '@/game/planetologist-movement';
import {
gameTerritories,
location,
splitLocation,
territory,
} from '@/game/board';
import { planetologistFlightSelection, type PlanetologistFlight } from '@/game/planetologist-flight';
import {
PlanetologistMovementAction,
planetologistMoveDraft,
@@ -36,7 +38,9 @@ export function PlanetologistMovement(props: Props) {
const marker = planetologistNoFieldMarker(props.game);
const me = props.game.players.find(player => player.id === props.game.me);
return <PlanetologistMovementDraft
key={JSON.stringify([props.game.me, props.game.turn, me?.moved, marker?.tokenId, marker?.event, marker?.location])}
key={JSON.stringify([props.game.me, props.game.turn, me?.moved, marker?.tokenId, marker?.event, marker?.location,
props.game.ornithopter?.card?.id, props.game.ornithopter?.active?.event,
props.game.ornithopter?.active?.completed, props.game.discoveryOrnithopter?.token])}
{...props}
/>;
}
@@ -54,11 +58,22 @@ function PlanetologistMovementDraft({
const [forces, setForces] = useState<Counts>({});
const [elites, setElites] = useState<Counts>({});
const [includeNoField, setIncludeNoField] = useState(false);
const [flight, setFlight] = useState<PlanetologistFlight>(game.ornithopter?.active?.mode ?? 'ordinary');
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 transport = planetologistFlightSelection(game, flight);
const sources = Object.entries(me.forces).map(([from, count]) => {
if (!transport.cohort) return [from, count] as const;
const currentElite = me.elites?.forces[from] ?? 0;
const cohortElite = transport.cohort.elites[from] ?? 0;
const normal = Math.min(count - currentElite, (transport.cohort.forces[from] ?? 0) - cohortElite);
return [from, normal + Math.min(currentElite, cohortElite)] as const;
}).filter(([, count]) => count > 0);
const marker = planetologistNoFieldMarker(game);
const markerAvailable = !transport.cohort || !!(marker && transport.cohort.noField &&
marker.tokenId === transport.cohort.noField.tokenId && marker.event === transport.cohort.noField.event &&
location(marker.location.territory, marker.location.sector) === transport.cohort.noField.from);
const draft = planetologistMoveDraft(
game,
mode,
@@ -67,8 +82,9 @@ function PlanetologistMovementDraft({
forces,
elites,
includeNoField,
flight,
);
const blocked = planetologistMovementBlocked(game, draft, unavailableReason);
const blocked = planetologistMovementBlocked(game, draft, unavailableReason, flight);
const leaderName =
game.allLeaders.find((candidate) => candidate.id === leader)?.name ??
leader;
@@ -96,6 +112,42 @@ function PlanetologistMovementDraft({
<option value="range">One territory farther, maximum three</option>
<option value="gather">Gather from two territories</option>
</select>
{(game.ornithopter || game.discoveryOrnithopter) && (
<>
<label htmlFor={`${id}-flight`}>Movement transport</label>
<select
id={`${id}-flight`}
value={flight}
disabled={busy || !!game.ornithopter?.active}
onChange={event => setFlight(event.target.value as PlanetologistFlight)}
>
{(['ordinary', 'range3', 'twoGroups', 'discovery'] as const).map(choice => {
const option = planetologistFlightSelection(game, choice);
const label = choice === 'ordinary' ? 'Ordinary movement' : choice === 'range3'
? 'Ornithopter card: fixed range three' : choice === 'twoGroups'
? 'Ornithopter card: two different movements' : 'Carried Discovery Ornithopter';
return <option key={choice} value={choice} disabled={!!option.blocked}>
{label}{option.blocked ? ' — unavailable' : ''}
</option>;
})}
</select>
<p className="fine" aria-label="Provisional Planetologist flight composition">
Provisional flight composition: fixed-range Ornithopter and Discovery
flight stay at three territories for either skill choice, including
each origin of a gather. Two-group card flight uses extra range up to
three, or gathers two origins using each origin’s normal range. A
gather counts as one card movement; the next movement must use
different original forces. Only one flight source is used per movement.
</p>
{game.ornithopter?.active && !transport.blocked && (
<p className="notice">
{game.ornithopter.active.completed} card movements completed ·{' '}
{game.ornithopter.active.remaining} remaining. Select original unmoved
forces below for the next movement.
</p>
)}
</>
)}
<p className="fine">
Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below.
@@ -107,7 +159,7 @@ function PlanetologistMovementDraft({
id={`${id}-no-field`}
type="checkbox"
checked={includeNoField}
disabled={busy || !!game.homeworldMobility?.noFieldMovementBlocked}
disabled={busy || !markerAvailable || !!game.homeworldMobility?.noFieldMovementBlocked}
onChange={(event) => setIncludeNoField(event.target.checked)}
/>
Include your concealed No-Field from {territory(marker.location.territory).name}, sector {marker.location.sector}
@@ -121,9 +173,13 @@ function PlanetologistMovementDraft({
<p className="fine" aria-label="Provisional Planetologist No-Field gather">
Provisional gather composition: the concealed marker supplies one of
exactly two origin territories, alone or with physical forces there.
Select physical forces from one other territory. Every origin uses
its normal range; gather adds no extra range. Its value stays concealed.
Select physical forces from one other territory.{' '}
{transport.rangeOverride
? 'Every origin uses the selected flight’s fixed range of three.'
: 'Every origin uses its normal range; gather adds no extra range.'}{' '}
Its value stays concealed.
</p>
{!markerAvailable && <p className="fine">This marker is outside the original unmoved card cohort.</p>}
{game.homeworldMobility?.noFieldMovementBlocked && (
<p className="notice">{game.homeworldMobility.noFieldMovementBlocked}</p>
)}
@@ -151,7 +207,9 @@ function PlanetologistMovementDraft({
(candidate) => candidate.id === source.territory,
)?.name ?? source.territory;
const selected = forces[from] ?? 0;
const ownedElite = me.elites?.forces[from] ?? 0;
const ownedElite = transport.cohort
? Math.min(me.elites?.forces[from] ?? 0, transport.cohort.elites[from] ?? 0)
: me.elites?.forces[from] ?? 0;
const minimumElite = Math.max(0, selected - (owned - ownedElite));
return (
<div className="grid gap-2" key={from}>
@@ -201,12 +259,11 @@ function PlanetologistMovementDraft({
)}
<PlanetologistMovementAction
game={game} mode={mode} destination={destination} sector={sector}
forces={forces} elites={elites} includeNoField={includeNoField}
forces={forces} elites={elites} includeNoField={includeNoField} flight={flight}
busy={busy} unavailableReason={unavailableReason} reasonId={`${id}-reason`} act={act}
/>
<p className="fine">
Concealed No-Fields cannot join either Ornithopter card movement
or Discovery flight.
Only one flight source may be used for each movement.
Normal movement and arrival rules still apply.
</p>
</section>
+44 -6
View File
@@ -16,6 +16,40 @@ open mode gates.
## Current checkpoint and work
**Planetologist flight composition — 26 October. Prototyped / Partial.**
Both skill alternatives now compose with physical range3/twoGroups and carried
Discovery. Fixed3 stays3; twoGroups applies normal+1/cap3 or ordinary gathering
per accepted movement. A gather counts as one card movement under visible
provisional policy. Exact original cohorts prevent reuse after merging while
allowing untouched residual groups. Shared projected flight choices feed actual
controls and minimal policy; original allowances, credits and one disposal stay.
Discovery receipts bind skill/leader/both origins while retaining legacy ordinary
bytes. The reproduced native Discovery profile mismatch is fixed by projecting
its existing enabled flag. No new mode, saved root field or strategic AI opens.
Three disjoint physical/Discovery/control lanes and root integration pass
**20 files,196/196**, types/lint/build and **sixteen native engine/GameTable SSR/
actual selected-props submit-callback paths** on unchanged source. Twelve public
E/R/A +choam/Skills routes earn the actual cache card for1, a paid four-counter
shipment for8 and ordinary split; prefixes use Basic74 / Advanced79–80 actions.
Four public G/E/H Skills/Discovery routes acquire the actual token through
turn-one Collection and use it on turn two; prefixes use Basic65 / Advanced71.
Two-to-four continuation actions finish the original Collection. Physical routes
retain45 Treachery, Discovery routes33; each keeps14 Skills/15 Traitors, and the
Discovery inventory keeps all8 identities. Sixteen physical engine cases, seven
Discovery cases, fifteen controlled cases and receipt/profile regressions cover
the slice. Feedback removes a redundant random Traitor-deal replay, integrates
the old Discovery selector, retains legal pre-shipment AI candidates after
shipment choices and completes the controlled Discovery projection shape.
Frozen v3 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-flight-proof-v3/`;
journeys/HTML: adjacent `20261026-planetologist-flight-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
SSR/selected-props callbacks do not certify DOM/hydration, browser/mobile/deployed
acceptance or full combinations/modes. Final assurance remains due.
[Source matrix and execution](PLANETOLOGIST_RULES.md#physical-and-discovery-flight-composition--26-october-2026).
**Planetologist No-Field gathering — 26 October. Prototyped / Partial.**
The own concealed marker and selected physical counters can gather from exactly
two territories to one destination. Each selected sector keeps ordinary range;
@@ -132,12 +166,16 @@ 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 source check: the live Planetologist/card-movement refusal in
`validatePlanetologistMove`, against the existing
[Ornithopter contract](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md) and
[Planetologist contract](PLANETOLOGIST_RULES.md). Distinguish printed movement
limits from missing compositions before implementation. Bounded allied-marker
review found an existing legal voluntary-reveal→endMovement route; the supplied
Next source-supported interaction: Sandmaster collection along physical
Ornithopter range3/twoGroups and carried Discovery routes. Its printed moving-
forces band excludes none of these items. The live `validateSandmasterOrder`
and `game/sandmaster-movement.ts` adapters still refuse them. Preserve chosen
paths, exact piles, one collection per entered territory per movement, current
range, original cohorts and one commitment/disposal. A second card movement
requotes current spice; income never enlarges unmoved quotas. Use actual trained
sources and produced Spice blows, not changed played assignments or staged piles.
Bounded allied-marker review found an existing legal voluntary-reveal→endMovement
route; the supplied
physical15/36 text does not expressly add alliance separation as a forced reveal
trigger. Preserve that route rather than calling it a deadlock or inventing an
automatic reveal. Complete rules and final assurance remain unfinished.
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 26 October 2026 — Planetologist physical and Discovery flight matrix
**Prototyped / Partial.** Connect both skill alternatives to physical range3,
physical twoGroups and carried Discovery. Fixed range stays3; the two normal card
movements choose +1/cap3 or ordinary-range gathering independently. A gather
counts as one card movement under visible provisional composition. Whole-map
cohorts exclude moved arrivals and preserve original typed residuals. Shared
owned transport choices drive actual controls and minimal policy. Discovery
signatures bind skill/leader/origins; legacy ordinary signatures retain their
exact bytes. Projecting the existing Discovery-enabled flag fixes a reproduced
native Skills view mismatch without opening a new module combination.
Frozen **20-file196/196**, types/lint/build and **sixteen native engine/GameTable
SSR/actual selected-props submit-callback paths** pass. Twelve public E/R/A
+choam/Skills routes earn card1, shipment8 and a real split (Basic74 / Advanced
79–80 source actions); four public G/E/H Skills/Discovery routes earn actual
turn-one Collection acquisition and later use (Basic65 / Advanced71). Two-to-four
continuation actions finish same-turn Collection. Physical routes retain45 cards,
Discovery33; each retains14 Skills/15 Traitors, Discovery all8 token identities.
Sixteen physical, seven Discovery and fifteen controlled cases plus receipt/
profile regressions cover the slice and four minimal policies. Feedback repairs
a redundant random setup replay, the old single-origin Discovery selector,
pre-shipment policy candidate visibility and controlled projection typing.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-flight-proof-v3/`;
journeys/HTML: adjacent `20261026-planetologist-flight-runtime/`.
No saved-room/D1/server, reset/restart, deployment or recovery operation. No new
saved root field, action or mode gate. SSR/selected-props callbacks do not prove
DOM/hydration, browser/mobile/deployed acceptance or complete combinations.
Full rules and final assurance remain open.
[Source matrix and contract](PLANETOLOGIST_RULES.md#physical-and-discovery-flight-composition--26-october-2026).
## 26 October 2026 — Concealed No-Field two-origin Planetologist gathering
**Prototyped / Partial.** The own marker supplies one of exactly two origins,
+67
View File
@@ -4,6 +4,73 @@ 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.
## Physical and Discovery flight composition — 26 October 2026
The physical Planetologist face permits forces from two territories in a single
movement to a common destination, or +1 normal range capped at3. The physical
Ornithopter face permits one group up to3 or two different groups using normal
movement. Discovery's publisher E3p13 text supplies3 instead of normal movement
for one later movement action. Existing [physical-card provenance](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#authority)
and [skill-card text](LEADER_SKILLS_RULES.md) remain the source contracts; both
physical faces were freshly inspected. No joint publisher FAQ was located.
Under 14 October choose-recommended authority, use this provisional composition:
| Flight source | Planetologist range | Planetologist gather |
| --- | --- | --- |
| Physical range3 | One origin, fixed3, never4 | Both origins fixed3 to one destination; one card movement |
| Physical twoGroups | Each current movement may add1/cap3 to normal range | Each selected sector uses ordinary range; each gather counts as one of the two card movements |
| Carried Discovery | One origin, fixed3, never4 | Both origins fixed3 to one destination; one ordinary movement action |
Treating the single gather as one card movement is the material interpretation,
despite the physical card's ordinary one-group wording. It is visible guidance,
not an express joint ruling. The two alternatives stay exclusive per action;
different card movements may choose different Planetologist alternatives. A
skilled movement uses one flight source, not physical and Discovery together.
The original physical card is reserved/played and discarded once through its
existing frame. A first gather subtracts every selected source from the original
unmoved cohort. A second selection must fit both those exact normal/elite/marker
quotas and current custody; merged first arrivals cannot be reused, while
untouched residual forces from an original territory may move. Only completing
the second card movement earns its existing movement credit. Early ending
abandons it without an extra credit. Existing Hajr timing/allowances remain.
Discovery still requires acquisition on an earlier turn, current ownership and
one actual commit before consumption. Its existing receipt string additionally
binds the selected skill/leader and both normalized gather origins. Ordinary
single-origin no-skill signatures retain their exact legacy bytes. New skill
receipts cannot be replayed after changing an origin, trainer or alternative.
No new saved root field, action or module gate is introduced.
Projected transport choices share exact ranges, current source events and
cohort checks with actual submission and minimal policy. The existing enabled
Discovery configuration is now projected explicitly so already-admitted native
Skills/Discovery games retain the engine's movement profile in their own view.
This fixes a reproduced projection mismatch; no new module combination opens.
Full transport interactions, browser/deployed acceptance and assurance remain due.
**Frozen evidence:** v3 passes20 files,196/196, types/lint/build and sixteen
native engine/GameTable SSR/actual selected-props submit-callback paths. Twelve
public physical-card routes retain the actual paid card1, shipment4/cost8 and
ordinary split, using Basic74 / Advanced79–80 source actions. Four public
Skills/Discovery routes acquire the original token through turn-one Collection
and use it on turn two, using Basic65 / Advanced71 actions. All six compositions
and all four twoGroups first/second skill pairs finish same-turn Collection in
two-to-four continuation actions. Physical routes retain45 Treachery, Discovery
33; each retains14 Skills/15 Traitors, and Discovery all8 token identities.
Sixteen physical engine cases, seven Discovery cases, fifteen controlled cases
and legacy-receipt/native-profile regressions cover the slice and minimal profiles.
Feedback removes a redundant random Traitor-deal replay, connects the original
Discovery selector, preserves legal pre-shipment policy choices after ordinary
shipments and completes controlled projection fields.
Report/logs: private `20261026-planetologist-flight-proof-v3/` under
`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
`20261026-planetologist-flight-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.
## Concealed No-Field two-origin gathering — 26 October 2026
Compose the printed No-Field marker-as-one-force rule (E2 pp5–6,10–12) with
+12
View File
@@ -1,5 +1,17 @@
# Richese Ornithopter: movement integration audit
## Planetologist movement composition — 26 October 2026
Both physical modes now compose provisionally with either Planetologist
alternative. Fixed range stays3; twoGroups uses current normal range with
optional +1/cap3 or normal-range gathering. Each accepted gather counts as one
card movement. Its full selected map leaves the original unmoved cohort, and
the second move cannot reuse transported counters/marker after merging.
Card custody, one discard, existing credits, early ending and Hajr remain.
[Selected matrix, source and contract](PLANETOLOGIST_RULES.md#physical-and-discovery-flight-composition--26-october-2026)
also distinguishes the separate Discovery token and its signed two-origin quote.
This is explicit composition, not a joint FAQ or complete-mode acceptance.
## Guild Cunning and earned Hajr movement — 18 October 2026
The [printed Guild Nexus](NEXUS_GUILD_RULES.md#primary-evidence) grants only a
+12
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Planetologist physical and Discovery flight — 26 October 2026
Under 14 October choose-recommended authority, compose both skill alternatives
with physical Ornithopter range3/twoGroups and carried Discovery. Fixed3 remains3;
twoGroups applies normal+1/cap3 or ordinary-range gathering independently to each
movement. Count one two-origin gather as one card movement and preserve disjoint
original cohorts, current custody and existing credits/disposal. Discovery
gather uses fixed3 for both origins in one ordinary action and consumes its
earlier-acquired token once. This counting/range policy is visibly provisional,
not a located joint FAQ; physical-card one-group wording is acknowledged.
Use one flight source per skilled move; no module gate opens.
[Source matrix and contract](PLANETOLOGIST_RULES.md#physical-and-discovery-flight-composition--26-october-2026).
## Planetologist concealed No-Field gathering — 26 October 2026
Under 14 October choose-recommended authority, compose the printed moving-marker
+3 -1
View File
@@ -103,6 +103,7 @@ export function botGroundMoveAllowed(
to: string,
elite: number,
planetologist?: PlanetologistMoveMode,
fixedRange?: 3,
) {
const source = splitLocation(from),
target = splitLocation(to);
@@ -130,7 +131,8 @@ export function botGroundMoveAllowed(
)
)
return false;
return groundMoveRouteDistance(g, p, from, to) <= botMovementRange(g, p, elite, planetologist);
return groundMoveRouteDistance(g, p, from, to) <=
(fixedRange ?? botMovementRange(g, p, elite, planetologist));
}
/** Fremen reinforcement radius is static territory geometry, not a ground route. */
export function fremenReserveEntry(to: string) {
+75
View File
@@ -0,0 +1,75 @@
import type { Action, GameView } from './engine';
import { location, splitLocation } from './board';
import { botGroundMoveAllowed } from './bot-mobility';
import { planetologistFlightSelection, type PlanetologistFlight } from './planetologist-flight';
import { planetologistLeader, planetologistMovementModeSupported,
planetologistGatherAdvisorEntry, type PlanetologistMoveMode } from './planetologist-movement';
import { validateCohortSelection } from './ornithopter';
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
type Source = { from: string; elite: number; marker: boolean };
/** Minimal legal participation using only current projected transport and stock.
* One counter per origin leaves distinct residual groups where available. */
export function botPlanetologistFlightActions(game: GameView,
targets: readonly { territory: string; sector: number }[],
transports: readonly PlanetologistFlight[]): Action[] {
const me = game.players.find(player => player.id === game.me);
if (!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id ||
game.response || game.decision || game.phaseOpening || game.truthtrance ||
game.automaticContinuationPending || game.roomControl?.paused || game.roomControl?.closed ||
!planetologistLeader(game, me.id) || !planetologistMovementModeSupported(game) ||
(!game.ornithopter?.active && !nexusGuildMovementAvailable(game))) return [];
const info = game.richeseNoField?.owner === me.id ? game.richeseNoField : null;
const marker = info?.private?.deployed;
const result: Action[] = [];
for (const transport of transports) {
const flight = planetologistFlightSelection(game, transport);
if (flight.blocked) continue;
const sources: Source[] = Object.entries(me.forces).flatMap(([from, total]) => {
const elite = me.elites?.forces[from] ?? 0;
const availableElite = Math.min(elite, flight.cohort?.elites[from] ?? elite);
const availableNormal = Math.min(total - elite, flight.cohort
? (flight.cohort.forces[from] ?? 0) - (flight.cohort.elites[from] ?? 0) : total - elite);
return availableNormal > 0 ? [{ from, elite: 0, marker: false }]
: availableElite > 0 ? [{ from, elite: 1, marker: false }] : [];
}).slice(0, 12);
if (marker?.controller === me.id && info?.event && !game.homeworldMobility?.noFieldMovementBlocked) {
const from = location(marker.location.territory, marker.location.sector);
if (!flight.cohort || (flight.cohort.noField?.tokenId === marker.tokenId &&
flight.cohort.noField.event === info.event && flight.cohort.noField.from === from))
sources.push({ from, elite: 0, marker: true });
}
for (const mode of ['range', 'gather'] as PlanetologistMoveMode[]) {
const groups: Source[][] = mode === 'range' ? sources.map(source => [source])
: sources.flatMap((first, index) => sources.slice(index + 1)
.filter(second => splitLocation(first.from).territory !== splitLocation(second.from).territory)
.map(second => [first, second]));
let accepted = 0;
choices: for (const target of targets) for (const group of groups) {
const to = location(target.territory, target.sector);
if (!group.every(source => botGroundMoveAllowed(game, me, source.from, to,
source.elite, mode, flight.rangeOverride))) continue;
const entry = planetologistGatherAdvisorEntry(game, me,
group.map(source => splitLocation(source.from).territory), target.territory);
if (entry.blocked) continue;
const forces = Object.fromEntries(group.filter(source => !source.marker).map(source => [source.from, 1]));
const elites = Object.fromEntries(group.filter(source => !source.marker).map(source => [source.from, source.elite]));
const selectedMarker = group.some(source => source.marker) && marker && info?.event
? { tokenId: marker.tokenId, event: info.event,
from: location(marker.location.territory, marker.location.sector) } : undefined;
if (flight.cohort) {
try { validateCohortSelection(flight.cohort, me.forces, me.elites?.forces ?? {}, forces, elites, selectedMarker); }
catch { continue; }
}
result.push({ type: 'move', planetologist: mode, forces,
...(me.elites ? { eliteForces: elites } : {}),
...(selectedMarker ? { noField: selectedMarker.tokenId, event: selectedMarker.event } : {}),
...(entry.requiresFlip ? { fighters: true } : {}),
...flight.fields, territory: target.territory, sector: target.sector });
if (++accepted === 24) break choices;
}
}
}
return result;
}
+11 -5
View File
@@ -104,6 +104,7 @@ import {
guildTransportCost,
} from './bot-mobility';
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
import { botPlanetologistFlightActions } from './bot-planetologist-flight';
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning';
@@ -3371,11 +3372,13 @@ function policyActions(g: GameView): Action[] {
const movementAvailable = nexusGuildMovementAvailable(g);
const hajr = nexusGuildHajrAction(g);
if (hajr) return [hajr];
const cardMoves = ornithopterMoves(g);
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked
? discoveryFlightBotActions(g,
destinations(g).filter(target => target.score > -1000)
.map(target => ({ territory: target.t, sector: target.s })))
const flightTargets = destinations(g).filter(target => target.score > -1000)
.map(target => ({ territory: target.t, sector: target.s }));
const skillCardMoves = botPlanetologistFlightActions(g, flightTargets, ['range3', 'twoGroups']);
const cardMoves = [...skillCardMoves, ...ornithopterMoves(g)];
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked && !g.ornithopter?.active
? [...botPlanetologistFlightActions(g, flightTargets, ['discovery']),
...discoveryFlightBotActions(g, flightTargets)]
: [];
if (discoveryMoves.length && (me.shipped || me.reserves === 0))
return discoveryMoves;
@@ -3826,6 +3829,9 @@ function policyActions(g: GameView): Action[] {
Number(b.gather) - Number(a.gather),
);
actions.push(...groundMoves.map((candidate) => candidate.action));
// Moving before an unused shipment is legal; retain these candidates after
// the existing shipment choices without changing their ordinary priority.
actions.push(...skillCardMoves);
if (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));
+34 -5
View File
@@ -3,7 +3,10 @@ import { gameDistance, gameTerritories, location, splitLocation, validGameLocati
import { isAdvisor } from './advisors';
import { presenceAt } from './force-presence';
import { strongholdPathBlocked, territoryEntryBlock } from './occupancy';
import { botEntryAllowed, botMovementRange } from './bot-mobility';
import { botEntryAllowed, botMovementRange, botGroundMoveAllowed } from './bot-mobility';
import { planetologistLeader, planetologistMovementModeSupported, planetologistGatherAdvisorEntry,
selectedOriginElites } from './planetologist-movement';
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching';
import { nexusGuildMovementAvailable } from './nexus-guild-cunning-options';
import { validateCohortSelection } from './ornithopter';
@@ -26,6 +29,12 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
held.acquiredTurn !== offer.acquiredTurn || held.acquiredTurn >= game.turn)
return fail('This carried token is no longer available.');
if (!move || move.type !== 'move') return fail('Select a movement group and destination above.');
const skill = move.planetologist;
if (skill !== undefined && ((skill !== 'range' && skill !== 'gather') ||
!planetologistLeader(game, me.id) || !planetologistMovementModeSupported(game)))
return fail('Choose a current living Planetologist and movement alternative.');
if (skill && (game.ornithopter?.active || move.movementCard !== undefined || move.ornithopterEvent !== undefined))
return fail('Choose one Ornithopter flight source for this Planetologist movement.');
if (typeof move.territory !== 'string' || !whole(move.sector) ||
!validGameLocation(game, move.territory, move.sector))
return fail('Select an available destination and sector.');
@@ -36,14 +45,16 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
if (!entries) return fail('Select forces from one territory.');
const forces: Record<string, number> = {}, elites: Record<string, number> = {};
let origin: string | undefined;
const origins = new Set<string>();
for (const [key, count] of entries) {
const source = splitLocation(key);
if (!validGameLocation(game, source.territory, source.sector) ||
!whole(count) || count > (me.forces[key] ?? 0))
return fail('The selected forces are no longer available.');
if (!count) continue;
if (origin && origin !== source.territory) return fail('Select forces from one territory.');
origin = source.territory;
origins.add(source.territory);
if (origins.size > (skill === 'gather' ? 2 : 1)) return fail('Select forces from one territory, or exactly two with Planetologist gathering.');
origin ??= source.territory;
const availableElite = me.elites?.forces[key] ?? 0;
const selectedElite = move.forces !== undefined
? object(move.eliteForces) ? move.eliteForces[key] : undefined : move.elite;
@@ -63,12 +74,30 @@ export function discoveryFlightMove(game: GameView, move: Action | null): Quote
if (marker === null) return fail('Choose the current deployed No-Field.');
if (marker) {
const markerOrigin = splitLocation(marker.from).territory;
if (origin && origin !== markerOrigin) return fail('The selected No-Field and forces must share a territory.');
origin = markerOrigin;
origins.add(markerOrigin);
if (origins.size > (skill === 'gather' ? 2 : 1)) return fail('The selected No-Field and forces must share a territory.');
origin ??= markerOrigin;
}
if (!origin) return fail('Select at least one force or the deployed No-Field.');
const sources = [...Object.keys(forces), ...(marker ? [marker.from] : [])];
if (sources.some(key => key === destination)) return fail('Choose a different destination.');
if (skill) {
if (origins.size !== (skill === 'gather' ? 2 : 1)) return fail('Choose the correct number of Planetologist origins.');
if (marker && game.homeworldMobility?.noFieldMovementBlocked)
return fail(game.homeworldMobility.noFieldMovementBlocked);
const entry = planetologistGatherAdvisorEntry(game, me, [...origins], move.territory);
if (entry.blocked) return fail(entry.blocked);
if (sources.some(from => !botGroundMoveAllowed(game, me, from, destination,
selectedOriginElites(Object.entries(forces), elites, splitLocation(from).territory), skill, 3)))
return fail('This route is blocked or more than three territories away.');
if (skill === 'gather') {
try { quoteAdvisorGatherMatching(game, { owner: me.id, move: me.moved ?? 0,
destination: move.territory, sector: move.sector, advisors: isAdvisor(me, move.territory), group: forces }); }
catch (error) { if (!(error instanceof AdvisorGatherMatchingError)) throw error; return fail(error.message); }
}
return { action: { ...move, ...(entry.requiresFlip ? { fighters: true } : {}),
discoveryOrnithopter: offer.token }, blocked: null };
}
if (!botEntryAllowed(game, me, move.territory, move.sector, 'move', origin))
return fail('The selected destination is unavailable to this group.');
if (origin !== move.territory && game.balisetRestrictions.some(b =>
+18 -1
View File
@@ -23,6 +23,8 @@ export type DiscoveryFlightMovement = {
eliteGroup: Readonly<Record<string, number>>;
elite: number;
origin: string;
origins?: readonly string[];
planetologist?: { leader: string; mode: 'range' | 'gather' };
total: number;
to: string;
sector: number;
@@ -86,9 +88,19 @@ function selection(move: DiscoveryFlightMovement): string {
(move.lockedTurn === undefined || integer(move.lockedTurn)) &&
Array.isArray(move.group) && record(move.eliteGroup),
'Bind the Discovery Ornithopter to an ordinary declared movement group.');
const skill = move.planetologist;
requireFlight(skill === undefined || (record(skill) && name(skill.leader) &&
(skill.mode === 'range' || skill.mode === 'gather')),
'The Discovery flight has an invalid Planetologist selection.');
const origins = move.origins ?? [move.origin];
requireFlight(skill?.mode === 'gather'
? Array.isArray(move.origins) && origins.length === 2 &&
origins.every(name) && new Set(origins).size === 2 && origins[0] === move.origin
: move.origins === undefined,
'A Discovery gather must retain exactly two normalized Planetologist origins.');
const group = move.group;
requireFlight(group.every(part => Array.isArray(part) && part.length === 2 &&
validSource(part[0], move.origin) && integer(part[1]) && part[1] > 0) &&
origins.some(origin => validSource(part[0], origin)) && integer(part[1]) && part[1] > 0) &&
new Set(group.map(([key]) => key)).size === group.length,
'The Discovery flight has invalid or repeated source forces.');
requireFlight(Object.entries(move.eliteGroup).every(([key, count]) => integer(count) &&
@@ -102,6 +114,9 @@ function selection(move: DiscoveryFlightMovement): string {
if (move.noField) requireFlight(record(move.noField) &&
name(move.noField.tokenId) && name(move.noField.event) && validSource(move.noField.from, move.origin),
'The Discovery flight has lost its selected No-Field marker.');
requireFlight(origins.every(origin => group.some(([key]) => validSource(key, origin)) ||
(move.noField && validSource(move.noField.from, origin))),
'Every Discovery gather origin must supply actual selected forces or its marker.');
const destination = `${move.to}:${move.sector}`;
requireFlight(group.every(([key]) => key !== destination) && move.noField?.from !== destination,
'The Discovery flight must move its selected group to a different location.');
@@ -112,6 +127,8 @@ function selection(move: DiscoveryFlightMovement): string {
move.elite, move.total, move.to, move.sector, move.advisors, move.wantsFighters,
move.lockedTurn ?? null,
move.noField ? [move.noField.tokenId, move.noField.event, move.noField.from] : null,
// Preserve existing no-skill single-origin receipt signatures byte for byte.
...(skill ? [[skill.leader, skill.mode, [...origins]]] : []),
]);
}
+3 -2
View File
@@ -17364,8 +17364,8 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder,
'This Planetologist movement no longer has its living, uncaptured skilled leader.');
requireRule(planetologistMovementModeSupported(g),
'Planetologist movement combined with expansion factions is still being implemented.');
requireRule(!move.ornithopterEvent && !move.discoveryFlight,
'Planetologist combined with special movement cards is still being implemented.');
requireRule(!(move.ornithopterEvent && move.discoveryFlight),
'Choose one Ornithopter flight source for this Planetologist movement.');
// Selected composition: the concealed marker moves as one force for either
// alternative. Gathering retains each origin's ordinary range; no +1 stacks.
requireRule(skill.mode === 'gather'
@@ -39389,6 +39389,7 @@ export function viewGame(state: Game, id: string) {
},
expansions: g.expansions,
ecazTreachery: !!g.ecazTreachery,
discoveryEnabled: g.discoveryEnabled === true,
turn: g.turn,
phase: g.phase,
phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null,
+82
View File
@@ -0,0 +1,82 @@
import type { Action, GameView } from './engine';
import { location, splitLocation, validGameLocation } from './board';
import { isDiscoveryTokenId } from './discoveries';
import { validateCohortSelection, type MovementCohort } from './ornithopter';
export type PlanetologistFlight = 'ordinary' | 'range3' | 'twoGroups' | 'discovery';
export type PlanetologistFlightSelection = {
fields: Partial<Action>;
rangeOverride?: 3;
cohort?: MovementCohort;
blocked: string | null;
};
const fail = (blocked: string): PlanetologistFlightSelection => ({ fields: {}, blocked });
const named = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
/** Select only the current seat's projected physical card or carried token.
* Range/path, selected forces and action priority are checked by the consumer. */
export function planetologistFlightSelection(game: GameView,
flight: PlanetologistFlight = 'ordinary'): PlanetologistFlightSelection {
if (!game.players.some(player => player.id === game.me)) return fail('Choose the current movement owner.');
if (!['ordinary', 'range3', 'twoGroups', 'discovery'].includes(flight))
return fail('Choose an available movement transport.');
const card = game.ornithopter;
const active = card?.active;
if (active && card) {
if (card.blocked) return fail(card.blocked);
if (flight === 'discovery') return fail('Finish the current physical Ornithopter before using Discovery flight.');
if (flight !== 'ordinary' && flight !== active.mode)
return fail('Continue the current Ornithopter mode before selecting another transport.');
const total = active.mode === 'twoGroups' ? 2 : active.mode === 'range3' ? 1 : 0;
const played = game.playedOrnithopter;
if (!named(card?.card?.id) || !named(active.event) || !total ||
!Number.isSafeInteger(active.completed) || active.completed < 0 ||
!Number.isSafeInteger(active.remaining) || active.remaining <= 0 ||
active.completed + active.remaining !== total ||
(played && (played.player !== game.me || played.card.id !== card.card.id ||
played.mode !== active.mode || played.completed !== active.completed)))
return fail('The current Ornithopter frame no longer belongs to this movement.');
if (!Array.isArray(card.modes) || !card.modes.some(option => option.mode === active.mode))
return fail('The current Ornithopter mode is no longer offered.');
if (active.mode === 'twoGroups' && active.completed > 0 && !active.cohort)
return fail('The original unmoved Ornithopter cohort is unavailable.');
if (active.cohort) {
try {
validateCohortSelection(active.cohort, active.cohort.forces, active.cohort.elites, {}, {});
const sources = [...Object.keys(active.cohort.forces), ...Object.keys(active.cohort.elites),
...(active.cohort.noField ? [active.cohort.noField.from] : [])];
if (sources.some(from => {
const point = splitLocation(from);
return from !== location(point.territory, point.sector) ||
!validGameLocation(game, point.territory, point.sector);
})) return fail('The original unmoved Ornithopter cohort has invalid sources.');
} catch {
return fail('The original unmoved Ornithopter cohort is no longer valid.');
}
}
return { fields: { ornithopterEvent: active.event },
...(active.mode === 'range3' ? { rangeOverride: 3 as const } : {}),
...(active.cohort ? { cohort: active.cohort } : {}), blocked: null };
}
if (flight === 'ordinary') return { fields: {}, blocked: null };
if (flight === 'discovery') {
const offer = game.discoveryOrnithopter;
if (!offer) return fail('You do not carry an available Discovery Ornithopter.');
if (offer.blocked) return fail(offer.blocked);
const held = game.discoveries?.tokens.find(token => token.id === offer.token);
if (!isDiscoveryTokenId(offer.token) || offer.range !== 3 || !held ||
game.discoveries?.tokens.filter(token => token.face === 'ornithopter' &&
token.status === 'carried' && token.owner === game.me).length !== 1 ||
held.face !== 'ornithopter' || held.status !== 'carried' || held.owner !== game.me ||
held.acquiredTurn !== offer.acquiredTurn || held.revealedTurn !== offer.acquiredTurn ||
!Number.isSafeInteger(offer.acquiredTurn) || offer.acquiredTurn < 1 || offer.acquiredTurn >= game.turn)
return fail('The selected Discovery Ornithopter is no longer carried by you or ready for use.');
return { fields: { discoveryOrnithopter: offer.token }, rangeOverride: 3, blocked: null };
}
if (!card || !named(card.card.id)) return fail('You do not hold an available Ornithopter card.');
const mode = card.modes?.find(option => option.mode === flight);
if (!mode) return fail('Choose one of the offered Ornithopter modes.');
if (card.blocked || mode.blocked) return fail(card.blocked || mode.blocked!);
return { fields: { movementCard: card.card.id, ornithopter: flight },
...(flight === 'range3' ? { rangeOverride: 3 as const } : {}), blocked: null };
}
+7 -6
View File
@@ -1095,7 +1095,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Special flight combinations remain unfinished.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
'Ordinary allied shipment costs one spice into a stronghold or two elsewhere. Guild recipients and active purchased prices use the existing provisional bank-only discount. Canceling a native Guild recipient’s rate charges one full-price marker, never zero/five materialized forces. A supplier’s original whole-fee1 approval cannot silently become2; release unchanged and obtain a new full-price offer/consent. Independent purchased price remains. Fremen on-planet reserves stay ineligible.',
@@ -5735,6 +5735,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'Planetologist may provisionally compose either movement alternative with physical Ornithopter or carried Discovery. Fixed flight stays three, including both gather origins. In two-group card mode, each action chooses boosted normal range or ordinary-range gathering; a gather counts as one card movement. Different original counters must supply the second movement, even after merging. Current trainer, source events, earlier-turn token ownership and one disposal remain.',
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
'Classic Nexus core with base33 or selected36 permits optional all14, original Tech3+ and Advanced Strongholds2+. Offers, starting-card order, mandatory normal Banker and native reactions remain. Actual third-party Bureaucrat sees the real Guild-card recipient. Tech pays only at its matching phase end; Banker waits for Mentat. Earned local support does not become personal money or reserve fees. Selection/printed roles are public capabilities; other native/module envelopes stay separate.',
'Explicit guild-betrayal --leader-skills remains a bounded classic preset of the original all14 setup. It does not require independent cards or another overlay. Native Mentat private questions, skilled-disc posture, Sandmaster collection and normal income retain their shared quotes; exact payer/recipient and printed Bureaucrat third-party conditions remain. Native cards, paid forces, Use/Pass and movement/plan continuation stay physical. Wider combinations and complete-mode acceptance remain separate.',
@@ -5774,11 +5775,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
{area:'Verification',status:'Partial',detail:"Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
{ area:'Implementation',status:'Partial',detail:'Planetologist flight composes all six alternatives with exact original cohorts and signed Discovery origins; ordinary no-skill receipt bytes remain. Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income is mandatory in fresh supported ordinary all14 setups; existing games are not retrofitted and other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',evidence:['game/planetologist-flight.ts','game/bot-planetologist-flight.ts','game/discovery-flight.ts','components/planetologist-movement-action.tsx','tests/planetologist-flight-controls.test.tsx','tests/planetologist-flight-profile.test.ts','tests/fixture-planetologist-ornithopter.ts','tests/planetologist-ornithopter-engine.test.ts','tests/fixture-planetologist-discovery-flight.ts','tests/planetologist-discovery-flight-engine.test.ts','game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Shared owned-flight selection supplies current fields, fixed ranges and whole-map cohorts to the existing form and guarded actual submit. Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'Minimal policies offer legal skill flights from the same projected selection and original quotas, without strategic tuning. All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'Gather-as-one-card-movement and fixed-three overrides are explicitly provisional; no joint FAQ or complete modes are claimed. All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
{area:'Verification',status:'Partial',detail:"Native paid-card and earlier-turn Discovery paths, controlled callbacks and receipt regressions cover the selected flight matrix. Browser/deployed and final assurance remain due. Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
],
},
{
+37
View File
@@ -29,6 +29,43 @@ function fixture() {
return { context, token, move };
}
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
void test('ordinary Discovery receipt signatures retain their legacy bytes with no Planetologist suffix', () => {
const { context, token, move } = fixture();
assert.equal(quoteDiscoveryFlight(context, token, move).selection,
'["p","false_wall_west",[["false_wall_west:16",2,1],["false_wall_west:17",1,0]],1,3,"the_great_flat",15,false,false,null,null]');
});
void test('Discovery Planetologist receipts bind both normalized origins and the exact skill choice', () => {
const { context, token, move } = fixture();
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' },
group: [['false_wall_west:16', 2], ['wind_pass:14', 1]],
eliteGroup: { 'false_wall_west:16': 1, 'wind_pass:14': 0 } };
const receipt = quoteDiscoveryFlight(context, token, gather);
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, gather));
for (const changed of [
{ ...gather, planetologist: { leader: 'other-leader', mode: 'gather' as const } },
{ ...gather, origins: ['false_wall_west', 'sihaya_ridge'] },
{ ...gather, planetologist: { leader: 'p-leader', mode: 'range' as const } },
]) assert.throws(() => validateDiscoveryFlight(context, receipt, changed), DiscoveryFlightError);
const range = { ...move, planetologist: { leader: 'p-leader', mode: 'range' as const } };
assert.notEqual(quoteDiscoveryFlight(context, token, range).selection,
quoteDiscoveryFlight(context, token, move).selection);
});
void test('Discovery gathering rejects unused duplicate third or untrained origins before spending the token', () => {
const { context, token, move } = fixture(), before = structuredClone(context);
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' } };
for (const changed of [gather, { ...gather, origins: ['false_wall_west', 'false_wall_west'] },
{ ...gather, origins: ['wind_pass', 'false_wall_west'] },
{ ...gather, origins: ['false_wall_west', 'wind_pass', 'sihaya_ridge'] },
{ ...gather, planetologist: undefined }, { ...gather, origins: undefined }])
assert.throws(() => quoteDiscoveryFlight(context, token, changed), DiscoveryFlightError);
assert.deepEqual(context, before);
});
function rejectedUnchanged<T>(input: T, work: () => unknown) {
const before = structuredClone(input);
assert.throws(work, DiscoveryFlightError);
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { gameTerritories, location, splitLocation } from '../game/board';
import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { presenceAt } from '../game/force-presence';
import { planetologistLeader } from '../game/planetologist-movement';
import { validateDiscoveryState } from '../game/discoveries';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { assertSelectedDeckSkillsInventory } from './fixture-selected-deck-skills';
import {
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
revealClassicDiscoverySkills,
} from './fixture-discovery-classic-skills';
export type PlanetologistDiscoveryFlightStep = { actor: string; action: Action };
export const planetologistDiscoveryFlightClean = classicDiscoverySkillsClean;
export function planetologistDiscoveryFlightPlayer(game: Game, actor = 'guild') {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
}
export function planetologistDiscoveryFlightInventory(game: Game) {
assertSelectedDeckSkillsInventory(game); validateDiscoveryState(game.discoveries!);
return {
treachery: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.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(),
discoveries: game.discoveries!.tokens.map(token => ({ id: token.id, face: token.face, type: token.type }))
.sort((a, b) => a.id.localeCompare(b.id)),
spice: [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(),
};
}
export function planetologistDiscoveryFlightPhysical(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), moved: player.moved,
shipped: player.shipped }));
}
export function actPlanetologistDiscoveryFlight(game: Game, actor: string, action: Action,
actions: PlanetologistDiscoveryFlightStep[] = []): Game {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function advancePlanetologistDiscoveryFlight(game: Game, until: (state: Game) => boolean,
actions: PlanetologistDiscoveryFlightStep[] = []): Game {
return advanceClassicDiscoverySkills(game, until, actions);
}
/** Original unopened skill-offer lottery only; all other randomness stays native. */
function originalOffers<T>(game: Game, run: () => T): T {
const rolls = skillOfferRolls(game, { guild: 'planetologist', emperor: 'killer-medic', harkonnen: 'prana-bindu-adept' });
const original = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function routes(game: Game, actor: string, first: string) {
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
!game.players.some(player => presenceAt(player, place.id)))
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.filter(key => splitLocation(key).sector !== game.storm);
const targets = rocks.filter(target => groundMoveRouteDistance(view, me, first, target) === 3 &&
botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(first).territory));
const rangeFourLocation = rocks.find(target => groundMoveRouteDistance(view, me, first, target) === 4 &&
botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(first).territory));
if (!targets.length || !rangeFourLocation) return null;
for (const targetLocation of targets) for (const second of rocks) {
if (splitLocation(second).territory === splitLocation(first).territory ||
splitLocation(second).territory === splitLocation(targetLocation).territory) continue;
if (groundMoveRouteDistance(view, me, second, targetLocation) !== 3) continue;
const gatherTooFarLocation = rocks.find(target => target !== second &&
splitLocation(target).territory !== splitLocation(second).territory &&
[first, second].every(from => groundMoveRouteDistance(view, me, from, target) <= 4) &&
[first, second].some(from => groundMoveRouteDistance(view, me, from, target) === 4));
if (gatherTooFarLocation) return { second, targetLocation, rangeFourLocation, gatherTooFarLocation };
}
return null;
}
/** Public Basic/Advanced G/E/H module, readiness and start controls precede the
* actual all14 offers/training. Original Collection earns the physical token;
* native next-turn chronology and a second funded shipment earn both sources.
* Only unopened lotteries/unplayed Spice order are selected. No played force,
* wallet, phase, card, token or receipt is installed. Advanced is preview proof. */
export function createPlanetologistDiscoveryFlightFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, actor = 'guild', observer = 'emperor', opponent = 'harkonnen';
let game = createGame('PLANETOLOGISTDISCOVERYFLIGHT', newPlayer(actor, 'Guild', 'guild'), advanced);
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
joinGame(game, newPlayer(opponent, 'Harkonnen', 'harkonnen'));
const actions: PlanetologistDiscoveryFlightStep[] = [], initial = structuredClone(game);
const act = (owner: string, action: Action) => { game = actPlanetologistDiscoveryFlight(game, owner, action, actions); };
act(actor, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
act(actor, { type: 'moduleOption', module: 'discoveries', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const ready = structuredClone(game);
originalOffers(game, () => act(actor, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }));
const started = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.leaderSkills && game.discoveries);
const native = createClassicDiscoverySkillsFixture({ initial: game, skill: 'planetologist', face: 'ornithopter', amount: 3 });
actions.push(...native.actions.map(step => structuredClone(step)));
// Keep the actual completed chronology. Replaying setup would deal fresh
// Traitors when the last technique is chosen, rather than retain this deal.
assert.equal(native.game.status, 'playing');
game = native.game; const collection = structuredClone(game);
game = revealClassicDiscoverySkills(native, actions);
const acquired = structuredClone(game), originalToken = structuredClone(game.discoveries!.tokens.find(token => token.id === native.token)!);
assert.equal(originalToken.status, 'carried'); assert.equal(originalToken.owner, actor); assert.equal(originalToken.acquiredTurn, 1);
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistDiscoveryFlight(game, until, actions); };
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistDiscoveryFlightClean(state));
const first = location(native.parent, native.parentSector), selected = routes(game, actor, first);
assert.ok(selected, 'The real later board must offer two sources with one common fixed-three route and a four-route negative');
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistDiscoveryFlightStep = { actor,
action: { type: 'ship', ...splitLocation(selected.second), amount: 1, elite: 0, allyPayment: 0 } };
act(actor, shipmentStep.action); reach(planetologistDiscoveryFlightClean);
const afterShipment = structuredClone(game);
assert.equal(planetologistDiscoveryFlightPlayer(beforeShipment, actor).spice - planetologistDiscoveryFlightPlayer(game, actor).spice, 1);
assert.equal(planetologistDiscoveryFlightPlayer(beforeShipment, actor).reserves - planetologistDiscoveryFlightPlayer(game, actor).reserves, 1);
assert.equal(planetologistDiscoveryFlightPlayer(game, actor).forces[first], 3);
assert.equal(planetologistDiscoveryFlightPlayer(game, actor).forces[selected.second], 1);
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
const sources = [first, selected.second] as const, forces = { [first]: 3, [selected.second]: 1 };
const target = splitLocation(selected.targetLocation), rangeFour = splitLocation(selected.rangeFourLocation);
const gatherTooFar = splitLocation(selected.gatherTooFarLocation);
const action: Action = { type: 'move', planetologist: 'gather', forces,
discoveryOrnithopter: native.token, ...target };
const rangeAction: Action = { type: 'move', planetologist: 'range', forces: { [first]: 1 },
discoveryOrnithopter: native.token, ...target };
const rangeFourAction: Action = { ...rangeAction, ...rangeFour };
const gatherTooFarAction: Action = { ...action, ...gatherTooFar };
const originalInventory = planetologistDiscoveryFlightInventory(game);
return { game, rawGame: structuredClone(game), sourceGame: structuredClone(game), advanced, actor, owner: actor,
observer, opponent, trainer, leader: trainer, token: native.token, originalToken, acquiredTurn: originalToken.acquiredTurn!,
initial, ready, started, offered: native.setup, collection, acquired, native,
beforeShipment, shipmentStep, afterShipment, first, second: selected.second, source: first, sources, forces,
target, targetLocation: selected.targetLocation, rangeFour, rangeFourLocation: selected.rangeFourLocation,
gatherTooFar, gatherTooFarLocation: selected.gatherTooFarLocation,
action, gatherAction: action, rangeAction, rangeFourAction, gatherTooFarAction,
originalInventory, originalPhysical: planetologistDiscoveryFlightPhysical(game),
actions, publicActions: actions, sourceActionCount: actions.length,
staging: [
'Original public G/E/H Basic/Advanced moduleOption Leader Skills + Discoveries, readiness and start, actual Treachery/Traitor deal and original private Planetologist offer/training. The original unopened all14 offer lottery alone selects the requested skill; Advanced retains its explicit preview start.',
...native.staging,
'Actual turn-one paid Guild shipment sends three original reserves to the original Discovery parent. Actual Collection inspection/reveal acquires the original physical Ornithopter token on turn one; native Mentat, next Storm and phases reach turn-two owned movement without a clock or phase install.',
'Actual turn-two one-force paid Guild shipment creates the second physical origin on empty rock. Both real origins have a common empty-rock destination exactly three territories away; the independent range-four and gather over-three negatives remain unplayed.',
'The caller owns the actual Discovery + Planetologist range/gather commit. Fixed-three gather-as-one-action is a visibly provisional composition under the 14 October authority, not a joint publisher FAQ. No public origins or internal receipt is supplied.',
] };
}
export type PlanetologistDiscoveryFlightFixture = ReturnType<typeof createPlanetologistDiscoveryFlightFixture>;
export function finishPlanetologistDiscoveryFlight(fixture: PlanetologistDiscoveryFlightFixture, game: Game,
actions: PlanetologistDiscoveryFlightStep[] = []) {
return advancePlanetologistDiscoveryFlight(game, state => state.turn === fixture.game.turn &&
state.phase === 7 && planetologistDiscoveryFlightClean(state), actions);
}
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { gameTerritories, location, splitLocation } from '../game/board';
import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { presenceAt } from '../game/force-presence';
import { planetologistLeader, type PlanetologistMoveMode } from '../game/planetologist-movement';
import type { OrnithopterMode } from '../game/ornithopter';
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
export const PLANETOLOGIST_ORNITHOPTER = 'richese-ornithopter';
export type PlanetologistOrnithopterStep = { actor: string; action: Action };
export type PlanetologistOrnithopterOptions = {
advanced?: boolean;
mode?: OrnithopterMode;
planetologist?: PlanetologistMoveMode;
secondPlanetologist?: PlanetologistMoveMode;
};
export const planetologistOrnithopterClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function planetologistOrnithopterPlayer(game: Game, actor = 'emperor') {
const player = game.players.find(candidate => candidate.id === actor);
assert.ok(player, `The original ${actor} seat must remain present`); return player;
}
/** Includes the original physical card's live played zone between groups. */
export function planetologistOrnithopterInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ornithopter ? [game.ornithopter.card] : []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const treachery = cards.map(card => card.id).sort();
assert.equal(new Set(treachery).size, treachery.length, 'Each original Treachery Card keeps one physical slot');
return { treachery,
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() };
}
export function planetologistOrnithopterStocks(game: Game) {
return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
tanks: player.tanks, physical: Object.values(player.forces).reduce((sum, count) => sum + count, 0),
eliteReserves: player.elites?.reserves ?? 0, eliteTanks: player.elites?.tanks ?? 0,
elites: Object.values(player.elites?.forces ?? {}).reduce((sum, count) => sum + count, 0),
leaders: structuredClone(player.leaders) }));
}
export function planetologistOrnithopterDestination(action: Action) {
assert.equal(typeof action.territory, 'string'); assert.equal(typeof action.sector, 'number');
return location(action.territory as string, action.sector as number);
}
export function actPlanetologistOrnithopter(game: Game, actor: string, action: Action,
actions: PlanetologistOrnithopterStep[] = []) {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
}
function nextPlanetologistOrnithopterStep(game: Game): PlanetologistOrnithopterStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === PLANETOLOGIST_ORNITHOPTER))
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: PLANETOLOGIST_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
if (!decision && game.richeseAuction?.cardId === PLANETOLOGIST_ORNITHOPTER &&
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
}
return nextChoamIndependentSkillsStep(game);
}
export function advancePlanetologistOrnithopter(game: Game, until: (state: Game) => boolean,
actions: PlanetologistOrnithopterStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Planetologist/card chronology ended before its owned boundary');
const step = nextPlanetologistOrnithopterStep(game);
assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`);
game = actPlanetologistOrnithopter(game, step.actor, step.action, actions);
}
throw Error('The native Planetologist/Ornithopter chronology did not reach its finite boundary');
}
function destinations(game: Game, actor: string, origins: readonly string[], distance: number,
empty = true, rocksOnly = true) {
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
return gameTerritories(game).filter(place => (place.type === 'rock' || (!rocksOnly && place.type === 'sand')) &&
!origins.some(source => splitLocation(source).territory === place.id) &&
(!empty || !game.players.some(player => presenceAt(player, place.id))))
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.filter(target => origins.every(source => botEntryAllowed(view, me, splitLocation(target).territory,
splitLocation(target).sector, 'move', splitLocation(source).territory)))
.filter(target => {
const distances = origins.map(source => groundMoveRouteDistance(view, me, source, target));
return distances.every(value => Number.isFinite(value) && value <= distance) && Math.max(...distances) === distance;
});
}
function firstRoute(game: Game, actor: string, sourceA: string, sourceB: string,
mode: OrnithopterMode, skill: PlanetologistMoveMode) {
const origins = skill === 'gather' ? [sourceA, sourceB] : [sourceA];
const range = mode === 'range3' ? 3 : skill === 'range' ? 2 : 1;
const target = destinations(game, actor, origins, range)[0];
const tooFar = destinations(game, actor, origins, range + 1, true, false)[0];
return target && tooFar ? { target, tooFar } : null;
}
/** Original public E/R/A, real living trainer, paid cache1 purchase, original
* funded shipment4 for8 and ordinary split2 earn A2/B2 on turn two. No held
* card, force, wallet, clock, training assignment or flight is installed. */
export function createPlanetologistOrnithopterFixture(options: PlanetologistOrnithopterOptions = {}) {
const advanced = options.advanced ?? false, mode = options.mode ?? 'twoGroups';
const planetologist = options.planetologist ?? 'gather', secondPlanetologist = options.secondPlanetologist ?? planetologist;
const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['emperor', 'richese', 'atreides'],
expansions: ['choam'], skill: 'planetologist' });
const actor = setup.owner, actions: PlanetologistOrnithopterStep[] = [...setup.actions];
let game = structuredClone(setup.afterSetup);
const act = (owner: string, action: Action) => { game = actPlanetologistOrnithopter(game, owner, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistOrnithopter(game, until, actions); };
const originalInventory = planetologistOrnithopterInventory(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache' &&
!!state.richeseCache?.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const beforeAcquisition = structuredClone(game), acquisitionStart = actions.length;
reach(state => planetologistOrnithopterClean(state) &&
planetologistOrnithopterPlayer(state, actor).hand.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const acquisition = { before: beforeAcquisition, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
assert.equal(planetologistOrnithopterPlayer(beforeAcquisition, actor).spice - planetologistOrnithopterPlayer(game, actor).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistOrnithopterClean(state));
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
!game.players.some(player => presenceAt(player, place.id)))
.flatMap(place => place.sectors.filter(sector => sector >= 7 && sector <= 16 && sector !== game.storm)
.map(sector => location(place.id, sector)));
let geometry: { sourceA: string; sourceB: string } | null = null;
for (const sourceA of rocks) {
for (const sourceB of rocks) {
if (splitLocation(sourceA).territory === splitLocation(sourceB).territory ||
groundMoveRouteDistance(view, me, sourceA, sourceB) !== 1 || !botGroundMoveAllowed(view, me, sourceA, sourceB, 0) ||
!firstRoute(game, actor, sourceA, sourceB, mode, planetologist) ||
!destinations(game, actor, [sourceA, sourceB], 1).length) continue;
geometry = { sourceA, sourceB }; break;
}
if (geometry) break;
}
assert.ok(geometry, 'Actual empty rock origins must support the selected card range and two-origin programme');
const { sourceA, sourceB } = geometry;
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistOrnithopterStep = { actor,
action: { type: 'ship', ...splitLocation(sourceA), amount: 4, elite: 0, allyPayment: 0 } };
act(actor, shipmentStep.action); reach(planetologistOrnithopterClean);
const shipment = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
assert.equal(planetologistOrnithopterPlayer(beforeShipment, actor).spice - planetologistOrnithopterPlayer(game, actor).spice, 8);
const beforeSplit = structuredClone(game), splitStep: PlanetologistOrnithopterStep = { actor,
action: { type: 'move', from: sourceA, amount: 2, elite: 0, ...splitLocation(sourceB) } };
act(actor, splitStep.action); reach(planetologistOrnithopterClean);
const splitMovement = { before: beforeSplit, step: splitStep, after: structuredClone(game) };
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistOrnithopterClean(state));
assert.equal(planetologistOrnithopterPlayer(game, actor).forces[sourceA], 2);
assert.equal(planetologistOrnithopterPlayer(game, actor).forces[sourceB], 2);
assert.equal(planetologistOrnithopterPlayer(game, actor).moved, 0);
assert.ok(planetologistOrnithopterPlayer(game, actor).hand.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const routes = firstRoute(game, actor, sourceA, sourceB, mode, planetologist);
assert.ok(routes, 'The real second-turn board retains its chosen bounded route');
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
const forces: Record<string, number> = planetologist === 'gather' ? { [sourceA]: 1, [sourceB]: 1 } : { [sourceA]: 1 };
const action: Action = { type: 'move', planetologist, forces, movementCard: PLANETOLOGIST_ORNITHOPTER,
ornithopter: mode, ...splitLocation(routes.target) };
const tooFarAction: Action = { ...action, ...splitLocation(routes.tooFar) };
assert.deepEqual(planetologistOrnithopterInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, emperor: actor,
richese: setup.seats.richese!, observer: setup.seats.atreides!, advanced, mode, planetologist, secondPlanetologist,
card: PLANETOLOGIST_ORNITHOPTER, trainer, source: sourceA, sourceA, sourceB,
target: splitLocation(routes.target), targetLocation: routes.target, tooFarLocation: routes.tooFar,
action, tooFarAction, forces, acquisition, shipment, beforeShipment, shipmentStep,
afterShipment: shipment.after, splitMovement, originalInventory, originalStocks: planetologistOrnithopterStocks(game),
actions, sourceActionCount: actions.length, endMovement: { type: 'endMovement' } satisfies Action,
staging: [...setup.staging,
'Original public Emperor/Richese/Atreides +choam/Leader Skills ready/start and actual living Emperor Planetologist training. Only original unopened setup skill/scalar lotteries are selected; no played hand, wallet, force, leader, phase or receipt is installed. Advanced remains preview evidence.',
'The original Richese cache offers its actual physical Ornithopter through a real once-around auction. Emperor actually bids/pays1, then ships four normal counters to non-city rock for8 and makes an actual ordinary range-one split of two counters to adjacent rock. Native end-turn and later phase controls earn turn-two A2/B2 and retain the same purchased card.',
'Selected provisional composition: each two-origin gather counts as one card movement. A fixed range3 applies to every gather origin and stays3 with the range alternative; the twoGroups mode uses current normal range with one chosen Planetologist alternative for each action. Original unmoved cohorts constrain both source maps and do not grow from merged first arrivals. This is not a retrieved combined publisher FAQ.',
'Stop before the first flight declaration. Caller-owned actions commit first/second groups, their actual source maps, one original discard, movement credit and original Collection continuation.',
] };
}
export type PlanetologistOrnithopterFixture = ReturnType<typeof createPlanetologistOrnithopterFixture>;
export function movePlanetologistOrnithopter(fixture: PlanetologistOrnithopterFixture,
game = structuredClone(fixture.game), actions: PlanetologistOrnithopterStep[] = []) {
game = actPlanetologistOrnithopter(game, fixture.actor, fixture.action, actions);
return advancePlanetologistOrnithopter(game, planetologistOrnithopterClean, actions);
}
export function planetologistOrnithopterSecondAction(fixture: PlanetologistOrnithopterFixture,
game: Game, skill: PlanetologistMoveMode = fixture.secondPlanetologist): Action {
assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1);
const forces: Record<string, number> = skill === 'gather'
? { [fixture.sourceA]: 1, [fixture.sourceB]: 1 }
: { [fixture.planetologist === 'gather' ? fixture.sourceA : fixture.sourceB]: 1 };
const origins = Object.keys(forces);
const target = skill === 'gather' && fixture.planetologist === 'gather'
? fixture.targetLocation : destinations(game, fixture.actor, origins, skill === 'range' ? 2 : 1)[0];
assert.ok(target, 'The actual remaining original cohort must have a legal second movement');
return { type: 'move', planetologist: skill, forces, ornithopterEvent: game.ornithopter.event, ...splitLocation(target) };
}
export function finishPlanetologistOrnithopter(fixture: PlanetologistOrnithopterFixture, game: Game,
actions: PlanetologistOrnithopterStep[] = []) {
return advancePlanetologistOrnithopter(game, state => state.turn === fixture.game.turn &&
state.phase === 7 && planetologistOrnithopterClean(state), actions);
}
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { location, splitLocation } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryFlightMove } from '../game/discovery-flight-options';
import { planetologistLeader } from '../game/planetologist-movement';
import {
actPlanetologistDiscoveryFlight as act,
createPlanetologistDiscoveryFlightFixture as fixture,
finishPlanetologistDiscoveryFlight as finish,
planetologistDiscoveryFlightInventory as inventory,
planetologistDiscoveryFlightPhysical as physical,
planetologistDiscoveryFlightPlayer as player,
type PlanetologistDiscoveryFlightFixture as Fixture,
type PlanetologistDiscoveryFlightStep as Step,
} from './fixture-planetologist-discovery-flight';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.initial.status, 'lobby'); assert.equal(f.ready.status, 'lobby'); assert.equal(f.started.status, 'setup');
assert.equal(f.collection.status, 'playing'); assert.equal(f.game.status, 'playing');
assert.equal(f.game.advanced, f.advanced); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.actor); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.response ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.game.players.map(p => p.faction), ['guild', 'emperor', 'harkonnen']);
assert.ok(f.game.leaderSkills && f.game.discoveryEnabled && f.game.discoveries);
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery);
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills'));
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries'));
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'discovery' &&
step.action.token === f.token && step.action.reveal === true));
assert.equal(f.collection.turn, 1); assert.equal(f.collection.phase, 7);
assert.equal(f.acquired.turn, 1); assert.equal(f.originalToken.acquiredTurn, 1); assert.equal(f.originalToken.revealedTurn, 1);
assert.equal(f.originalToken.face, 'ornithopter'); assert.equal(f.originalToken.status, 'carried'); assert.equal(f.originalToken.owner, f.actor);
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
assert.match(viewGame(f.acquired, f.actor).discoveryOrnithopter!.blocked!, /after/);
assert.equal(viewGame(f.game, f.actor).discoveryOrnithopter!.blocked, null);
assert.equal(viewGame(f.game, f.observer).discoveryOrnithopter, null);
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(player(f.game, f.actor).shipped, true);
assert.equal(player(f.game, f.actor).forces[f.first], 3); assert.equal(player(f.game, f.actor).forces[f.second], 1);
assert.equal(player(f.game, f.actor).forces['tueks_sietch:5'], 5, 'The original off-route Guild garrison remains physical');
assert.equal(f.beforeShipment.turn, 2); assert.equal(f.shipmentStep.action.type, 'ship'); assert.equal(f.shipmentStep.action.amount, 1);
assert.equal(player(f.beforeShipment, f.actor).spice - player(f.afterShipment, f.actor).spice, 1);
assert.equal(player(f.beforeShipment, f.actor).reserves - player(f.afterShipment, f.actor).reserves, 1);
assert.notEqual(splitLocation(f.first).territory, splitLocation(f.second).territory);
const view = viewGame(f.game, f.actor), me = view.players.find(p => p.id === f.actor)!;
for (const from of f.sources) assert.equal(groundMoveRouteDistance(view, me, from, f.targetLocation), 3);
assert.equal(groundMoveRouteDistance(view, me, f.first, f.rangeFourLocation), 4);
assert.ok(f.sources.some(from => groundMoveRouteDistance(view, me, from, f.gatherTooFarLocation) === 4));
assert.equal(Object.hasOwn(f.action, 'origins'), false); assert.equal(Object.hasOwn(f.action, 'discoveryFlight'), false);
assert.equal(f.sourceActionCount, f.actions.length); assert.ok(f.sourceActionCount > 20);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.treachery.length, 33);
assert.equal(f.originalInventory.skills.length, 14); assert.equal(f.originalInventory.discoveries.length, 8);
}
function moved(f: Fixture, game: Game, action: Action) {
const before = player(f.game, f.actor), after = player(game, f.actor);
const forces = action.forces !== undefined ? action.forces as Record<string, number>
: { [action.from as string]: action.amount as number };
const destination = location(action.territory as string, action.sector as number);
const expected = { ...before.forces };
for (const [from, amount] of Object.entries(forces)) {
expected[from] -= amount; if (!expected[from]) delete expected[from];
}
expected[destination] = (expected[destination] ?? 0) + Object.values(forces).reduce((sum, amount) => sum + amount, 0);
assert.deepEqual(after.forces, expected);
assert.equal(after.moved, before.moved + 1); assert.equal(after.shipped, before.shipped);
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks); assert.equal(after.spice, before.spice);
assert.deepEqual(after.elites, before.elites); assert.deepEqual(after.leaders, before.leaders); assert.deepEqual(after.hand, before.hand);
assert.deepEqual(game.leaderSkills, f.game.leaderSkills); assert.equal(planetologistLeader(game, f.actor), f.trainer);
assert.deepEqual(game.players.filter(p => p.id !== f.actor), f.game.players.filter(p => p.id !== f.actor));
const token = game.discoveries!.tokens.find(token => token.id === f.token)!;
assert.equal(token.status, 'removed'); assert.equal(token.owner, null); assert.equal(token.acquiredTurn, 1);
assert.equal(token.revealedTurn, 1); assert.equal(viewGame(game, f.actor).discoveryOrnithopter, null);
assert.deepEqual(game.discoveries!.tokens.filter(token => token.id !== f.token),
f.game.discoveries!.tokens.filter(token => token.id !== f.token));
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced preview' : 'Basic';
void test(`public ${band} Discovery plus Planetologist range stays fixed at three and consumes one token after the accepted movement`, () => {
const f = fixture({ advanced }); source(f);
rejected(f.game, f.actor, f.rangeFourAction);
const ordinary = { ...f.rangeAction }; delete ordinary.discoveryOrnithopter;
rejected(f.game, f.actor, ordinary);
const quote = discoveryFlightMove(viewGame(f.game, f.actor), ordinary);
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.rangeAction);
const game = act(f.game, f.actor, f.rangeAction); moved(f, game, f.rangeAction);
rejected(game, f.actor, f.rangeAction); rejected(game, f.actor, f.action);
const actions: Step[] = [], done = finish(f, game, actions);
assert.equal(done.turn, 2); assert.equal(done.phase, 7);
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.equal(player(done, f.actor).moved, 1); assert.equal(player(done, f.actor).forces[f.targetLocation], 1);
assert.equal(done.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.deepEqual(inventory(done), f.originalInventory);
});
void test(`public ${band} Discovery plus Planetologist gathers both actual funded origins at fixed three as one ordinary action`, () => {
const f = fixture({ advanced }); source(f);
rejected(f.game, f.actor, f.gatherTooFarAction);
const ordinary = { ...f.action }; delete ordinary.discoveryOrnithopter;
rejected(f.game, f.actor, ordinary);
const quote = discoveryFlightMove(viewGame(f.game, f.actor), ordinary);
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.action);
const before = structuredClone(f.game), game = act(f.game, f.actor, f.action);
assert.deepEqual(f.game, before); moved(f, game, f.action);
assert.equal(player(game, f.actor).forces[f.first] ?? 0, 0); assert.equal(player(game, f.actor).forces[f.second] ?? 0, 0);
assert.equal(player(game, f.actor).forces[f.targetLocation], 4);
rejected(game, f.actor, f.action);
const actions: Step[] = [], done = finish(f, game, actions);
assert.equal(done.turn, 2); assert.equal(done.phase, 7); assert.equal(player(done, f.actor).moved, 1);
assert.equal(player(done, f.actor).forces[f.targetLocation], 4);
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.deepEqual(inventory(done), f.originalInventory);
});
}
void test('the native acquired token rejects same-turn, stale, foreign, malformed and changed-source declarations without consuming it', () => {
const f = fixture(); source(f);
const sameTurn = discoveryFlightMove(viewGame(f.acquired, f.actor), f.rangeAction);
assert.equal(sameTurn.action, null); assert.match(sameTurn.blocked!, /after/);
rejected(f.acquired, f.actor, f.rangeAction);
rejected(f.game, f.observer, f.action); rejected(f.game, f.opponent, f.action);
for (const action of [
{ ...f.action, discoveryOrnithopter: 'discovery-token-99' },
{ ...f.action, discoveryOrnithopter: f.game.discoveries!.tokens.find(token => token.id !== f.token)!.id },
{ ...f.action, forces: { [f.first]: 4, [f.second]: 1 } },
{ ...f.action, forces: { [f.first]: 3 } },
{ ...f.action, forces: { [f.first]: 3, [f.second]: 1, 'tueks_sietch:5': 1 } },
{ ...f.action, forces: { [f.first]: -1, [f.second]: 1 } },
{ ...f.action, origins: f.sources.map(from => splitLocation(from).territory) },
{ ...f.action, discoveryFlight: { token: f.token } },
{ ...f.action, ornithopterEvent: 'stale-card-event' },
]) rejected(f.game, f.actor, action);
assert.deepEqual(physical(f.game), f.originalPhysical);
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
});
void test('controlled eligibility and changed-source supplements cannot reuse the original native combined declaration', () => {
const f = fixture(); source(f);
// CONTROLLED supplements: these do not claim played trainer deaths/captures,
// a new action receipt or casualties in the original positive chronology.
for (const state of ['dead', 'captured'] as const) {
const controlled = structuredClone(f.game), trainer = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
if (state === 'dead') trainer.dead = true; else trainer.capturedBy = f.observer;
assert.equal(planetologistLeader(controlled, f.actor), null); rejected(controlled, f.actor, f.action);
}
const changed = structuredClone(f.game), me = player(changed, f.actor);
me.forces[f.first]--; me.tanks++;
assert.equal(me.reserves + me.tanks + Object.values(me.forces).reduce((sum, amount) => sum + amount, 0), 20);
rejected(changed, f.actor, f.action);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.deepEqual(inventory(f.game), f.originalInventory);
});
void test('all four minimal profiles offer legal fixed-three range and gather Discovery compositions at both native sources', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }); source(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = difficulty;
const actions = botActions(view);
for (const mode of ['range', 'gather'] as const) {
const action = actions.find(action => action.type === 'move' && action.planetologist === mode && action.discoveryOrnithopter === f.token);
assert.ok(action, `${difficulty} must offer the owned ${mode} Discovery composition`);
assert.equal(Object.hasOwn(action, 'origins'), false); assert.equal(Object.hasOwn(action, 'discoveryFlight'), false);
const game = act(f.game, f.actor, action); moved(f, game, action);
assert.equal(player(game, f.actor).moved, 1);
}
}
}
});
@@ -0,0 +1,289 @@
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, type PlanetologistMovementActionProps } from '../components/planetologist-movement-action';
import { planetologistFlightSelection, type PlanetologistFlight } from '../game/planetologist-flight';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { gameTerritories, location, splitLocation } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { richeseCards } from '../game/richese-cards';
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
const near = 'wind_pass:14', other = 'wind_pass_north:17', middle = 'false_wall_west:16';
const target = 'polar_sink:0';
// Controlled owned projections test actual selection/submission callbacks, not
// earned card/token custody or authoritative engine continuation. Independent
// native physical/Discovery sources provide that proof. Stateful UI uses React.
function projected(richese = false): GameView {
const source = createGame('PLANETOLOGISTFLIGHTCONTROLS', newPlayer('p', 'Pilot', richese ? 'richese' : 'emperor'), false, ['choam']);
joinGame(source, newPlayer('a', 'Atreides', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.phase = 5; game.turn = 3; game.storm = 18; game.active = 'p';
game.order = ['p', 'a']; game.movementRemaining = ['p', 'a'];
const me = game.players[0];
me.forces = { [near]: 2, [other]: 2, [middle]: 2 };
me.moved = 0; me.movesAllowed = 1; me.shipped = true;
game.leaderSkills = { offer: null, eligibleLeaders: [], battleChoice: null,
assignments: [{ skill: 'planetologist', owner: me.id, controller: me.id,
leader: me.leaders[0].id, captured: false, faceUp: true }] };
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter'); assert.ok(card);
game.ornithopter = { card, blocked: null, active: null,
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }] };
return game;
}
function carried(game: GameView) {
game.discoveries = { blocked: null, canInspect: [], canReveal: [], supplyCount: 0, newlyRevealed: [],
tokens: [{ id: 'discovery-token-1', type: 'smuggler', face: 'ornithopter', status: 'carried',
territory: 'funeral_plain', sector: 2, revealedTurn: 1, owner: game.me, acquiredTurn: 1 }],
} as NonNullable<GameView['discoveries']>;
game.discoveryOrnithopter = { token: 'discovery-token-1', acquiredTurn: 1, range: 3, blocked: null };
return game;
}
function active(game: GameView, mode: 'range3' | 'twoGroups' = 'twoGroups') {
const flight = game.ornithopter!;
flight.active = { event: 'controlled-active-flight', mode, completed: mode === 'twoGroups' ? 1 : 0, remaining: 1,
cohort: { forces: { [near]: 1, [other]: 1, [middle]: 1 }, elites: {} } };
game.playedOrnithopter = { player: game.me, card: flight.card, mode, completed: flight.active.completed };
game.players[0].moved = mode === 'twoGroups' ? 1 : 0;
return game;
}
function atDistance(game: GameView, count: number): string {
const source = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.find(from => !['arrakeen', 'carthag', 'false_wall_west'].includes(splitLocation(from).territory) &&
groundMoveRouteDistance(game, game.players[0], from, target) === count);
assert.ok(source, `A canonical controlled source at distance ${count} is available`);
game.players[0].forces[source] = 2;
return source;
}
function propsFor(game: GameView, flight: PlanetologistFlight = 'range3', forces: Record<string, number> = { [middle]: 1 }): PlanetologistMovementActionProps {
return { game, mode: 'range', destination: 'polar_sink', sector: 0, forces, elites: {},
flight, busy: false, act() {} };
}
function click(props: PlanetologistMovementActionProps): Action[] {
const actions: Action[] = [];
PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
return actions;
}
function expected(props: PlanetologistMovementActionProps): Action {
return { type: 'move', planetologist: props.mode, forces: props.forces,
...planetologistFlightSelection(props.game, props.flight).fields, territory: props.destination, sector: props.sector };
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination: 'polar_sink', sector: 0, busy, act() {} }));
}
function button(props: PlanetologistMovementActionProps) {
return PlanetologistMovementAction(props);
}
void test('ordinary selection and six/seven-argument drafts retain the original physical action without flight fields', () => {
const game = projected(), before = structuredClone(game);
assert.deepEqual(planetologistFlightSelection(game), { fields: {}, blocked: null });
const ordinary = { type: 'move', planetologist: 'range', forces: { [middle]: 1 }, territory: 'polar_sink', sector: 0 };
assert.deepEqual(planetologistMoveDraft(game, 'range', 'polar_sink', 0, { [middle]: 1 }, {}).action, ordinary);
assert.deepEqual(planetologistMoveDraft(game, 'range', 'polar_sink', 0, { [middle]: 1 }, {}, false).action, ordinary);
assert.deepEqual(click(propsFor(game, 'ordinary')), [ordinary]);
assert.deepEqual(game, before);
});
void test('first physical selections reuse the offered card and exact printed mode with fixed three only for range3', () => {
const game = projected();
assert.deepEqual(planetologistFlightSelection(game, 'range3'), {
fields: { movementCard: game.ornithopter!.card.id, ornithopter: 'range3' }, rangeOverride: 3, blocked: null,
});
assert.deepEqual(planetologistFlightSelection(game, 'twoGroups'), {
fields: { movementCard: game.ornithopter!.card.id, ornithopter: 'twoGroups' }, blocked: null,
});
});
void test('Discovery selection reuses the owned carried token and acquisition history without another flight source', () => {
const game = carried(projected());
assert.deepEqual(planetologistFlightSelection(game, 'discovery'), {
fields: { discoveryOrnithopter: 'discovery-token-1' }, rangeOverride: 3, blocked: null,
});
const props = propsFor(game, 'discovery');
assert.deepEqual(click(props), [expected(props)]);
assert.equal('movementCard' in click(props)[0], false);
assert.equal('ornithopterEvent' in click(props)[0], false);
});
void test('fixed physical and Discovery flight allow range or two-origin gather with each origin up to three', () => {
for (const flight of ['range3', 'discovery'] as const) for (const mode of ['range', 'gather'] as const) {
const game = flight === 'discovery' ? carried(projected()) : projected();
const far = atDistance(game, 3), before = structuredClone(game);
const forces = mode === 'range' ? { [far]: 1 } : { [far]: 1, [middle]: 2 };
const props = { ...propsFor(game, flight, forces), mode };
assert.equal(planetologistMoveDraft(game, mode, 'polar_sink', 0, forces, {}, false, flight).blocked, null);
assert.deepEqual(click(props), [expected(props)]);
assert.equal(button({ ...props, busy: true }).props.disabled, true);
assert.deepEqual(click({ ...props, busy: true }), []);
assert.deepEqual(game, before);
}
});
void test('fixed three cannot become four for either Planetologist alternative or flight source', () => {
for (const flight of ['range3', 'discovery'] as const) for (const mode of ['range', 'gather'] as const) {
const game = flight === 'discovery' ? carried(projected()) : projected();
const distant = atDistance(game, 4);
const forces = mode === 'range' ? { [distant]: 1 } : { [distant]: 1, [near]: 1 };
assert.deepEqual(click({ ...propsFor(game, flight, forces), mode }), []);
}
});
void test('two-group card flight keeps boosted range separate from gather normal range and uses one card movement per gather', () => {
const game = projected(), far = atDistance(game, 3);
assert.deepEqual(click(propsFor(game, 'twoGroups')), [expected(propsFor(game, 'twoGroups'))]);
const gather = { ...propsFor(game, 'twoGroups', { [near]: 1, [other]: 1 }), mode: 'gather' as const };
assert.deepEqual(click(gather), [expected(gather)]);
assert.deepEqual(click({ ...gather, forces: { [near]: 1, [middle]: 1 } }), []);
assert.deepEqual(click(propsFor(game, 'twoGroups', { [far]: 1 })), []);
assert.equal(gather.game.players[0].moved, 0, 'Quoting/submitting to a collector does not manufacture a card movement');
});
void test('the active own frame resolves ordinary to its exact event and permits remaining card movement after ordinary allowance is spent', () => {
const game = active(projected()), before = structuredClone(game);
const quote = planetologistFlightSelection(game);
assert.deepEqual(quote, { fields: { ornithopterEvent: 'controlled-active-flight' }, cohort: game.ornithopter!.active!.cohort, blocked: null });
for (const flight of ['ordinary', 'twoGroups'] as const) {
const props = { ...propsFor(game, flight, { [near]: 1, [other]: 1 }), mode: 'gather' as const };
assert.equal(button(props).props.disabled, false);
assert.deepEqual(click(props), [expected(props)]);
assert.equal('movementCard' in click(props)[0], false);
}
assert.deepEqual(click(propsFor(game, 'range3')), []);
assert.deepEqual(game, before);
assert.deepEqual(click(propsFor(carried(structuredClone(game)), 'discovery')), []);
});
void test('active cohort validation includes every gathered source and exact original normal/elite quotas', () => {
const game = active(projected());
const gather = { ...propsFor(game, 'twoGroups', { [near]: 2, [other]: 1 }), mode: 'gather' as const };
assert.deepEqual(click(gather), []);
game.players[0].elites = { forces: { [near]: 1 }, reserves: 0, tanks: 0, revived: 0 };
const typed = { ...propsFor(game, 'twoGroups', { [near]: 1 }), elites: { [near]: 1 } };
assert.deepEqual(click(typed), []);
assert.deepEqual(click({ ...typed, elites: { [near]: 0 } }), [
{ type: 'move', planetologist: 'range', forces: { [near]: 1 }, eliteForces: { [near]: 0 },
ornithopterEvent: 'controlled-active-flight', territory: 'polar_sink', sector: 0 },
]);
game.ornithopter!.active!.cohort!.elites[near] = 1;
assert.equal(click(typed).length, 1);
});
void test('the selected concealed zero marker must be an original unmoved member of an active physical cohort', () => {
const game = active(projected(true));
const tokens = createRicheseNoField(['flight-zero', 'flight-three', 'flight-five']);
const model = deployRicheseNoField(tokens, { tokenId: 'flight-zero', controller: game.me, location: splitLocation(near) });
const publicMarker = projectRicheseNoField(model, false);
game.players[0].noField = publicMarker;
game.richeseNoField = { owner: game.me, event: 'controlled-marker-event', public: publicMarker, private: model } as NonNullable<GameView['richeseNoField']>;
const props = { ...propsFor(game, 'twoGroups', { [other]: 1 }), mode: 'gather' as const, includeNoField: true };
assert.deepEqual(click(props), []);
game.ornithopter!.active!.cohort!.noField = { tokenId: 'flight-zero', event: 'controlled-marker-event', from: near };
const before = structuredClone(game);
assert.deepEqual(click(props), [{ ...expected(props), noField: 'flight-zero', event: 'controlled-marker-event' }]);
assert.deepEqual(game, before);
game.ornithopter!.active!.cohort!.noField!.event = 'old-marker-event';
assert.deepEqual(click(props), []);
});
void test('malformed completed stale foreign and missing-cohort active frames expose no flight selection', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.playedOrnithopter!.player = 'a'; },
game => { game.playedOrnithopter!.card = { ...game.ornithopter!.card, id: 'stale-card' }; },
game => { game.ornithopter!.active!.event = ''; },
game => { game.ornithopter!.active!.remaining = 0; },
game => { game.ornithopter!.active!.completed = 0.5; },
game => { game.ornithopter!.modes = []; },
game => { delete game.ornithopter!.active!.cohort; },
game => { game.ornithopter!.active!.cohort!.forces[near] = -1; },
game => { game.ornithopter!.active!.cohort!.elites[near] = 2; },
];
for (const change of changes) {
const game = active(projected()); change(game);
assert.ok(planetologistFlightSelection(game).blocked);
assert.deepEqual(click(propsFor(game, 'ordinary', { [near]: 1 })), []);
}
});
void test('first-card mode blocks and current carried-token ownership acquisition and removal remain authoritative', () => {
const blocked = projected(); blocked.ornithopter!.modes[0].blocked = 'This card mode is unavailable.';
assert.equal(planetologistFlightSelection(blocked, 'range3').blocked, 'This card mode is unavailable.');
assert.deepEqual(click(propsFor(blocked)), []);
blocked.ornithopter = null; assert.ok(planetologistFlightSelection(blocked, 'twoGroups').blocked);
const changes: ((game: GameView) => void)[] = [
game => { game.discoveryOrnithopter = null; },
game => { game.discoveryOrnithopter!.blocked = 'Finish the current response.'; },
game => { game.discoveries!.tokens[0].owner = 'a'; },
game => { game.discoveries!.tokens[0].status = 'removed'; },
game => { game.discoveries!.tokens[0].acquiredTurn = 3; game.discoveryOrnithopter!.acquiredTurn = 3; },
game => { game.discoveries!.tokens[0].revealedTurn = 2; },
game => { game.discoveries!.tokens.push({ ...game.discoveries!.tokens[0], id: 'discovery-token-2' }); },
];
for (const change of changes) {
const game = carried(projected()); change(game);
assert.ok(planetologistFlightSelection(game, 'discovery').blocked);
assert.deepEqual(click(propsFor(game, 'discovery')), []);
}
});
void test('actual callbacks do not silently substitute a newly selected card event or Discovery token', () => {
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
const game = flight === 'discovery' ? carried(projected()) : flight === 'twoGroups' ? active(projected()) : projected();
const actions: Action[] = [];
const props = propsFor(game, flight, { [near]: 1 });
const leaf = PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } });
assert.equal(leaf.props.disabled, false);
if (flight === 'range3') game.ornithopter!.card = { ...game.ornithopter!.card, id: 'another-card' };
else if (flight === 'twoGroups') game.ornithopter!.active!.event = 'new-flight-event';
else {
game.discoveries!.tokens[0].id = 'discovery-token-2';
game.discoveryOrnithopter!.token = 'discovery-token-2';
}
leaf.props.onClick(); assert.deepEqual(actions, []);
}
});
void test('current phase response automatic player allowance and trainer guards still protect every actual flight callback', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.phase = 6; }, game => { game.active = 'a'; },
game => { game.response = {} as NonNullable<GameView['response']>; },
game => { game.automaticContinuationPending = true; }, game => { game.players[0].autopilot = 'Easy'; },
game => { game.players[0].moved = 1; }, game => { game.players[0].leaders[0].dead = true; },
];
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) for (const change of changes) {
const game = flight === 'discovery' ? carried(projected()) : projected(); change(game);
const props = { ...propsFor(game, flight), unavailableReason: '' };
assert.deepEqual(click(props), []);
assert.equal(button(props).props.disabled, true);
}
});
void test('the parent exposes all transport choices persistent provisional counting and original second-movement controls', () => {
const game = carried(projected());
const html = markup(game);
for (const value of ['ordinary', 'range3', 'twoGroups', 'discovery']) assert.match(html, new RegExp(`<option value="${value}"`));
assert.match(html, /Movement transport/);
assert.match(html, /aria-label="Provisional Planetologist flight composition"/);
assert.match(html, /fixed-range Ornithopter and Discovery flight stay at three territories for either skill choice/);
assert.match(html, /A gather counts as one card movement/);
assert.match(html, /different original forces/);
const second = markup(active(projected()));
assert.match(second, /1 card movements completed · 1 remaining/);
assert.match(second, /Select original unmoved forces below/);
assert.match(second, /Wind Pass, sector 14 · 1 forces/);
assert.doesNotMatch(second, /Wind Pass, sector 14 · 2 forces/);
});
void test('selection and actual flight callbacks use projected capabilities without private hands reserves or opponent inventories', () => {
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
const game = flight === 'discovery' ? carried(projected()) : projected();
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves'])
Object.defineProperty(player, field, { get() { throw Error(`Flight controls read unrelated ${player.id}.${field}`); } });
const props = propsFor(game, flight);
assert.deepEqual(click(props), [expected(props)]);
assert.match(markup(game), /Provisional Planetologist flight composition/);
}
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { createDiscoveryNativeE2SkillsFixture } from './fixture-discovery-native-e2-skills';
void test('native Discovery skill projections retain the existing enabled module for movement eligibility', () => {
for (const advanced of [false, true]) {
const f = createDiscoveryNativeE2SkillsFixture({ advanced });
assert.equal(f.game.discoveryEnabled, true);
assert.equal(planetologistMovementModeSupported(f.game), true);
for (const actor of [f.richese, f.choam, f.guild]) {
const projected = viewGame(f.game, actor);
assert.equal(projected.lobbyModules, null);
assert.equal(planetologistMovementModeSupported(projected), true);
}
}
});
@@ -78,7 +78,7 @@ void test('the owned marker has a usable checkbox origin and persistent provisio
assert.match(html, /value stays concealed/);
assert.match(html, /No direct joint FAQ settles this combination/);
assert.match(html, /No physical board forces are available to move/);
assert.match(html, /Concealed No-Fields cannot join either Ornithopter card movement or Discovery flight/);
assert.match(html, /Only one flight source may be used for each movement/);
assert.doesNotMatch(html, /controlled-zero|controlled-three|controlled-five|Confirm|Acknowledge|countdown/);
}
assert.deepEqual(game, before);
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { splitLocation, territory } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { planetologistLeader } from '../game/planetologist-movement';
import {
actPlanetologistOrnithopter as act, advancePlanetologistOrnithopter as advance,
createPlanetologistOrnithopterFixture as fixture, finishPlanetologistOrnithopter as finish,
movePlanetologistOrnithopter as move, planetologistOrnithopterClean as clean,
planetologistOrnithopterDestination as destination, planetologistOrnithopterInventory as inventory,
planetologistOrnithopterPlayer as player, planetologistOrnithopterSecondAction as secondAction,
planetologistOrnithopterStocks as stocks, type PlanetologistOrnithopterFixture,
} from './fixture-planetologist-ornithopter';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function ownView(game: Game, actor: string) { return viewGame(game, actor).players.find(seat => seat.id === actor)!; }
function selected(action: Action): Record<string, number> {
assert.ok(action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces));
for (const count of Object.values(action.forces)) assert.equal(typeof count, 'number');
return action.forces as Record<string, number>;
}
function expectedForces(game: Game, actor: string, action: Action) {
const forces = structuredClone(player(game, actor).forces);
for (const [key, count] of Object.entries(selected(action))) {
forces[key] -= count; if (!forces[key]) delete forces[key];
}
const target = destination(action);
forces[target] = (forces[target] ?? 0) + Object.values(selected(action)).reduce((sum, count) => sum + count, 0);
return forces;
}
function source(f: PlanetologistOrnithopterFixture) {
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'richese', 'atreides']);
assert.deepEqual(f.game.expansions, ['choam']); assert.ok(f.game.leaderSkills);
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(clean(f.game));
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
assert.ok(f.acquisition.steps.some(step => step.actor === f.actor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.actor).spice - player(f.acquisition.after, f.actor).spice, 1);
assert.ok(player(f.acquisition.before, f.richese).spice < player(f.acquisition.after, f.richese).spice);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
assert.equal(f.shipment.before.turn, 1); assert.equal(f.shipment.step.action.type, 'ship');
assert.equal(f.shipment.step.action.amount, 4); assert.equal(f.shipment.step.action.elite, 0);
assert.equal(player(f.shipment.before, f.actor).spice - player(f.shipment.after, f.actor).spice, 8);
assert.equal(player(f.shipment.before, f.actor).reserves - player(f.shipment.after, f.actor).reserves, 4);
assert.equal(f.splitMovement.step.action.type, 'move'); assert.equal(f.splitMovement.step.action.amount, 2);
assert.equal(f.splitMovement.step.action.planetologist, undefined);
assert.equal(player(f.splitMovement.after, f.actor).moved, 1);
assert.deepEqual(player(f.game, f.actor).forces, { [f.sourceA]: 2, [f.sourceB]: 2 });
assert.equal(player(f.game, f.actor).reserves, 16); assert.equal(player(f.game, f.actor).tanks, 0);
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(ownView(f.game, f.actor).movesAllowed, 1);
assert.equal(player(f.game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(f.game.ornithopter ?? null, null);
assert.equal(territory(splitLocation(f.sourceA).territory).type, 'rock');
assert.equal(territory(splitLocation(f.sourceB).territory).type, 'rock');
assert.equal(Object.hasOwn(f.action, 'origins'), false);
const view = viewGame(f.game, f.actor), me = view.players.find(seat => seat.id === f.actor)!;
const origins = Object.keys(f.forces), distances = origins.map(from => groundMoveRouteDistance(view, me, from, f.targetLocation));
const range = f.mode === 'range3' ? 3 : f.planetologist === 'range' ? 2 : 1;
assert.equal(Math.max(...distances), range);
assert.ok(distances.every(distance => distance <= range));
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function complete(f: PlanetologistOrnithopterFixture, game: Game, count: 1 | 2) {
assert.equal(player(game, f.actor).moved, count);
assert.equal(ownView(game, f.actor).movesAllowed, count);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, count - 1);
assert.equal(game.ornithopter ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 0);
assert.deepEqual(stocks(game), f.originalStocks);
assert.deepEqual(game.leaderSkills, f.game.leaderSkills);
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const advanced of [false, true]) for (const planetologist of ['range', 'gather'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} physical range3 with Planetologist ${planetologist} stays capped at three and spends one movement`, () => {
const f = fixture({ advanced, mode: 'range3', planetologist }); source(f);
rejected(f.game, f.actor, f.tooFarAction);
const before = structuredClone(f.game), expected = expectedForces(f.game, f.actor, f.action);
let game = move(f); assert.deepEqual(f.game, before);
assert.deepEqual(player(game, f.actor).forces, expected); complete(f, game, 1);
rejected(game, f.actor, f.action);
const counters = structuredClone(player(game, f.actor).forces), steps: { actor: string; action: Action }[] = [];
game = finish(f, game, steps);
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
assert.ok(steps.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
for (const advanced of [false, true]) for (const planetologist of ['range', 'gather'] as const)
for (const secondPlanetologist of ['range', 'gather'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} twoGroups changes Planetologist ${planetologist} to ${secondPlanetologist} with distinct original cohorts`, () => {
const f = fixture({ advanced, mode: 'twoGroups', planetologist, secondPlanetologist }); source(f);
rejected(f.game, f.actor, f.tooFarAction);
const firstExpected = expectedForces(f.game, f.actor, f.action);
let game = move(f);
assert.deepEqual(player(game, f.actor).forces, firstExpected);
assert.equal(player(game, f.actor).moved, 1); assert.equal(ownView(game, f.actor).movesAllowed, 2);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1);
const expectedCohort = { [f.sourceA]: 2 - f.forces[f.sourceA], [f.sourceB]: 2 - (f.forces[f.sourceB] ?? 0) };
assert.deepEqual(game.ornithopter.cohort!.forces, expectedCohort,
'The first card group removes every selected origin map from the original cohort');
assert.deepEqual(game.ornithopter.cohort!.elites, {});
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
rejected(game, f.actor, { type: 'move', planetologist: 'range', forces: { [f.targetLocation]: 1 },
ornithopterEvent: game.ornithopter.event, ...splitLocation(f.sourceA) });
const action = secondAction(f, game), expected = expectedForces(game, f.actor, action);
assert.equal(action.planetologist, secondPlanetologist);
assert.equal(Object.hasOwn(action, 'origins'), false);
rejected(game, f.actor, { ...action, ornithopterEvent: 'stale-card-event' });
game = act(game, f.actor, action); game = advance(game, clean);
assert.deepEqual(player(game, f.actor).forces, expected); complete(f, game, 2);
if (planetologist === 'gather' && secondPlanetologist === 'gather')
assert.deepEqual(player(game, f.actor).forces, { [f.targetLocation]: 4 },
'The same-origin residual units may legitimately merge with the first gather without reusing its units');
rejected(game, f.actor, action);
const counters = structuredClone(player(game, f.actor).forces);
game = finish(f, game);
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
void test('original residual quotas survive merging a first card arrival into a later source stack', () => {
const f = fixture({ mode: 'twoGroups', planetologist: 'range' }); source(f);
const first: Action = { ...f.action, ...splitLocation(f.sourceB) };
let game = act(f.game, f.actor, first); game = advance(game, clean);
assert.equal(player(game, f.actor).forces[f.sourceB], 3);
assert.equal(game.ornithopter!.cohort!.forces[f.sourceB], 2);
const nativeSecond = secondAction(f, game, 'range');
rejected(game, f.actor, { ...nativeSecond, forces: { [f.sourceB]: 3 } });
const second: Action = { ...nativeSecond, forces: { [f.sourceB]: 2 } };
game = act(game, f.actor, second); game = advance(game, clean);
complete(f, game, 2);
assert.equal(player(game, f.actor).forces[f.sourceB], 1, 'The first moved unit stays behind at its new location');
assert.equal(player(game, f.actor).forces[destination(second)], 2);
});
void test('ending after one accepted gather discards the actual card without earning an abandoned second-group credit', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, mode: 'twoGroups', planetologist: 'gather' }); source(f);
let game = move(f);
const counters = structuredClone(player(game, f.actor).forces);
game = act(game, f.actor, f.endMovement); game = advance(game, state => clean(state) && state.active !== f.actor);
assert.equal(game.ornithopter ?? null, null);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(player(game, f.actor).moved, 1);
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
game = finish(f, game);
assert.equal(game.phase, 7); assert.deepEqual(player(game, f.actor).forces, counters);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('physical Planetologist declarations reject foreign, stale and untrained controlled selections before spending the original card', () => {
const f = fixture({ mode: 'range3', planetologist: 'gather' }); source(f);
rejected(f.game, f.observer, f.action);
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'foreign-card-event' });
rejected(f.game, f.actor, { ...f.action, movementCard: 'foreign-card' });
rejected(f.game, f.actor, { ...f.action, origins: [splitLocation(f.sourceA).territory, splitLocation(f.sourceB).territory] });
rejected(f.game, f.actor, { ...f.action, planetologist: { mode: 'gather', leader: f.trainer } });
// Controlled trainer inputs supplement the twelve genuine public programmes;
// they are not claimed to be played casualty/capture histories.
for (const state of ['dead', 'captured'] as const) {
const controlled = structuredClone(f.game), leader = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
if (state === 'dead') leader.dead = true;
else leader.capturedBy = f.observer;
assert.equal(planetologistLeader(controlled, f.actor), null);
rejected(controlled, f.actor, f.action);
}
assert.deepEqual(stocks(f.game), f.originalStocks);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
});
void test('all four minimal profiles offer physical card and current second-group Planetologist alternatives', () => {
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const)
for (const planetologist of ['range', 'gather'] as const) {
const f = fixture({ advanced, mode, planetologist }); source(f);
const sources = [f.game, ...(mode === 'twoGroups' ? [move(f)] : [])];
for (const game of sources) for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.actor); view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
const candidates = botActions(view).filter(action => action.type === 'move' && action.planetologist === planetologist &&
(game.ornithopter ? action.ornithopterEvent === game.ornithopter.event
: action.movementCard === f.card && action.ornithopter === mode));
assert.ok(candidates.length, `${difficulty} offers ${mode}/${planetologist}/${game.ornithopter ? 'second' : 'initial'} movement`);
const action = candidates[0], before = player(game, f.actor).moved;
let accepted = act(game, f.actor, action); accepted = advance(accepted, clean);
assert.equal(player(accepted, f.actor).moved, before + 1);
assert.deepEqual(stocks(accepted), stocks(game));
assert.deepEqual(inventory(accepted), f.originalInventory);
}
}
});