diff --git a/README.md b/README.md
index 4a251f46..0fcc1181 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,9 @@ The local Leader Skills prototype also supports Basic Moritani with base
opponents and the full fourteen-card deck. See [entry, connected interactions
and remaining boundaries](docs/MORITANI_LEADER_SKILLS.md). Basic Tleilaxu also
supports the full deck, native Rihani exchanges and saved revival choices; see
-[its integration boundary](docs/TLEILAXU_LEADER_SKILLS.md).
+[its integration boundary](docs/TLEILAXU_LEADER_SKILLS.md). Basic Ixians now connect
+cyborg movement, HMS routes and battle aftermath through the same full deck; see
+[Ixian integration](docs/IX_LEADER_SKILLS.md).
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
diff --git a/components/planetologist-movement.tsx b/components/planetologist-movement.tsx
index e322c610..072615f1 100644
--- a/components/planetologist-movement.tsx
+++ b/components/planetologist-movement.tsx
@@ -5,6 +5,7 @@ import type { Action, GameView } from '@/game/engine';
import {
planetologistLeader,
planetologistMovementModeSupported,
+ selectedOriginElites,
type PlanetologistMoveMode,
} from '@/game/planetologist-movement';
import { botGroundMoveAllowed } from '@/game/bot-mobility';
@@ -93,7 +94,8 @@ export function planetologistMoveDraft(
if (
selected.some(
([from]) =>
- !botGroundMoveAllowed(game, me, from, target, elites[from] ?? 0, mode),
+ !botGroundMoveAllowed(game, me, from, target,
+ selectedOriginElites(selected, elites, splitLocation(from).territory), mode),
)
)
return {
@@ -194,6 +196,12 @@ export function PlanetologistMovement({
Destination: {territory(destination).name}, sector {sector}. Select the
physical forces to move below.
+ {me.faction === 'ixians' && (
+
+ Each origin uses its own selected cyborgs for movement range. Cyborgs
+ from one territory cannot carry suboids from the other territory farther.
+
+ )}
{sources.length ? (
sources.map(([from, owned]) => {
const source = splitLocation(from);
diff --git a/components/sandmaster-movement.tsx b/components/sandmaster-movement.tsx
index f154dcbd..f78bd175 100644
--- a/components/sandmaster-movement.tsx
+++ b/components/sandmaster-movement.tsx
@@ -3,10 +3,8 @@
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
- GRAPH,
gameTerritories,
location,
- mobileRouteDistance,
splitLocation,
} from '@/game/board';
import {
@@ -17,6 +15,8 @@ import {
sandmasterLeader,
sandmasterModeSupported,
sandmasterPathBlocked,
+ sandmasterAdjacent,
+ sandmasterRouteDistance,
type SandmasterChoice,
} from '@/game/sandmaster-movement';
import { Button } from './ui/button';
@@ -144,18 +144,18 @@ export function SandmasterMovement({
const extensions =
last === destination
? []
- : (GRAPH[last] ?? []).filter(
+ : sandmasterAdjacent(game, last).filter(
(next) =>
!route.includes(next) &&
!sandmasterPathBlocked(game, game.me, from, next) &&
- mobileRouteDistance([...route, next]) <= order.range,
+ sandmasterRouteDistance(game, [...route, next]) <= order.range,
);
return (
{placeName(game, from)} route
{route.map((key) => placeName(game, key)).join(' → ')} ·{' '}
- {mobileRouteDistance(route)} / {order.range} territories
+ {sandmasterRouteDistance(game, route)} / {order.range} territories
`${count} from sector ${splitLocation(key).sector}`).join(', ')})` : '';
+ return `Save ${option.normal} ordinary + ${option.elite} elite: ${kept}return ${total - (option.kept ? 1 : 0)} to reserves${origins}`;
}
export function SukGraduatePanel({ decision, act, busy }: {
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 91220804..6fb55891 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,32 +6,29 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Latest delivered checkpoint: `489bd32`, [Basic Moritani with Leader Skills](MORITANI_LEADER_SKILLS.md).
-Types, lint, **5,034 offline tests** and the production build passed; five
-complete two-through-six-player samples had no rejected actions. Browser setup
-and refresh were checked. All 1,580 prior rooms and 3,348 seats remained intact;
-one QA room/seat brought the baseline to 1,581 rooms and 3,349 human seats.
-Screenshot capture timed out; full visual acceptance remains open.
+Latest delivered checkpoint: `d3559f6`, [Basic Tleilaxu with Leader Skills](TLEILAXU_LEADER_SKILLS.md).
+Types, lint, **5,058 offline tests** and the production build passed; five
+complete two-through-six-player samples had no rejected actions. Browser setup,
+Rihani exchange, mobile controls and refresh were checked. All 1,581 prior rooms
+and 3,349 seats remained intact; one QA room/seat brought the baseline to 1,582
+rooms and 3,350 human seats. Screenshot capture timed out; full visual acceptance
+remains open.
-Current work: [Basic Tleilaxu with Leader Skills](TLEILAXU_LEADER_SKILLS.md)
-connects genuine full-deck setup, own revival through automatic response chains,
-native Rihani Face Dancer exchanges and later Face Dance skill custody. Zoal's
-Smuggler collection now uses the opposing printed disc value. Focused tests,
-authenticated saved choices, private controls and a reusable two-through-six
-player sample profile accompany final source-bound reports. Existing missing
-skill bands, Advanced Tleilaxu/foreign gholas and other roster/module combinations
-remain separate work; admission does not certify complete Leader Skills.
-The Bureaucrat/Emperor gift question is still pending. The `8f7a62c` policy
-checkpoint removed usage checks/cutoffs and deferred full AI work until feature
-completion, followed by three adjacent 75% targets.
+Current work: [Basic Ixians with Leader Skills](IX_LEADER_SKILLS.md) connects
+genuine starting-card/skill setup, per-origin cyborg movement, Sandmaster HMS
+entry/exit, Suk rescue before actual cyborg substitution, Rihani precedence and
+mandatory green-Special disposal. Saved receipts bind physical losses and choices.
+Focused tests, authenticated setup recovery and a two-through-six-player sample
+profile accompany final reports. Existing missing skill bands, Advanced and
+other module combinations remain separate work. The Bureaucrat/Emperor gift
+question is still pending.
The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese
-empty-cache auction arithmetic still cross recorded source questions; Loyalty is
-now connected. For later Tleilaxu/Skills integration, create replacement offers
-at successful native revival commitment: the current automatic response loop can
-revive after the outer custody observer. This is a confirmed dependency in the
-currently gated roster, not a demonstrated supported-profile defect; no synthetic
-profile was enabled or labeled verified in this pass.
+empty-cache auction arithmetic still cross recorded source questions. Native
+Tleilaxu revival now creates its skill offer at successful commitment, including
+automatic response chains; the former dependency is resolved in its Basic profile.
+The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred full AI
+work until feature completion, followed by three adjacent 75% targets.
The [platform audit](AUTOPILOT.md#platform-audit-20-september-2026) found no supported
Sites scheduler or Durable Object provisioning contract. Durable unattended bot
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 8c03c6fa..faae01cd 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,15 @@
# Dune implementation status
+## 21 September 2026 — Basic Ixians with Leader Skills
+
+Genuine full-deck setup connects cyborg movement per origin, Sandmaster HMS entry
+and exit, Suk rescue before actual Tank substitution, Rihani precedence and exact
+green-Special mandatory disposal. Signed aftermath choices, private controls,
+legal AI paths, authenticated concurrent recovery and two-through-six-player
+samples accompany the [integration contract](IX_LEADER_SKILLS.md). Existing
+missing effects and mode/publication gates remain; source-bound final evidence
+is stored outside the checkout.
+
## 21 September 2026 — Basic Tleilaxu with Leader Skills
Genuine Basic Tleilaxu setup now uses the full skill deck, native Rihani Face
diff --git a/docs/IX_LEADER_SKILLS.md b/docs/IX_LEADER_SKILLS.md
new file mode 100644
index 00000000..aaeab70b
--- /dev/null
+++ b/docs/IX_LEADER_SKILLS.md
@@ -0,0 +1,84 @@
+# Basic Ixians with Leader Skills
+
+Connected development prototype, 21 September 2026. Genuine Basic Ixian setup
+now supports the full fourteen-card Leader Skills deck, optionally alongside
+Tleilaxu and base factions. Public starts and completion/publication gates remain
+closed. Existing missing skill bands are not certified by this admission.
+
+## Setup and configuration
+
+Use Basic rules, exactly Ixians & Tleilaxu, two through six players including
+Ixians, and no other optional modules. The real private Ixian starting-card
+choice precedes simultaneous private skill offers, then public assignments,
+Traitors and native Face Dancers. The first Storm retains actual HMS placement
+and all seven cyborgs/thirteen suboids. No dealt card determines admission.
+
+Create and ready a fresh local lobby, then follow the backed-up version-checked
+[prototype entry](IX_PROTOTYPE.md) using profile `leader-skills`.
+The shared initializer retains the [Tleilaxu integration](TLEILAXU_LEADER_SKILLS.md).
+
+## Connected interactions
+
+- Planetologist calculates each gathered origin's range using only cyborgs
+ selected from that origin. Selected sectors within the same territory share
+ their moving group. Its range bonus independently survives native movement
+ cancellation; a three-territory cyborg move still opens the proper Karama
+ window. Independent city ornithopters do not create that native window.
+- Sandmaster routes can enter or leave the HMS interior through its current
+ pointer. Adjacency belongs to the room, respects the storm and never turns the
+ interior into a shortcut or relocates the HMS. Interior battles and spice
+ effects remain distinct from the external pointing sector.
+- Suk Graduate finishes rescue before native substitution. Kept cyborgs and
+ cyborgs returned to reserves are excluded from the actual Tank-loss map. Where
+ more than one reserve-rescue origin is possible, the player explicitly selects
+ it; only cyborgs truly lost in each sector can be substituted there. Normal
+ rescued-force origins need no extra choice because no subsequent effect here
+ depends on them. This exposes existing physical choices, not a new rescue power.
+- Rihani finishes its private exchange before substitution. A saved receipt
+ binds original losses, physical counters, cards and the declared exchange.
+ Substitution allow, decline and cancellation all resume mandatory then optional
+ winner cleanup; Planetologist substitutes cannot escape disposal.
+- Thumper, the additional Harvester and Amal are the expansion's green Special
+ cards eligible for Planetologist substitution. Exact physical identities,
+ names and effects are checked. Their native effects do not execute during
+ substitution; other cards in the engine's broad Special bucket are excluded.
+
+## Sources and interpretation
+
+Existing [Leader Skills rules](LEADER_SKILLS_RULES.md),
+[Planetologist contract](PLANETOLOGIST_RULES.md),
+[Suk Graduate contract](SUK_GRADUATE_RULES.md),
+[Sandmaster routes](SANDMASTER_MOVEMENT.md) and
+[Ix rules](IX_PROTOTYPE.md) supply the established powers, physical custody and
+skill-before-faction precedence. Rescue-sector selection records the physical
+choice needed to compose those powers without reviving an already rescued counter.
+
+The canonical green Ix card inventory was checked directly against the printed
+components visible in this [component photograph](https://static.lelekan.ua/image/cache/catalog/igri/dune-ixians-tleilaxu-expansion-eng/dune-ixians-tleilaxu-expansion-gale-force-nine-eng-lelekan-components-2-800x800.jpg).
+This is physical component evidence, not a retailer's rules interpretation.
+Thumper also appears in the existing designer Planetologist example. The private
+source archive records SHA-256
+`6d2eb22889059edc8b35b9cd1178b6907312132b1b41768051d15abcc9c89125`.
+Sandtrout is a brown Spice card; red/blue battle cards do not qualify.
+
+## Verification and remaining scope
+
+```sh
+npm test -- ix-skills
+node --import tsx tools/faction-games.ts --profile ix-skills --out /private/new-ix-skills-samples
+```
+
+The sample profile adds Tleilaxu, Emperor, Guild, Harkonnen and Fremen in order
+and preserves stable scenario ordinals 26–30. Focused tests use all fourteen
+genuine assignments and conserved battle/movement positions, with legal actions
+from existing AI profiles, controls, private views, strict rejection and JSON
+continuation. In-memory authenticated recovery verifies exactly one concurrent
+starting-card choice, subsequent private setup and unchanged seats. Final
+source-bound check/build, samples, browser and preservation reports remain private.
+These are playability checks, not AI strength studies.
+
+Advanced Ixians, additional faction/module combinations, Sandmaster's extra
+native HMS relocation band and the other missing Leader Skill effects remain
+separate work. Existing Banker, Mentat, Diplomat and modified-Smuggler questions
+remain in the [decision register](RULE_DECISIONS.md). Full AI implementation and
+difficulty tuning wait until all non-AI game features are complete.
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 829bcf8a..426ded4b 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -177,3 +177,12 @@ routes, optional collection, human/AI controls and saved cancellation. The
[worm-ride follow-up](SANDMASTER_WORM.md) adds native Fremen destination collection
and saved arrival reactions. Other special relocation and combined modules
remain unfinished.
+
+## Basic Ixian integration
+
+[Basic Ixians with Leader Skills](IX_LEADER_SKILLS.md) adds genuine starting-card
+setup, per-origin cyborg range, Sandmaster HMS entry/exit, explicit Suk cyborg
+rescue origins, Rihani-before-substitution and mandatory green-Special disposal.
+Tleilaxu/base opponents are supported through the complete deck; Advanced and
+other combinations remain separate. This supersedes earlier Ix admission gaps,
+not the documented missing skill bands or Sandmaster native HMS relocation.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 3de0d519..e251bc78 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -33,6 +33,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
+| Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) |
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
diff --git a/docs/TLEILAXU_LEADER_SKILLS.md b/docs/TLEILAXU_LEADER_SKILLS.md
index bd65f35e..f4388179 100644
--- a/docs/TLEILAXU_LEADER_SKILLS.md
+++ b/docs/TLEILAXU_LEADER_SKILLS.md
@@ -13,7 +13,8 @@ and no other optional modules. It keeps the real Ix Treachery/Spice decks and
the Cheap Hero Traitor identity. Starting Treachery precedes simultaneous
private two-card skill offers; public assignments and ordinary Traitor choices
precede the native three Face Dancers. No hidden card controls profile admission.
-Ixians, Advanced Tleilaxu and combined modules require further integration.
+Ixians now have a separate [Basic integration](IX_LEADER_SKILLS.md); Advanced
+Tleilaxu and combined modules require further work.
## Connected interactions
diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md
index 913ec0c5..9f0da09b 100644
--- a/docs/VERIFICATION_WORKFLOW.md
+++ b/docs/VERIFICATION_WORKFLOW.md
@@ -117,6 +117,10 @@ Old room IDs in historical browser evidence no longer identify active local game
Future games must be preserved. This was a one-time user-authorized reset, not a
maintenance strategy or permission to clear future games.
+The explicit `--profile ix-skills` runs genuine Basic Ixian full-deck setup for
+two through six players, with Tleilaxu and base opponents. It preserves ordinals
+26–30 and full custody/private saved continuation; see [Ix integration](IX_LEADER_SKILLS.md).
+
## Private-seat restoration
Create a private JSON file (mode 0600) outside the checkout with this structure;
diff --git a/game/bot-mobility.ts b/game/bot-mobility.ts
index b2b68405..96deabff 100644
--- a/game/bot-mobility.ts
+++ b/game/bot-mobility.ts
@@ -5,7 +5,7 @@ import { presenceAt } from './force-presence';
import { EcazOccupancyError } from './ecaz-occupy';
import {
planetologistLeader,
- planetologistRange,
+ groundMovementRange,
type PlanetologistMoveMode,
} from './planetologist-movement';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
@@ -77,17 +77,13 @@ export function botMovementRange(
elite: number,
planetologist?: PlanetologistMoveMode,
) {
- const base =
- fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
- ? 3
- : (p.faction === 'fremen' && !p.fremenMovementBlocked) ||
- (p.faction === 'ixians' && elite > 0 && !p.ixMovementBlocked)
- ? 2
- : 1;
- const ordinary = base + (p.faction === 'choam' ? g.choamMovementBonus : 0);
- return planetologist && planetologistLeader(g, p.id)
- ? planetologistRange(ordinary, planetologist)
- : ordinary;
+ return groundMovementRange({
+ faction: p.faction,
+ cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')),
+ selectedElites: elite,
+ nativeBlocked: p.faction === 'ixians' ? !!p.ixMovementBlocked : !!p.fremenMovementBlocked,
+ choamBonus: p.faction === 'choam' ? g.choamMovementBonus : 0,
+ }, planetologist && planetologistLeader(g, p.id) ? planetologist : undefined);
}
export function botGroundMoveAllowed(
g: GameView,
diff --git a/game/engine.ts b/game/engine.ts
index a84a00f0..43d82cea 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -13,11 +13,11 @@ import {
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
-import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
+import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
-import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
+import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
@@ -1419,6 +1419,7 @@ export type Game = {
order: MovementOrder;
} | null;
pendingIxSubstitution?: {
+ receipt?: { event: string; turn: number; physical: string; signature: string };
homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number };
player: string;
territory: string;
@@ -1610,6 +1611,7 @@ export type Game = {
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
sukRescue?: { signature: string; completed: boolean };
+ ixSubstitution?: { signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
sandmaster?: { leader: string; key: string; before: number; after: number };
smugglerCollection?: SmugglerBattleReceipt;
@@ -5289,6 +5291,7 @@ function leaderSkillsIntegrity(g: Game) {
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
sandmasterIntegrity(g);
sukRescueIntegrity(g);
+ ixSubstitutionIntegrity(g);
rihaniIntegrity(g);
smugglerBattleIntegrity(g);
for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) {
@@ -9388,8 +9391,9 @@ function sandmasterIntegrity(g: Game) {
for (const other of [g.pendingIxMove, g.pendingChoamMove])
if (other?.sandmaster !== undefined) validateSandmasterOrder(g, getPlayer(g, other.player), other);
}
-function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
- return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(movementRange(g, p, move.elite), move.planetologist?.mode);
+function movementOrderRange(g: Game, p: Player, move: MovementOrder, origin = move.origin) {
+ return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange(
+ movementRange(g, p, selectedOriginElites(move.group, move.eliteGroup, origin)), move.planetologist?.mode);
}
function validatePlanetologistMove(g: Game, p: Player, move: Pick) {
const skill = move.planetologist;
@@ -9457,28 +9461,14 @@ function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
);
}
function movementRange(g: Game, p: Player, elite: number) {
- const base =
- fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
- ? 3
- : (p.faction === 'fremen' &&
- !(
- p.fremenMovementBlocked?.turn === g.turn &&
- p.fremenMovementBlocked.move === p.moved
- )) ||
- (p.faction === 'ixians' &&
- elite > 0 &&
- !(
- p.ixMovementBlocked?.turn === g.turn &&
- p.ixMovementBlocked.move === p.moved
- ))
- ? 2
- : 1;
- return (
- base +
- (p.faction === 'choam' && g.choamMovement?.turn === g.turn
- ? g.choamMovement.bonus
- : 0)
- );
+ const blocked = p.faction === 'ixians' ? p.ixMovementBlocked : p.fremenMovementBlocked;
+ return groundMovementRange({
+ faction: p.faction,
+ cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')),
+ selectedElites: elite,
+ nativeBlocked: blocked?.turn === g.turn && blocked.move === p.moved,
+ choamBonus: p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
+ });
}
function balisetPrevents(g: Game, player: string, origin: string, to: string) {
const choam = byFaction(g, 'choam');
@@ -9598,7 +9588,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
(key) =>
gameDistance(g, key, location(move.to, move.sector), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
- ) <= movementOrderRange(g, p, move),
+ ) <= movementOrderRange(g, p, move, splitLocation(key).territory),
),
'The movement route is no longer available.',
);
@@ -15982,6 +15972,7 @@ function resolveBattle(g: Game) {
territory: b.territory, skill: quote.sukGraduate,
commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory),
cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
+ ...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}),
};
updateSukReceipt(g);
}
@@ -16098,7 +16089,7 @@ function settleWinnerCasualties(
'Choose the original Suk Graduate casualty allocation.');
pending.losses = { ...choice };
updateSukReceipt(g);
- const options = sukRescueOptions(pending.skill, pending.pool, choice);
+ const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins);
if (options.length === 1) settleSukRescue(g, options[0], true);
else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event,
territory: to, mode: pending.skill.mode, options };
@@ -16121,26 +16112,70 @@ function settleWinnerCasualties(
`${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
);
+ stageIxSubstitution(g, p, to, cards, losses);
+ finishWinner(g, p, to, cards);
+}
+/** Stage faction substitution after actual Tank losses; skills finish before it opens. */
+function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record) {
+ const cyborgsLost = Object.values(losses).reduce((sum, count) => sum + count, 0);
const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : Object.entries(p.forces)
.filter(([key]) => splitLocation(key).territory === to)
.reduce(
(sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0),
0,
);
- if (p.faction === 'ixians' && choice.elite > 0 && survivingSuboids > 0) {
+ if (p.faction === 'ixians' && cyborgsLost > 0 && survivingSuboids > 0) {
g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards,
...(to.startsWith('homeworld:') ? { homeworld: {
- pool: combatArmy(g, p.id, to), cyborgsLost: choice.elite,
+ pool: combatArmy(g, p.id, to), cyborgsLost,
eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses,
} } : {}),
};
- g.decision = {
- kind: 'ixSubstitution',
- player: p.id,
- territory: to,
- losses,
- };
- } else finishWinner(g, p, to, cards);
+ if (g.leaderSkills) {
+ const pending = g.pendingIxSubstitution;
+ pending.receipt = { event: g.lastBattleContext!.event, turn: g.turn, physical: sukPhysicalSignature(p), signature: '' };
+ pending.receipt.signature = ixSubstitutionSignature(pending);
+ g.lastBattleContext!.ixSubstitution = { signature: pending.receipt.signature, completed: false };
+ }
+ }
+}
+function ixSubstitutionSignature(pending: NonNullable) {
+ return JSON.stringify([pending.player, pending.territory, pending.losses, pending.cards,
+ pending.receipt?.event, pending.receipt?.turn, pending.receipt?.physical,
+ ...(pending.sources === undefined ? [] : [pending.sources, pending.recover])]);
+}
+function ixSubstitutionIntegrity(g: Game) {
+ const pending = g.pendingIxSubstitution, context = g.lastBattleContext, obligation = context?.ixSubstitution;
+ if (!pending?.receipt && !obligation) {
+ requireRule(!pending || !g.leaderSkills, 'The Ixian skill aftermath lost its substitution receipt.');
+ return;
+ }
+ if (obligation?.completed) {
+ requireRule(!pending, 'The completed Ixian substitution cannot be replayed.');
+ return;
+ }
+ const owner = pending && g.players.find(p => p.id === pending.player);
+ requireRule(pending?.receipt && owner?.faction === 'ixians' && obligation && !obligation.completed &&
+ context.event === pending.receipt.event && context.turn === pending.receipt.turn && g.turn === context.turn &&
+ context.winner === pending.player && context.territory === pending.territory &&
+ g.phase === 6 && !g.battle && g.status === 'playing' &&
+ pending.receipt.signature === obligation.signature && pending.receipt.signature === ixSubstitutionSignature(pending) &&
+ pending.receipt.physical === sukPhysicalSignature(owner) &&
+ pending.cards.every(id => owner.hand.some(card => card.id === id)),
+ 'The saved Ixian substitution changed its original battle losses, cards or physical counters.');
+ const decisions = homeworldSavedDecisions(g);
+ const selecting = decisions.filter(d => d.kind === 'ixSubstitution');
+ const exchangeResponse = g.response?.kind === 'ixSubstitution' || g.pendingKarama?.use.kind === 'cancel' &&
+ g.pendingKarama.use.response.kind === 'ixSubstitution';
+ requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory &&
+ JSON.stringify(d.losses) === JSON.stringify(pending.losses)) &&
+ (exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) &&
+ (exchangeResponse || selecting.length === 1 || decisions.some(d => d.kind === 'rihani' && d.player === pending.player)),
+ 'The saved Ixian substitution lost its original choice or preceding skill exchange.');
+}
+function completeIxSubstitution(g: Game) {
+ g.pendingIxSubstitution = null;
+ if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true;
}
function sukForcePool(p: Player, territory: string): SukForceGroup[] {
return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
@@ -16171,6 +16206,7 @@ function sukRescueIntegrity(g: Game) {
context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
pending.signature === sukReceiptSignature(pending) &&
pending.physical === sukPhysicalSignature(player) &&
+ (pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') &&
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) &&
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
@@ -16181,7 +16217,7 @@ function sukRescueIntegrity(g: Game) {
requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) &&
decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event &&
d.territory === pending.territory && d.mode === pending.skill.mode &&
- JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!))),
+ JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins))),
'The saved Suk Graduate rescue choices differ from the committed casualties.');
} else {
const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player);
@@ -16199,7 +16235,7 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
const player = getPlayer(g, pending.player);
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
'The Suk Graduate rescue no longer has its committed physical counters.');
- const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option);
+ const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
for (const group of quote.removed) {
player.forces[group.key] -= group.normal + group.elite;
if (!player.forces[group.key]) delete player.forces[group.key];
@@ -16219,6 +16255,7 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
g.lastBattleContext!.sukRescue!.completed = true;
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
observeOccupation(g);
+ if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote.eliteTanks!);
finishWinner(g, player, pending.territory, pending.cards);
}
function winnerDiscardSignature(pending: NonNullable) {
@@ -16372,6 +16409,14 @@ function projectedRihani(g: Game, owner: string) {
}
function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
if (startRihaniVictory(g, winner, t, cards)) return;
+ if (g.pendingIxSubstitution) {
+ const pending = g.pendingIxSubstitution;
+ requireRule(pending.player === winner.id && pending.territory === t &&
+ JSON.stringify(pending.cards) === JSON.stringify(cards) && !pending.sources && !pending.recover,
+ 'Resume the original Ixian substitution after Leader Skills.');
+ g.decision = { kind: 'ixSubstitution', player: winner.id, territory: t, losses: pending.losses };
+ return;
+ }
if (g.pendingWinnerDiscards) {
winnerDiscardsIntegrity(g);
const pending = g.pendingWinnerDiscards;
@@ -17923,6 +17968,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
} else if (response.kind === 'ixMovement') {
const quote = canceled ? movementCancellationQuote(g, response) : null;
const move = g.pendingIxMove!;
+ // Cancellation only releases the declaration; its uncommitted army or route
+ // need not remain executable. An allowed move must still be legal now.
+ if (!canceled) validateMovementOrder(g, getPlayer(g, move.player), move);
g.pendingIxMove = null;
if (canceled) {
const player = getPlayer(g, move.player);
@@ -17933,13 +17981,11 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
player.ixMovementBlocked = { turn: quote.turn, move: quote.move };
log(
g,
- `${player.name}'s cyborg movement advantage was canceled. A one-territory move remains available.`,
+ `${player.name}'s cyborg movement advantage was canceled for this move. Forces remain in place and no movement was spent. Independent ornithopters and Planetologist still apply to a legal replacement.`,
);
} else offerChoamMovement(g, move);
} else if (response.kind === 'ixSubstitution') {
- const quote = canceled
- ? ixSubstitutionCancellationQuote(g, response)!
- : null;
+ ixSubstitutionCancellationQuote(g, response);
const pending = g.pendingIxSubstitution!;
const player = getPlayer(g, pending.player);
if (!canceled && pending.homeworld) {
@@ -17968,15 +18014,14 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
);
}
observeOccupation(g);
- g.pendingIxSubstitution = null;
- if (quote) {
+ completeIxSubstitution(g);
+ if (canceled) {
log(
g,
'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.',
);
- if (quote.decision) g.decision = quote.decision;
- else finishBattle(g);
- } else finishWinner(g, player, pending.territory, pending.cards);
+ }
+ finishWinner(g, player, pending.territory, pending.cards);
} else if (
[
'choamRevival',
@@ -21757,7 +21802,7 @@ function applyActionInner(
} else if (decision.kind === 'ixSubstitution') {
const pending = g.pendingIxSubstitution!;
if (action.decline === true) {
- g.pendingIxSubstitution = null;
+ completeIxSubstitution(g);
finishWinner(g, p, pending.territory, pending.cards);
} else {
const parse = (input: unknown, available: Record) => {
@@ -21795,6 +21840,10 @@ function applyActionInner(
sum(pending.sources) === sum(pending.recover),
'Exchange one surviving suboid for each cyborg lost in this battle.',
);
+ if (pending.receipt) {
+ pending.receipt.signature = ixSubstitutionSignature(pending);
+ g.lastBattleContext!.ixSubstitution!.signature = pending.receipt.signature;
+ }
g.response = { kind: 'ixSubstitution', owner: id, passed: [] };
}
} else if (decision.kind === 'revivalStop') {
@@ -23951,22 +24000,26 @@ function applyActionInner(
(key) =>
gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
- ) <= speed,
+ ) <= movementOrderRange(g, p, move, splitLocation(key).territory),
),
- `That destination is blocked or more than ${speed} territories away.`,
+ planetologist?.mode === 'gather'
+ ? 'Each selected origin must have an unblocked route within its own movement range.'
+ : `That destination is blocked or more than ${speed} territories away.`,
);
validateFlightSelection(g, p, move);
if (
- !move.ornithopterRange &&
+ !move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'ixians' &&
elite > 0 &&
- speed === 2 &&
- group.some(
- ([key]) =>
+ !(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) &&
+ !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
+ (move.sandmaster
+ ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
+ : group.some(([key]) =>
+ selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 &&
gameDistance(g, key, location(to, s), (k) =>
- pathBlocked(g, p, k, isAdvisor(p, origin)),
- ) > 1,
- )
+ pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)),
+ ) > planetologistRange(1, planetologist?.mode)))
) {
g.pendingIxMove = move;
g.response = {
@@ -23983,7 +24036,7 @@ function applyActionInner(
!(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) &&
!fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') &&
(move.sandmaster
- ? Object.values(move.sandmaster.routes).some(route => mobileRouteDistance(route) > 1)
+ ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1)
: group.some(
([key]) => gameDistance(g, key, location(to, s), (k) =>
pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode)))
diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts
index 017110d5..93cdd68a 100644
--- a/game/leader-skill-combat.ts
+++ b/game/leader-skill-combat.ts
@@ -60,14 +60,20 @@ export const PLANETOLOGIST_BASE_SPECIALS = [
{ id: 'treachery-32', name: 'Truthtrance', effect: 'truthtrance' },
] as const;
+export const PLANETOLOGIST_IX_SPECIALS = [
+ { id: 'ix-thumper', name: 'Thumper', effect: 'thumper' },
+ { id: 'ix-harvester', name: 'Harvester', effect: 'harvester' },
+ { id: 'ix-amal', name: 'Amal', effect: 'amal' },
+] as const;
+
const planetologistBaseSpecialById = new Map<
string,
- (typeof PLANETOLOGIST_BASE_SPECIALS)[number]
+ (typeof PLANETOLOGIST_BASE_SPECIALS)[number] | (typeof PLANETOLOGIST_IX_SPECIALS)[number]
>(
- PLANETOLOGIST_BASE_SPECIALS.map((card) => [card.id, card]),
+ [...PLANETOLOGIST_BASE_SPECIALS, ...PLANETOLOGIST_IX_SPECIALS].map((card) => [card.id, card]),
);
-/** Shared engine/UI/bot eligibility: exact printed base component, no native use. */
+/** Shared engine/UI/bot eligibility: exact printed green component, no native use. */
export function isPlanetologistBattleSpecialCard(
card: Pick | undefined,
): boolean {
diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts
index e3f56495..cb17b328 100644
--- a/game/leader-skill-profile.ts
+++ b/game/leader-skill-profile.ts
@@ -37,8 +37,18 @@ export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): bool
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
+/** Basic Ixians, optionally with native Tleilaxu, keep the ordinary Ix inventories. */
+export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
+ return game.advanced === false && game.expansions.length === 1 &&
+ game.expansions[0] === 'ix' && noOtherLeaderSkillModules(game) &&
+ !!game.players?.some(player => player.faction === 'ixians') &&
+ game.players.every(player => ['ixians', 'tleilaxu'].includes(player.faction) ||
+ FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
+}
+
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
- return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game);
+ return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) ||
+ basicIxLeaderSkillsProfile(game);
}
/** Shared by rule quotes, controls and minimal legal bot participation. */
diff --git a/game/planetologist-movement.ts b/game/planetologist-movement.ts
index e2a31d7e..f7241b90 100644
--- a/game/planetologist-movement.ts
+++ b/game/planetologist-movement.ts
@@ -1,6 +1,7 @@
import { FACTIONS } from './catalog';
import { basicExpansionLeaderSkillsProfile } from './leader-skill-profile';
import type { Game, GameView } from './engine';
+import { splitLocation } from './board';
export type PlanetologistMoveMode = 'range' | 'gather';
export type PlanetologistMovement = {
@@ -38,6 +39,33 @@ export function planetologistRange(
return mode === 'range' ? Math.min(3, base + 1) : base;
}
+/** Cyborgs accompany selected sectors of their own origin, never another origin. */
+export function selectedOriginElites(
+ group: readonly [string, number][],
+ elites: Readonly>,
+ origin: string,
+): number {
+ return group.reduce((total, [key, count]) => total +
+ (count > 0 && splitLocation(key).territory === origin ? (elites[key] ?? 0) : 0), 0);
+}
+
+/** Native movement and the independently retained Planetologist range share one calculation. */
+export function groundMovementRange(
+ options: {
+ faction: string;
+ cityOrnithopters: boolean;
+ selectedElites: number;
+ nativeBlocked: boolean;
+ choamBonus?: number;
+ },
+ mode?: PlanetologistMoveMode,
+): number {
+ const base = options.cityOrnithopters ? 3 :
+ !options.nativeBlocked && (options.faction === 'fremen' ||
+ (options.faction === 'ixians' && options.selectedElites > 0)) ? 2 : 1;
+ return planetologistRange(base + (options.choamBonus ?? 0), mode);
+}
+
/** Keep the existing base-roster boundary and add the connected Basic Moritani profile. */
export function planetologistMovementModeSupported(game: Game | GameView): boolean {
return game.players.every(player => FACTIONS.some(f => f.id === player.faction && f.expansion === 'base')) ||
diff --git a/game/reference.ts b/game/reference.ts
index ecce227e..c44ece89 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4000,6 +4000,29 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
+ {
+ id: 'ixian-leader-skills',
+ title: 'Ixians with Leader Skills',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Basic Ixians connect the full skill deck to cyborg movement, the HMS and battle aftermath.',
+ steps: [
+ 'The private starting-card choice precedes the full skill deal, Traitors and real first-Storm HMS placement. Basic Ixians can share this development combination with Tleilaxu and base factions; other optional modules and Advanced remain unavailable.',
+ 'Planetologist calculates each gathered origin separately using its selected cyborgs. Canceling native cyborg movement leaves the independent skill range available. Sandmaster routes can enter or leave the HMS through its current pointer.',
+ 'Suk Graduate rescues finish before substitution. Select the original sectors of rescued cyborgs where needed; only cyborgs actually sent to the Tanks can return through substitution. Rihani also finishes before native substitution.',
+ 'Planetologist may substitute the green Thumper, Harvester or Amal in its weapon role. Their normal effects do not execute, and mandatory disposal still follows allowed, declined or canceled substitution.',
+ 'Missing skill bands, extra Sandmaster HMS relocation and other combined configurations remain unfinished. This prototype does not certify complete rules or AI strength.',
+ ],
+ related: ['leader-skills', 'tleilaxu-leader-skills', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Genuine setup, per-origin range, HMS force routes, actual rescued/casualty custody and ordered battle cleanup are connected.', evidence: ['game/leader-skill-profile.ts', 'game/suk-graduate.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Implemented', detail: 'Private setup, typed movement, room-local routes and explicit rescue-origin choices retain saved decisions.', evidence: ['components/planetologist-movement.tsx', 'components/sandmaster-movement.tsx', 'components/suk-graduate.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Existing profiles use legal shared ranges and current choice lists. Full strategy and difficulty tuning remain deferred until feature completion.', evidence: ['game/bot-mobility.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Implemented', detail: 'Configuration, physical source inventory, source composition and remaining boundaries are recorded.', evidence: ['docs/IX_LEADER_SKILLS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Full-deck setups, controls, corruption/custody, authenticated concurrent recovery and two-through-six-player samples cover this bounded composition.', evidence: ['tests/ix-skills-movement.test.ts', 'tests/ix-skills-battle.test.ts', 'tests/ix-skills-suk.test.ts', 'tests/ix-skills-recovery.test.ts', 'tools/faction-games.ts'] },
+ ],
+ },
{
id: 'tleilaxu-leader-skills',
title: 'Tleilaxu with Leader Skills',
@@ -4073,7 +4096,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
- related: ['tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
+ related: ['ixian-leader-skills', 'tleilaxu-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 normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',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/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.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 automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',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/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
diff --git a/game/sandmaster-movement.ts b/game/sandmaster-movement.ts
index 2de4b2cb..95d092ca 100644
--- a/game/sandmaster-movement.ts
+++ b/game/sandmaster-movement.ts
@@ -1,6 +1,6 @@
import { ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
import type { Action, Game, GameView } from './engine';
-import { GRAPH, location, mobileRouteDistance, splitLocation } from './board';
+import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION } from './board';
import { isAdvisor } from './advisors';
import { strongholdPathBlocked } from './occupancy';
import { botMovementRange } from './bot-mobility';
@@ -62,7 +62,10 @@ export function sandmasterPathBlocked(
key: string,
): boolean {
const p = g.players.find((p) => p.id === player);
- if (!p || !Object.hasOwn(GRAPH, key)) return true;
+ const pointer = g.mobileStronghold?.location;
+ if (!p || (!Object.hasOwn(GRAPH, key) && key !== MOBILE_LOCATION)) return true;
+ if (key === MOBILE_LOCATION && (!pointer || !Object.hasOwn(GRAPH, pointer) ||
+ (!!splitLocation(pointer).sector && splitLocation(pointer).sector === g.storm))) return true;
const at = splitLocation(key);
return (
(!!at.sector && at.sector === g.storm) ||
@@ -74,6 +77,30 @@ export function sandmasterPathBlocked(
)
);
}
+
+/** The HMS interior is a room-local dead end connected only through its pointer. */
+export function sandmasterAdjacent(g: Game | GameView, key: string): string[] {
+ const pointer = g.mobileStronghold?.location;
+ if (key === MOBILE_LOCATION) return pointer && Object.hasOwn(GRAPH, pointer) ? [pointer] : [];
+ return [...(GRAPH[key] ?? []), ...(pointer === key ? [MOBILE_LOCATION] : [])];
+}
+
+/** Ordinary forces may enter or leave HMS; this never relocates its pointer. */
+export function sandmasterRouteDistance(g: Game | GameView, route: string[]): number {
+ if (!route.includes(MOBILE_LOCATION)) return mobileRouteDistance(route);
+ if (!route.length || route.length > Object.keys(GRAPH).length + 1 ||
+ new Set(route).size !== route.length || route.slice(1, -1).includes(MOBILE_LOCATION)) return Infinity;
+ let steps = 0;
+ for (let index = 0; index < route.length; index++) {
+ const key = route[index];
+ if (key !== MOBILE_LOCATION && !Object.hasOwn(GRAPH, key)) return Infinity;
+ if (key === MOBILE_LOCATION && !sandmasterAdjacent(g, key).length) return Infinity;
+ if (!index) continue;
+ if (!sandmasterAdjacent(g, route[index - 1]).includes(key)) return Infinity;
+ steps += Number(splitLocation(route[index - 1]).territory !== splitLocation(key).territory);
+ }
+ return steps;
+}
/** Only crossing a territory boundary enters a new collection opportunity. */
export function sandmasterEnteredTerritories(
routes: Record,
@@ -140,7 +167,7 @@ export function quoteSandmasterMovement(
route.every((k) => typeof k === 'string') &&
route[0] === from &&
route.at(-1) === location(move.to, move.sector) &&
- mobileRouteDistance(route) <= move.range &&
+ sandmasterRouteDistance(g, route) <= move.range &&
route.every((key) => !sandmasterPathBlocked(g, player, from, key)),
'Sandmaster needs a connected route within movement range, outside blocked sectors.',
);
@@ -198,7 +225,7 @@ export function sandmasterShortestRoute(
last = route.at(-1)!;
if (cost !== best.get(last)) continue;
if (last === to) return route;
- for (const key of GRAPH[last] ?? []) {
+ for (const key of sandmasterAdjacent(g, last)) {
if (sandmasterPathBlocked(g, player, from, key)) continue;
const next =
cost +
diff --git a/game/suk-graduate.ts b/game/suk-graduate.ts
index 2fba11c3..a166fa5e 100644
--- a/game/suk-graduate.ts
+++ b/game/suk-graduate.ts
@@ -8,6 +8,8 @@ export type SukRescueOption = {
elite: number;
/** The saved counter never leaves its original sector. */
kept: { key: string; kind: 'normal' | 'elite' } | null;
+ /** Distinct rescued cyborg origins when later Ixian substitution needs them. */
+ eliteReserves?: Record;
};
export type SukRescueReceipt = {
event: string;
@@ -20,6 +22,8 @@ export type SukRescueReceipt = {
cards: string[];
physical: string;
losses: Casualties | null;
+ /** New Ixian receipts preserve the actual per-sector cyborg Tank losses. */
+ eliteOrigins?: true;
signature: string;
};
@@ -61,7 +65,7 @@ export function sukCasualtyGroups(pool: readonly SukForceGroup[], losses: Casual
}
/** Only the actual dialed counters can be rescued; the two bands never add. */
-export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties): SukRescueOption[] {
+export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, eliteOrigins = false): SukRescueOption[] {
const groups = sukCasualtyGroups(pool, losses);
const total = losses.normal + losses.elite;
if (!total) return [];
@@ -79,15 +83,42 @@ export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForc
options.push({ normal, elite, kept: { key: group.key, kind } });
}
}
- return options;
+ if (!eliteOrigins) return options;
+ return options.flatMap(option => {
+ const allocations = rescuedEliteOrigins(groups, option);
+ return allocations.length > 1
+ ? allocations.map(eliteReserves => ({ ...option, eliteReserves })) : [option];
+ });
+}
+
+/** Enumerate physical reserve rescues, excluding the counter kept on the board. */
+function rescuedEliteOrigins(groups: readonly SukForceGroup[], option: SukRescueOption): Record[] {
+ const remaining = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
+ const result: Record[] = [];
+ const visit = (index: number, left: number, selected: Record) => {
+ if (index === groups.length) {
+ if (!left) result.push(selected);
+ return;
+ }
+ const group = groups[index];
+ const available = group.elite - (option.kept?.key === group.key && option.kept.kind === 'elite' ? 1 : 0);
+ for (let count = 0; count <= Math.min(left, available); count++)
+ visit(index + 1, left - count, count ? { ...selected, [group.key]: count } : selected);
+ };
+ visit(0, remaining, {});
+ return result;
}
/** Quote removals and destinations without touching tanks, reserves or battle-loss counters. */
-export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, option: SukRescueOption) {
- if (!sukRescueOptions(skill, pool, losses).some((o) => JSON.stringify(o) === JSON.stringify(option)))
+export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties, option: SukRescueOption, eliteOrigins = false) {
+ if (!sukRescueOptions(skill, pool, losses, eliteOrigins).some((o) => JSON.stringify(o) === JSON.stringify(option)))
throw new Error('Choose an available Suk Graduate rescue.');
const groups = sukCasualtyGroups(pool, losses);
+ const rescued = eliteOrigins ? option.eliteReserves ?? rescuedEliteOrigins(groups, option)[0] : null;
return {
+ ...(rescued ? { eliteTanks: Object.fromEntries(groups.map(group => [group.key,
+ group.elite - (rescued[group.key] ?? 0) - (option.kept?.key === group.key && option.kept.kind === 'elite' ? 1 : 0),
+ ]).filter(([, count]) => Number(count) > 0)) as Record } : {}),
removed: groups.map((g) => ({ ...g,
normal: g.normal - (option.kept?.key === g.key && option.kept.kind === 'normal' ? 1 : 0),
elite: g.elite - (option.kept?.key === g.key && option.kept.kind === 'elite' ? 1 : 0),
diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts
index 0a97bd11..af881510 100644
--- a/tests/faction-games-cli.test.ts
+++ b/tests/faction-games-cli.test.ts
@@ -135,6 +135,26 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat
assert.equal(existsSync(unsupported), false);
});
+void test('Ixian Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
+ const area = temporary(t);
+ const out = join(area, 'skills');
+ const result = run(out, '--profile', 'ix-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
+ assert.equal(result.status, 1);
+ assertFailedEvidence(out, 'ix-skills-2-basic', 1026, false);
+ const snapshot = join(out, 'failed-ix-skills-2-basic.json');
+ const game = json<{ advanced: boolean; leaderSkills: { deck: string[]; offers: Record; assignments: { skill: string }[] } }>(snapshot);
+ assert.equal(game.advanced, false);
+ const physical = [...game.leaderSkills.deck, ...Object.values(game.leaderSkills.offers).flatMap(o => o.cards), ...game.leaderSkills.assignments.map(a => a.skill)];
+ assert.equal(physical.length, 14); assert.equal(new Set(physical).size, 14);
+ const resumed = join(area, 'skills-resumed');
+ assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
+ assertFailedEvidence(resumed, 'ix-skills-2-basic', 1026, true);
+ const unsupported = join(area, 'unsupported');
+ const bad = run(unsupported, '--profile', 'ix-skills', '--rules', 'advanced');
+ assert.equal(bad.status, 1); assert.match(bad.stderr, /only Basic/);
+ assert.equal(existsSync(unsupported), false);
+});
+
void test('selected genuine three-player Advanced base sample fails honestly, resumes its fixed snapshot and keeps source-bound private evidence', (t) => {
const area = temporary(t);
const first = join(area, 'first');
diff --git a/tests/ix-skills-battle.test.ts b/tests/ix-skills-battle.test.ts
new file mode 100644
index 00000000..0a4a2d1d
--- /dev/null
+++ b/tests/ix-skills-battle.test.ts
@@ -0,0 +1,266 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
+import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+import type { LeaderSkillId } from '../game/leader-skill-cards';
+import {
+ completedIxSkillsGame,
+ assertIxSkillsCustody as custody,
+ ixSkillsPlayer as player,
+ reloadIxSkillsGame as reload,
+ rejectIxSkillsAction as reject,
+} from './ix-skills-fixture';
+
+const act = (game: Game, owner: string, action: Action) => applyAction(reload(game), owner, action);
+function stable(game: Game) {
+ custody(game);
+ assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
+}
+
+/** Conserved battle positions after real setup; these are not claimed played phase histories. */
+function stage(skill: LeaderSkillId, location = 'wind_pass:14') {
+ const game = completedIxSkillsGame({ requestedSkill: skill });
+ for (const p of game.players) {
+ game.deck.push(...p.hand);
+ p.hand = [];
+ p.forces = p.id === 't' ? {} : { [location]: 8, 'carthag:11': 1 };
+ p.reserves = p.id === 't' ? 20 : 11;
+ p.tanks = 0;
+ p.battleLosses = 0;
+ p.spice = 20;
+ }
+ player(game, 'i').elites = { reserves: 5, tanks: 0, forces: { [location]: 2 }, revived: 0 };
+ Object.assign(game, { phase: 6, active: 'i', order: ['i', 'e', 't'], storm: 18,
+ ready: [], decision: null, response: null, phaseOpening: null, spice: {} });
+ return game;
+}
+
+function take(game: Game, owner: string, predicate: (card: Game['deck'][number]) => boolean) {
+ const index = game.deck.findIndex(predicate);
+ assert.ok(index >= 0);
+ const card = game.deck.splice(index, 1)[0];
+ player(game, owner).hand.push(card);
+ return card;
+}
+
+function passResponses(game: Game) {
+ for (let step = 0; game.response && step < 20; step++) {
+ const p = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
+ assert.ok(p);
+ game = act(game, p.id, { type: 'passResponse' });
+ }
+ assert.equal(game.response, null);
+ return game;
+}
+
+function open(game: Game, territory = 'wind_pass') {
+ game = act(game, 'i', { type: 'chooseBattle', territory, target: 'e' });
+ for (let step = 0; step < 30; step++) {
+ if (game.response) game = passResponses(game);
+ else if (game.decision?.kind === 'leaderSkillVisibility')
+ game = act(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
+ else if (game.battle?.preparation)
+ game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
+ else break;
+ }
+ assert.equal(game.decision, null);
+ assert.equal(game.battle?.preparation, undefined);
+ return game;
+}
+
+function resolve(game: Game, own: Partial = {}, territory = 'wind_pass') {
+ game = open(game, territory);
+ game = act(game, 'i', { type: 'battlePlan', dial: 6, leader: player(game, 'i').leaders[0].id, ...own });
+ game = act(game, 'e', { type: 'battlePlan', dial: 0, leader: player(game, 'e').leaders[4].id });
+ game = act(game, 'i', { type: 'traitorCall', call: false });
+ game = act(game, 'e', { type: 'traitorCall', call: false });
+ if (game.decision?.kind === 'battleLosses') {
+ const choice = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 2);
+ assert.ok(choice >= 0);
+ game = act(game, 'i', { type: 'decision', choice });
+ }
+ return game;
+}
+
+function finishSubstitution(game: Game, outcome: 'allow' | 'cancel' | 'decline', karama?: string) {
+ assert.equal(game.decision?.kind, 'ixSubstitution');
+ if (outcome === 'decline') return act(game, 'i', { type: 'decision', decline: true });
+ game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
+ assert.equal(game.response?.kind, 'ixSubstitution');
+ stable(game);
+ if (outcome === 'cancel') {
+ assert.ok(karama);
+ return passResponses(act(game, 'e', { type: 'card', card: karama, mode: 'cancel' }));
+ }
+ return passResponses(game);
+}
+
+void test('Ixian Rihani completes its private exchange before native cyborg substitution and optional winner cleanup', () => {
+ for (const outcome of ['allow', 'cancel', 'decline'] as const) {
+ let game = stage('rihani-decipherer');
+ const shield = take(game, 'i', card => card.kind === 'shield');
+ const karama = take(game, 'e', card => card.effect === 'karama');
+ game = resolve(game, { defense: shield.id });
+ assert.equal(game.decision?.kind, 'rihani');
+ assert.deepEqual(game.pendingIxSubstitution?.losses, { 'wind_pass:14': 2 });
+ assert.equal(game.pendingIxSubstitution?.sources, undefined);
+ assert.equal(player(game, 'i').elites!.tanks, 2);
+ stable(game);
+ const event = game.rihaniHistory!.at(-1)!.event;
+ reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
+ if (outcome === 'decline') game = act(game, 'i', { type: 'decision', event, draw: false });
+ else {
+ game = act(game, 'i', { type: 'decision', event, draw: true });
+ const receipt = game.rihaniHistory!.at(-1)!;
+ assert.equal(receipt.stage, 'return');
+ assert.deepEqual(viewGame(game, 'e').rihani!.history, []);
+ stable(game);
+ game = act(game, 'i', { type: 'decision', event, cards: [receipt.drawn[0], receipt.eligible[0]] });
+ }
+ const history = structuredClone(game.rihaniHistory);
+ assert.equal(game.lastBattleContext!.rihani!.completed, true);
+ stable(game);
+ game = finishSubstitution(game, outcome, karama.id);
+ assert.equal(game.pendingIxSubstitution, null);
+ assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
+ assert.equal(player(game, 'i').elites!.tanks, outcome === 'allow' ? 1 : 2);
+ assert.equal(player(game, 'i').elites!.forces['wind_pass:14'] ?? 0, outcome === 'allow' ? 1 : 0);
+ assert.deepEqual(game.rihaniHistory, history);
+ stable(game);
+ game = act(game, 'i', { type: 'decision', discard: [] });
+ assert.equal(game.rihaniHistory!.length, 1);
+ assert.equal(player(game, 'i').hand.some(card => card.id === shield.id), true);
+ stable(game);
+ }
+});
+
+void test('a delayed Ixian substitution preserves its original losses and cannot be removed or selected during Rihani', () => {
+ const game = resolve(stage('rihani-decipherer'));
+ assert.equal(game.decision?.kind, 'rihani');
+ const event = game.rihaniHistory!.at(-1)!.event;
+ const mutations: ((saved: Game) => void)[] = [
+ saved => { saved.pendingIxSubstitution!.losses = { 'wind_pass:15': 2 }; },
+ saved => { saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 1 }; },
+ saved => { saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 1 }; },
+ saved => { saved.pendingIxSubstitution = null; },
+ saved => { player(saved, 'i').elites!.tanks--; player(saved, 'i').elites!.reserves++; },
+ ];
+ for (const mutate of mutations) {
+ const saved = reload(game);
+ mutate(saved);
+ reject(saved, 'i', { type: 'decision', event, draw: false });
+ }
+ const restored = act(game, 'i', { type: 'decision', event, draw: false });
+ assert.equal(restored.decision?.kind, 'ixSubstitution');
+ assert.deepEqual(restored.pendingIxSubstitution!.losses, { 'wind_pass:14': 2 });
+ stable(restored);
+});
+
+void test('an accepted Ixian substitution freezes matching source and recovery counts through its response', () => {
+ let game = stage('sandmaster');
+ take(game, 'e', card => card.effect === 'karama');
+ game = resolve(game);
+ game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
+ assert.equal(game.response?.kind, 'ixSubstitution');
+ for (const changeBoth of [false, true]) {
+ const saved = reload(game);
+ saved.pendingIxSubstitution!.recover = { 'wind_pass:14': 2 };
+ if (changeBoth) saved.pendingIxSubstitution!.sources = { 'wind_pass:14': 2 };
+ const voter = saved.players.find(p => !saved.response!.passed.includes(p.id));
+ assert.ok(voter);
+ reject(saved, voter.id, { type: 'passResponse' });
+ }
+ stable(game);
+});
+
+void test('every Ixian substitution outcome resumes mandatory Planetologist disposal before optional winner cards', () => {
+ const cases = [
+ ...(['allow', 'cancel', 'decline'] as const).map(outcome => ({ outcome, card: 'treachery-24' })),
+ { outcome: 'allow', card: 'ix-thumper' },
+ { outcome: 'cancel', card: 'ix-harvester' },
+ { outcome: 'decline', card: 'ix-amal' },
+ ] as const;
+ for (const { outcome, card } of cases) {
+ let game = stage('planetologist');
+ const special = take(game, 'i', candidate => candidate.id === card);
+ const shield = take(game, 'i', card => card.kind === 'shield');
+ const karama = take(game, 'e', card => card.effect === 'karama');
+ game = resolve(game, { weapon: special.id, defense: shield.id });
+ assert.equal(game.decision?.kind, 'ixSubstitution');
+ assert.equal(game.pendingWinnerDiscards!.cards.includes(special.id), true);
+ assert.equal(player(game, 'i').hand.some(card => card.id === special.id), true);
+ assert.equal(game.discard.some(card => card.id === special.id), false);
+ stable(game);
+ game = finishSubstitution(game, outcome, karama.id);
+ assert.equal(game.pendingIxSubstitution, null);
+ assert.equal(game.pendingWinnerDiscards, null);
+ assert.equal(game.lastBattleContext!.winnerDiscards!.completed, true);
+ assert.deepEqual(game.decision, { kind: 'battleCards', player: 'i', territory: 'wind_pass', cards: [shield.id] });
+ assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
+ reject(game, 'i', { type: 'decision', discard: [special.id] });
+ stable(game);
+ game = act(game, 'i', { type: 'decision', discard: [shield.id] });
+ assert.equal(game.discard.filter(card => card.id === special.id).length, 1);
+ assert.equal(game.discard.filter(card => card.id === shield.id).length, 1);
+ stable(game);
+ }
+});
+
+void test('a battle inside the HMS never uses spice in the territory beneath its pointer', () => {
+ for (const skill of ['smuggler', 'sandmaster'] as const) {
+ let game = stage(skill, MOBILE_LOCATION);
+ game.mobileStronghold!.location = 'wind_pass:14';
+ game.spice = { 'wind_pass:14': 8 };
+ game = resolve(game, { dial: 0 }, MOBILE_STRONGHOLD);
+ assert.equal(game.lastBattleContext!.territory, MOBILE_STRONGHOLD);
+ assert.deepEqual(game.spice, { 'wind_pass:14': 8 });
+ assert.equal(player(game, 'i').spice, 20);
+ assert.equal(game.pendingIxSubstitution, undefined);
+ stable(game);
+ }
+});
+
+for (const rescue of [true, false]) {
+ void test(rescue
+ ? 'Ixian Suk rescues a suboid in place and cyborg to reserves before substituting only the remaining dead cyborg'
+ : 'declining skilled Ixian Suk rescue leaves both original cyborg casualties available for substitution', () => {
+ let game = stage('suk-graduate');
+ take(game, 'e', card => card.effect === 'karama');
+ game = resolve(game);
+ const decision = game.decision;
+ assert.equal(decision?.kind, 'sukRescue');
+ if (decision?.kind !== 'sukRescue') throw new Error('Expected the original Suk rescue');
+ assert.deepEqual(game.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 0 });
+ assert.equal(game.pendingIxSubstitution, undefined);
+ assert.equal(player(game, 'i').tanks, 0);
+ stable(game);
+ const choice = decision.options.findIndex(option => rescue
+ ? option.normal === 1 && option.elite === 1 && option.kept?.kind === 'normal' && option.kept.key === 'wind_pass:14'
+ : option.normal === 0 && option.elite === 0 && option.kept === null);
+ assert.ok(choice >= 0);
+ game = act(game, 'i', { type: 'decision', event: decision.event, choice });
+ const recover = rescue ? 1 : 2;
+ assert.equal(game.pendingSukRescue, null);
+ assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
+ assert.equal(game.decision?.kind, 'ixSubstitution');
+ assert.deepEqual(game.pendingIxSubstitution!.losses, { 'wind_pass:14': recover });
+ assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
+ assert.equal(player(game, 'i').elites!.tanks, recover);
+ assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
+ assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
+ stable(game);
+ if (rescue) reject(game, 'i', { type: 'decision', sources: { 'wind_pass:14': 2 }, recover: { 'wind_pass:14': 2 } });
+ game = act(game, 'i', { type: 'decision', sources: { 'wind_pass:14': recover }, recover: { 'wind_pass:14': recover } });
+ assert.equal(game.response?.kind, 'ixSubstitution');
+ stable(game);
+ game = passResponses(game);
+ assert.equal(game.pendingIxSubstitution, null);
+ assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true);
+ assert.equal(player(game, 'i').elites!.reserves, rescue ? 6 : 5);
+ assert.equal(player(game, 'i').elites!.tanks, 0);
+ assert.equal(player(game, 'i').elites!.forces['wind_pass:14'], recover);
+ assert.equal(player(game, 'i').forces['wind_pass:14'], rescue ? 3 : 2);
+ assert.equal(player(game, 'i').tanks, rescue ? 4 : 6);
+ stable(game);
+ });
+}
diff --git a/tests/ix-skills-fixture.ts b/tests/ix-skills-fixture.ts
new file mode 100644
index 00000000..f1a31160
--- /dev/null
+++ b/tests/ix-skills-fixture.ts
@@ -0,0 +1,197 @@
+import assert from 'node:assert/strict';
+import { mock } from 'node:test';
+import {
+ applyAction,
+ createGame,
+ initializeLeaderSkillsGameForAudit,
+ joinGame,
+ newPlayer,
+ type Action,
+ type Game,
+ type Player,
+} from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
+import { traitorDeck } from '../game/traitors';
+
+export const IX_SKILLS_SEATS = {
+ tleilaxu: 't',
+ ixians: 'i',
+ emperor: 'e',
+} as const;
+
+export type IxSkillsFaction = keyof typeof IX_SKILLS_SEATS;
+export type IxSkillsFixtureOptions = {
+ requestedSkill?: LeaderSkillId;
+ skillOwner?: IxSkillsFaction;
+};
+
+const names: Record = {
+ tleilaxu: 'Tleilaxu',
+ ixians: 'Ixians',
+ emperor: 'Emperor',
+};
+
+export const reloadIxSkillsGame = (game: Game): Game =>
+ JSON.parse(JSON.stringify(game)) as Game;
+
+export function ixSkillsPlayer(game: Game, id: string): Player {
+ const player = game.players.find((candidate) => candidate.id === id);
+ assert.ok(player);
+ return player;
+}
+
+export function rejectIxSkillsAction(
+ game: Game,
+ id: string,
+ action: Action,
+ pattern?: RegExp,
+): void {
+ const before = structuredClone(game);
+ if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
+ else assert.throws(() => applyAction(game, id, action));
+ assert.deepEqual(game, before);
+}
+
+/** Count physical pieces only; projected/history copies do not create custody. */
+export function assertIxSkillsCustody(game: Game): void {
+ const skills = game.leaderSkills!;
+ const physical = [
+ ...skills.deck,
+ ...Object.values(skills.offers).flatMap((offer) => offer.cards),
+ ...skills.assignments.map((assignment) => assignment.skill),
+ ];
+ const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
+ assert.equal(physical.length, canonical.length);
+ assert.equal(new Set(physical).size, canonical.length);
+ assert.deepEqual([...physical].sort(), [...canonical].sort());
+
+ const treachery = [
+ ...(game.ixSetupCards ?? []),
+ ...(game.ixAuction?.cards ?? []),
+ ...game.deck,
+ ...game.discard,
+ ...game.players.flatMap((player) => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index) ?? []),
+ ].map((card) => card.id);
+ assert.deepEqual(treachery.sort(), treacheryDeck(['ix']).map((card) => card.id).sort());
+ for (const player of game.players)
+ assert.equal(
+ player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0),
+ 20,
+ `${player.name} must retain every physical force`,
+ );
+
+ const ixians = ixSkillsPlayer(game, 'i');
+ assert.equal(ixians.elites!.reserves + ixians.elites!.tanks + Object.values(ixians.elites!.forces).reduce((a, b) => a + b, 0), 7);
+ for (const [key, count] of Object.entries(ixians.elites!.forces))
+ assert.ok(count <= (ixians.forces[key] ?? 0));
+
+ if (game.status === 'playing' || game.status === 'finished') {
+ const traitors = [
+ ...game.traitorReserve!,
+ ...game.players.flatMap((player) => player.traitors),
+ ...game.players.flatMap((player) => (player.faceDancers ?? []).map((card) => card.leader)),
+ ];
+ assert.deepEqual(traitors.sort(), traitorDeck(game.players, true).sort());
+ assert.equal(new Set(traitors).size, traitors.length);
+ }
+}
+
+/** Real ready lobby and setup deal; only the production shuffle is controlled. */
+export function initializedIxSkillsOffers(
+ options: IxSkillsFixtureOptions = {},
+): Game {
+ const requestedSkill = options.requestedSkill ?? 'planetologist';
+ const skillOwner = options.skillOwner ?? 'ixians';
+ const factions = (
+ [skillOwner, 'ixians', 'tleilaxu', 'emperor'] as IxSkillsFaction[]
+ ).filter((faction, index, all) => all.indexOf(faction) === index);
+ const first = factions[0];
+ let game = createGame(
+ 'IXSKILLS',
+ newPlayer(IX_SKILLS_SEATS[first], names[first], first),
+ false,
+ ['ix'],
+ );
+ for (const faction of factions.slice(1))
+ joinGame(game, newPlayer(IX_SKILLS_SEATS[faction], names[faction], faction));
+ for (const player of game.players)
+ game = applyAction(game, player.id, { type: 'ready' });
+
+ const targetIndex = LEADER_SKILL_CARDS.findIndex((card) => card.id === requestedSkill);
+ assert.ok(targetIndex >= 0);
+ let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
+ mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
+ array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
+ return array;
+ });
+ try {
+ game = initializeLeaderSkillsGameForAudit(game);
+ } finally {
+ mock.restoreAll();
+ }
+ assert.equal(game.status, 'setup');
+ assert.equal(game.setupStage, 'skillTreachery');
+ assert.equal(game.decision?.kind, 'ixSetup');
+ assert.equal(game.ixSetupCards!.length, game.players.length);
+ assert.ok(game.players.every(player => player.hand.length === 0 && player.traitorChoices.length === 0));
+ assertIxSkillsCustody(game);
+ game = applyAction(reloadIxSkillsGame(game), 'i', {type: 'decision', card: game.ixSetupCards![0].id});
+ assert.equal(game.setupStage, 'leaderSkills');
+ assert.equal(game.ixSetupCards, null);
+ assert.equal(game.leaderSkills!.offers[IX_SKILLS_SEATS[skillOwner]].cards[0], requestedSkill);
+ assertIxSkillsCustody(game);
+ return game;
+}
+
+/** Complete skills, Traitors, three Face Dancers and the first Storm HMS placement. */
+export function completedIxSkillsGame(
+ options: IxSkillsFixtureOptions = {},
+): Game {
+ const requestedSkill = options.requestedSkill ?? 'planetologist';
+ const requestedOwner = IX_SKILLS_SEATS[options.skillOwner ?? 'ixians'];
+ let game = initializedIxSkillsOffers(options);
+ for (const player of game.players) {
+ const offer = game.leaderSkills!.offers[player.id];
+ game = applyAction(game, player.id, {
+ type: 'leaderSkill',
+ event: offer.event,
+ skill: player.id === requestedOwner ? requestedSkill : offer.cards[0],
+ leader: player.leaders[0].id,
+ });
+ }
+ assert.equal(game.setupStage, 'traitors');
+ assert.equal(ixSkillsPlayer(game, 't').faceDancers, undefined);
+ for (const player of game.players) {
+ if (player.faction === 'tleilaxu') continue;
+ const choice = ixSkillsPlayer(game, player.id).traitorChoices[0];
+ assert.ok(choice);
+ game = applyAction(game, player.id, { type: 'traitor', leader: choice });
+ }
+ assert.equal(game.status, 'playing');
+ assert.equal(game.setupStage, undefined);
+ assert.deepEqual(ixSkillsPlayer(game, 't').traitors, []);
+ assert.equal(ixSkillsPlayer(game, 't').faceDancers!.length, 3);
+ for (let step = 0; step < 20 && game.decision?.kind !== 'mobileStronghold'; step++) {
+ if (game.phaseOpening) {
+ const owner = game.players.find(player => !game.phaseOpening!.passed.includes(player.id));
+ assert.ok(owner);
+ game = applyAction(reloadIxSkillsGame(game), owner.id, {type: 'ready'});
+ } else if (game.stormPending !== null) {
+ const owner = game.players.find(player => !game.ready.includes(player.id));
+ assert.ok(owner);
+ game = applyAction(reloadIxSkillsGame(game), owner.id, {type: 'ready'});
+ } else {
+ const owner = game.stormDialers.find(id => game.stormDials[id] === undefined);
+ assert.ok(owner);
+ game = applyAction(reloadIxSkillsGame(game), owner, {type: 'stormDial', amount: 0});
+ }
+ }
+ assert.equal(game.decision?.kind, 'mobileStronghold');
+ game = applyAction(reloadIxSkillsGame(game), 'i', {type: 'decision', location: 'polar_sink:0'});
+ assert.equal(game.mobileStronghold!.location, 'polar_sink:0');
+ assert.equal(game.phase, 1);
+ assertIxSkillsCustody(game);
+ return game;
+}
diff --git a/tests/ix-skills-movement.test.ts b/tests/ix-skills-movement.test.ts
new file mode 100644
index 00000000..0a6a5409
--- /dev/null
+++ b/tests/ix-skills-movement.test.ts
@@ -0,0 +1,439 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
+import { GRAPH, TERRITORIES, gameDistance, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+import { botActions } from '../game/bots';
+import { botMovementRange } from '../game/bot-mobility';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement';
+import { SandmasterMovement, sandmasterMoveDraft } from '../components/sandmaster-movement';
+import { sandmasterRouteDistance } from '../game/sandmaster-movement';
+import { createTechTokens } from '../game/tech-tokens';
+import {
+ assertIxSkillsCustody as custody,
+ completedIxSkillsGame,
+ initializedIxSkillsOffers,
+ ixSkillsPlayer as player,
+ rejectIxSkillsAction as reject,
+ reloadIxSkillsGame as reload,
+} from './ix-skills-fixture';
+
+const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
+const sand = TERRITORIES.filter(t => t.type === 'sand')
+ .flatMap(t => t.sectors.filter(s => s !== 18).map(s => location(t.id, s)));
+const blocked = (key: string) => splitLocation(key).sector === 18;
+const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to, blocked);
+
+function stable(g: Game) {
+ custody(g);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
+}
+
+/** Conserved movement scenario after the complete real Ix setup and Storm placement. */
+function movement(skill: 'planetologist' | 'sandmaster' = 'planetologist', heldKarama = false): Game {
+ const g = completedIxSkillsGame({requestedSkill: skill});
+ for (const p of g.players) {
+ g.deck.push(...p.hand);
+ p.hand = [];
+ }
+ if (heldKarama) {
+ const index = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(index >= 0);
+ player(g, 'e').hand.push(...g.deck.splice(index, 1));
+ }
+ Object.assign(g, {
+ phase: 5, phaseOpening: null, active: 'i', storm: 18, decision: null,
+ response: null, ready: [], order: ['i', 'e', 't'], movementRemaining: ['i', 'e', 't'],
+ });
+ player(g, 'i').moved = 0;
+ player(g, 'i').shipped = true;
+ return g;
+}
+
+function deploy(g: Game, forces: Record, cyborgs: Record) {
+ const ix = player(g, 'i');
+ ix.forces = forces;
+ ix.reserves = 20 - Object.values(forces).reduce((a, b) => a + b, 0);
+ ix.elites!.forces = cyborgs;
+ ix.elites!.reserves = 7 - Object.values(cyborgs).reduce((a, b) => a + b, 0);
+ custody(g);
+ return g;
+}
+
+function geometry(g: Game) {
+ const from = sand.find(key => [1, 2, 3].every(d => sand.some(to => distance(g, key, to) === d)));
+ assert.ok(from);
+ const target = (d: number) => {
+ const to = sand.find(key => distance(g, from, key) === d);
+ assert.ok(to);
+ return to;
+ };
+ return {from, target};
+}
+
+function move(from: string, to: string, elite = 1, mode: 'range' | 'gather' | undefined = 'range'): Action {
+ const destination = splitLocation(to);
+ return {type: 'move', from, amount: 3, elite, territory: destination.territory,
+ sector: destination.sector, ...(mode ? {planetologist: mode} : {})};
+}
+
+function allow(g: Game): Game {
+ for (let step = 0; g.response && step < 8; step++) {
+ const owner = g.players.find(p => !g.response!.passed.includes(p.id));
+ assert.ok(owner);
+ g = act(g, owner.id, {type: 'passResponse'});
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+
+function cancel(g: Game): Game {
+ const card = player(g, 'e').hand.find(c => c.effect === 'karama');
+ assert.ok(card);
+ return act(g, 'e', {type: 'card', card: card.id, mode: 'cancel'});
+}
+
+void test('all fourteen physical skills reach genuine Ix card selection, Traitors, Face Dancers and first Storm HMS placement', () => {
+ for (const card of LEADER_SKILL_CARDS) {
+ const g = completedIxSkillsGame({requestedSkill: card.id});
+ assert.equal(g.leaderSkills!.assignments.find(a => a.owner === 'i')!.skill, card.id);
+ assert.equal(g.ixSetupCards, null);
+ assert.equal(g.mobileStronghold!.location, 'polar_sink:0');
+ assert.equal(player(g, 'i').reserves, 14);
+ assert.equal(player(g, 'i').elites!.reserves, 4);
+ assert.equal(player(g, 't').faceDancers!.length, 3);
+ assert.ok(g.players.every(p => p.hand.length === 1));
+ stable(g);
+ }
+ const offers = initializedIxSkillsOffers();
+ assert.equal(offers.setupStage, 'leaderSkills');
+ assert.ok(offers.players.every(p => p.traitorChoices.length === 0));
+ assert.equal(player(offers, 't').faceDancers, undefined);
+});
+
+void test('genuine ready Ix lobbies keep public starts, Advanced and additional module profiles closed', () => {
+ const lobby = () => {
+ let g = createGame('IXGATED', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
+ joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
+ joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
+ for (const p of g.players) g = act(g, p.id, {type: 'ready'});
+ return g;
+ };
+ reject(lobby(), 'i', {type: 'start'});
+ for (const change of [
+ (g: Game) => { g.advanced = true; },
+ (g: Game) => { g.discoveryEnabled = true; },
+ (g: Game) => { g.techTokens = createTechTokens(); },
+ (g: Game) => { g.expansions.push('choam'); },
+ (g: Game) => { g.homeworlds = {custody: null}; },
+ ]) {
+ const g = lobby();
+ change(g);
+ const before = structuredClone(g);
+ assert.throws(() => initializeLeaderSkillsGameForAudit(g));
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => {
+ const g = movement('planetologist', true);
+ const {from, target} = geometry(g);
+ deploy(g, {[from]: 4}, {[from]: 1});
+ const suboids = act(g, 'i', move(from, target(2), 0));
+ assert.equal(suboids.response, null);
+ assert.equal(player(suboids, 'i').elites!.forces[from], 1);
+ assert.equal(player(suboids, 'i').forces[target(2)], 3);
+ stable(suboids);
+ const pending = act(g, 'i', move(from, target(3)));
+ assert.equal(pending.response?.kind, 'ixMovement');
+ assert.equal(pending.pendingIxMove!.planetologist!.mode, 'range');
+ assert.equal(player(pending, 'i').moved, 0);
+ assert.deepEqual(player(pending, 'i').forces, player(g, 'i').forces);
+ stable(pending);
+ const settled = allow(pending);
+ assert.equal(player(settled, 'i').forces[target(3)], 3);
+ assert.equal(player(settled, 'i').elites!.forces[target(3)], 1);
+ assert.equal(player(settled, 'i').moved, 1);
+ stable(settled);
+});
+
+void test('cyborg cancellation keeps the army and unspent movement while the Planetologist range-two replacement stays legal', () => {
+ const g = movement('planetologist', true);
+ const {from, target} = geometry(g);
+ deploy(g, {[from]: 4}, {[from]: 1});
+ let next = cancel(act(g, 'i', move(from, target(3))));
+ assert.equal(next.pendingIxMove, null);
+ assert.equal(player(next, 'i').moved, 0);
+ assert.deepEqual(player(next, 'i').forces, player(g, 'i').forces);
+ assert.deepEqual(player(next, 'i').elites, player(g, 'i').elites);
+ reject(next, 'i', move(from, target(3)), /blocked|more than 2/);
+ const view = viewGame(reload(next), 'i');
+ const ix = view.players.find(p => p.id === 'i')!;
+ assert.equal(botMovementRange(view, ix, 1), 1);
+ assert.equal(botMovementRange(view, ix, 1, 'range'), 2);
+ next = act(next, 'i', move(from, target(2)));
+ assert.equal(player(next, 'i').moved, 1);
+ assert.equal(player(next, 'i').forces[target(2)], 3);
+ assert.equal(next.response, null);
+ stable(next);
+});
+
+function gatherGeometry(g: Game, secondDistance: number) {
+ for (const to of sand) {
+ const first = sand.find(from => distance(g, from, to) === 2);
+ if (!first) continue;
+ const second = sand.find(from => splitLocation(from).territory !== splitLocation(first).territory &&
+ splitLocation(from).territory !== splitLocation(to).territory && distance(g, from, to) === secondDistance);
+ if (second) return {first, second, to};
+ }
+ assert.fail('A pair of distinct sand origins must exist.');
+}
+
+function gather(first: string, second: string, to: string, secondElite = 0): Action {
+ const target = splitLocation(to);
+ return {type: 'move', planetologist: 'gather', forces: {[first]: 3, [second]: 2},
+ eliteForces: {[first]: 1, [second]: secondElite}, territory: target.territory, sector: target.sector};
+}
+
+void test('gather rejects lending another origin cyborg speed, even when an unselected cyborg remains at the slow origin', () => {
+ const g = movement('planetologist', true);
+ const {first, second, to} = gatherGeometry(g, 2);
+ deploy(g, {[first]: 3, [second]: 3}, {[first]: 1, [second]: 1});
+ const action = gather(first, second, to);
+ reject(g, 'i', action, /blocked|more than/);
+ const target = splitLocation(to);
+ const draft = planetologistMoveDraft(viewGame(g, 'i'), 'gather', target.territory, target.sector,
+ {[first]: 3, [second]: 2}, {[first]: 1, [second]: 0});
+ assert.equal(draft.action, null);
+ assert.match(draft.blocked!, /Each selected source/);
+ const pending = act(g, 'i', gather(first, second, to, 1));
+ assert.equal(pending.response?.kind, 'ixMovement');
+ assert.deepEqual(pending.pendingIxMove!.origins, [splitLocation(first).territory, splitLocation(second).territory]);
+ const next = allow(pending);
+ assert.equal(player(next, 'i').forces[to], 5);
+ assert.equal(player(next, 'i').elites!.forces[to], 2);
+ assert.equal(player(next, 'i').forces[second], 1);
+ stable(next);
+});
+
+void test('gather combines a cyborg origin at distance two with a suboid origin at distance one, then cancels without partial transfer', () => {
+ const g = movement('planetologist', true);
+ const {first, second, to} = gatherGeometry(g, 1);
+ deploy(g, {[first]: 3, [second]: 2}, {[first]: 1});
+ const pending = act(g, 'i', gather(first, second, to));
+ assert.equal(pending.response?.kind, 'ixMovement');
+ const canceled = cancel(pending);
+ assert.deepEqual(player(canceled, 'i').forces, player(g, 'i').forces);
+ assert.deepEqual(player(canceled, 'i').elites, player(g, 'i').elites);
+ assert.equal(player(canceled, 'i').moved, 0);
+ reject(canceled, 'i', gather(first, second, to), /blocked|more than/);
+ const settled = allow(pending);
+ assert.equal(player(settled, 'i').forces[to], 5);
+ assert.equal(player(settled, 'i').elites!.forces[to], 1);
+ stable(canceled);
+ stable(settled);
+});
+
+void test('selected cyborgs in another sector of the same origin carry that origin in the UI and engine', () => {
+ const g = movement('planetologist', true);
+ let selected: {keys: string[]; to: string} | undefined;
+ for (const t of TERRITORIES.filter(t => t.type === 'sand')) {
+ const keys = t.sectors.filter(s => s !== 18).slice(0, 2).map(s => location(t.id, s));
+ if (keys.length !== 2) continue;
+ const to = sand.find(to => keys.every(from => distance(g, from, to) <= 3) &&
+ keys.some(from => distance(g, from, to) === 3));
+ if (to) { selected = {keys, to}; break; }
+ }
+ assert.ok(selected);
+ const {keys, to} = selected;
+ deploy(g, {[keys[0]]: 2, [keys[1]]: 2}, {[keys[1]]: 1});
+ const target = splitLocation(to);
+ const view = viewGame(g, 'i');
+ const draft = planetologistMoveDraft(view, 'range', target.territory, target.sector,
+ {[keys[0]]: 2, [keys[1]]: 2}, {[keys[0]]: 0, [keys[1]]: 1});
+ assert.equal(draft.blocked, null);
+ assert.ok(draft.action);
+ const pending = act(g, 'i', draft.action);
+ assert.equal(pending.response?.kind, 'ixMovement');
+ const next = allow(pending);
+ assert.equal(player(next, 'i').forces[to], 4);
+ assert.equal(player(next, 'i').elites!.forces[to], 1);
+ const html = renderToStaticMarkup(createElement(PlanetologistMovement, {
+ game: view, act: () => {}, busy: false, destination: target.territory, sector: target.sector,
+ }));
+ assert.match(html, /Each origin uses its own selected cyborgs/);
+ stable(next);
+});
+
+void test('city ornithopters give the Ix group range three without exposing an unnecessary cyborg response', () => {
+ const g = movement('planetologist', true);
+ const {from, target} = geometry(g);
+ deploy(g, {[from]: 4, 'arrakeen:10': 1}, {[from]: 1});
+ const next = act(g, 'i', move(from, target(3), 0));
+ assert.equal(next.response, null);
+ assert.equal(player(next, 'i').elites!.forces[from], 1);
+ assert.equal(player(next, 'i').forces[target(3)], 3);
+ stable(next);
+});
+
+void test('Ix Sandmaster chosen detours use the cyborg response even when the shortest destination route is one territory', () => {
+ const g = movement('sandmaster', true);
+ let selected: string[] | undefined;
+ for (const from of sand) {
+ for (const through of GRAPH[from]) {
+ if (!sand.includes(through) || splitLocation(through).territory === splitLocation(from).territory) continue;
+ const to = GRAPH[through].find(to => sand.includes(to) && distance(g, from, to) === 1 &&
+ mobileRouteDistance([from, through, to]) === 2);
+ if (to) { selected = [from, through, to]; break; }
+ }
+ if (selected) break;
+ }
+ assert.ok(selected);
+ const [from, through, to] = selected;
+ deploy(g, {[from]: 4}, {[from]: 1});
+ g.spice[through] = 3;
+ const action: Action = {...move(from, to, 1, undefined), sandmaster: {routes: {[from]: selected}, collect: [through]}};
+ // Explicitly omit Planetologist: this is the independently assigned Sandmaster card.
+ delete action.planetologist;
+ const pending = act(g, 'i', action);
+ assert.equal(pending.response?.kind, 'ixMovement');
+ assert.equal(pending.spice[through], 3);
+ const canceled = cancel(pending);
+ assert.equal(canceled.spice[through], 3);
+ assert.equal(player(canceled, 'i').moved, 0);
+ reject(canceled, 'i', action, /range|route|Sandmaster/);
+ const next = allow(pending);
+ assert.equal(next.spice[through], 2);
+ assert.equal(player(next, 'i').spice, player(g, 'i').spice + 1);
+ assert.equal(player(next, 'i').forces[to], 3);
+ stable(canceled);
+ stable(next);
+});
+
+void test('all bot levels produce legal Ix Planetologist routes from genuine custody without sharing cyborg speed across origins', () => {
+ const g = movement();
+ const {first, second, to} = gatherGeometry(g, 2);
+ deploy(g, {[first]: 5, [second]: 5}, {[first]: 2});
+ g.spice[to] = 12;
+ let skills = 0;
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(reload(g), 'i');
+ view.players.find(p => p.id === 'i')!.bot = difficulty;
+ const before = structuredClone(view);
+ const candidates = botActions(view).filter(a => a.type === 'move');
+ assert.deepEqual(view, before);
+ assert.ok(candidates.length > 0, difficulty);
+ for (const action of candidates) {
+ if (action.planetologist) skills++;
+ const next = act(g, 'i', action);
+ custody(next);
+ }
+ }
+ assert.ok(skills > 0, 'At least one real skill movement must be exercised.');
+});
+
+void test('saved Ix allowance revalidates each origin and trainer while cancellation only needs its original declaration', () => {
+ const g = movement('planetologist', true);
+ const {first, second, to} = gatherGeometry(g, 2);
+ deploy(g, {[first]: 3, [second]: 3}, {[first]: 1, [second]: 1});
+ const pending = act(g, 'i', gather(first, second, to, 1));
+ const changedSource = reload(pending);
+ player(changedSource, 'i').elites!.forces[second] = 0;
+ player(changedSource, 'i').elites!.reserves++;
+ reject(changedSource, 'e', {type: 'passResponse'});
+ const canceled = cancel(changedSource);
+ assert.equal(canceled.pendingIxMove, null);
+ assert.equal(player(canceled, 'i').moved, 0);
+ assert.deepEqual(player(canceled, 'i').forces, player(changedSource, 'i').forces);
+ assert.deepEqual(player(canceled, 'i').elites, player(changedSource, 'i').elites);
+ stable(canceled);
+ for (const mutate of [
+ (saved: Game) => { player(saved, 'i').leaders[0].capturedBy = 't'; },
+ (saved: Game) => { saved.pendingIxMove!.origins!.reverse(); },
+ ]) {
+ const saved = reload(pending);
+ mutate(saved);
+ const card = player(saved, 'e').hand.find(c => c.effect === 'karama')!;
+ reject(saved, 'e', {type: 'passResponse'});
+ reject(saved, 'e', {type: 'card', card: card.id, mode: 'cancel'});
+ }
+});
+
+void test('Sandmaster ordinary HMS entry and exit use the current pointer in engine, controls and saved routes', () => {
+ const pointer = 'wind_pass:14';
+ const g = movement('sandmaster');
+ g.mobileStronghold!.location = pointer; // Explicit conserved board position after genuine placement.
+ g.spice[pointer] = 3;
+ const exit: Action = {type: 'move', from: MOBILE_LOCATION, amount: 3, elite: 1,
+ territory: 'wind_pass', sector: 14};
+ const view = viewGame(g, 'i');
+ const draft = sandmasterMoveDraft(view, exit);
+ assert.equal(draft.blocked, null);
+ assert.ok(draft.action);
+ assert.deepEqual(draft.choice!.routes[MOBILE_LOCATION], [MOBILE_LOCATION, pointer]);
+ assert.equal(sandmasterRouteDistance(g, draft.choice!.routes[MOBILE_LOCATION]), 1);
+ const html = renderToStaticMarkup(createElement(SandmasterMovement, {
+ game: view, move: exit, act: () => {}, busy: false,
+ }));
+ assert.match(html, /Hidden Mobile Stronghold/);
+ const next = act(g, 'i', draft.action);
+ assert.equal(next.mobileStronghold!.location, pointer);
+ assert.equal(player(next, 'i').forces[MOBILE_LOCATION], 3);
+ assert.equal(player(next, 'i').forces[pointer], 3);
+ assert.equal(next.spice[pointer], 2);
+ stable(next);
+
+ const entering = movement('sandmaster');
+ entering.mobileStronghold!.location = pointer;
+ deploy(entering, {[pointer]: 4, [MOBILE_LOCATION]: 2}, {[pointer]: 1, [MOBILE_LOCATION]: 2});
+ const enter: Action = {type: 'move', from: pointer, amount: 3, elite: 1,
+ territory: MOBILE_STRONGHOLD, sector: 0};
+ const entryDraft = sandmasterMoveDraft(viewGame(entering, 'i'), enter);
+ assert.equal(entryDraft.blocked, null);
+ assert.ok(entryDraft.action);
+ assert.deepEqual(entryDraft.choice!.routes[pointer], [pointer, MOBILE_LOCATION]);
+ const entered = act(entering, 'i', entryDraft.action);
+ assert.equal(player(entered, 'i').forces[MOBILE_LOCATION], 5);
+ assert.equal(player(entered, 'i').elites!.forces[MOBILE_LOCATION], 3);
+ assert.equal(entered.mobileStronghold!.location, pointer);
+ stable(entered);
+
+ const storm = reload(g);
+ storm.storm = 14;
+ assert.equal(sandmasterMoveDraft(viewGame(storm, 'i'), exit).action, null);
+ reject(storm, 'i', draft.action);
+ const shifted = reload(g);
+ shifted.mobileStronghold!.location = 'false_wall_west:16';
+ reject(shifted, 'i', draft.action, /Sandmaster|route|range/);
+});
+
+void test('Sandmaster HMS endpoint extension saves a cyborg response and rejects a changed pointer before settlement', () => {
+ const g = movement('sandmaster', true);
+ const pointer = 'wind_pass:14';
+ g.mobileStronghold!.location = pointer;
+ const target = sand.find(key => distance(g, pointer, key) === 1 && distance(g, MOBILE_LOCATION, key) === 2);
+ assert.ok(target);
+ const at = splitLocation(target);
+ const action: Action = {type: 'move', from: MOBILE_LOCATION, amount: 3, elite: 1,
+ territory: at.territory, sector: at.sector};
+ const draft = sandmasterMoveDraft(viewGame(g, 'i'), action);
+ assert.ok(draft.action);
+ const pending = act(g, 'i', draft.action);
+ assert.equal(pending.response?.kind, 'ixMovement');
+ stable(pending);
+ const changed = reload(pending);
+ changed.mobileStronghold!.location = 'false_wall_west:16';
+ reject(changed, 'e', {type: 'passResponse'}, /Sandmaster|route/);
+ const canceled = cancel(pending);
+ assert.equal(player(canceled, 'i').forces[MOBILE_LOCATION], 6);
+ assert.equal(player(canceled, 'i').moved, 0);
+ const allowed = allow(pending);
+ assert.equal(player(allowed, 'i').forces[target], 3);
+ assert.equal(player(allowed, 'i').forces[MOBILE_LOCATION], 3);
+ assert.equal(allowed.mobileStronghold!.location, pointer);
+ stable(allowed);
+ stable(canceled);
+});
diff --git a/tests/ix-skills-recovery.test.ts b/tests/ix-skills-recovery.test.ts
new file mode 100644
index 00000000..f13be265
--- /dev/null
+++ b/tests/ix-skills-recovery.test.ts
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync } from 'node:sqlite';
+import { unitStore } from './fixture-nexus-room-store';
+import { startPrototypeRoom } from '../tools/prototype-room';
+import type { Action } from '../game/engine';
+
+void test('authenticated concurrent Ixian starting-card choice survives restart into private skill and Traitor setup', async () => {
+ const f = unitStore();
+ const db: DatabaseSync = f.sqlite;
+ const clock = { now: () => 10000, sleep: async () => {} };
+ try {
+ const created = await f.rooms.createRoom('Ixians', 'ixians', false, ['ix']);
+ const code = created.view.code;
+ const joined = await f.rooms.joinRoom(code, 'Tleilaxu', 'tleilaxu');
+ const auths = await Promise.all([created.token!, joined.token!].map(token => f.restart().authenticate(code, token)));
+ const act = async (index: number, action: Action) => {
+ const before = await f.restart().readRoom(code);
+ await f.restart().act(code, auths[index], before.version, action, clock);
+ return f.restart().readRoom(code);
+ };
+ await act(0, { type: 'ready' }); await act(1, { type: 'ready' });
+ const ready = await f.restart().readRoom(code);
+ const seats = db.prepare('SELECT * FROM seats').all();
+ startPrototypeRoom(db, code, ready.version, 'leader-skills');
+ const initial = await f.restart().readRoom(code);
+ assert.equal(initial.decision?.kind, 'ixSetup');
+ assert.equal(initial.ixSetupCards!.length, 2);
+ const other = await f.restart().readSeatView(code, auths[1]);
+ assert.equal(other.ixTechnology, null);
+ const choice = { type: 'decision', card: initial.ixSetupCards![0].id };
+ let arrivals = 0; let release!: () => void;
+ const barrier = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
+ const results = await Promise.allSettled([
+ f.rooms.act(code, auths[0], initial.version, choice, clock),
+ f.restart().act(code, auths[0], initial.version, choice, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
+ assert.equal(results.filter(r => r.status === 'rejected').length, 1);
+ let game = await f.restart().readRoom(code);
+ assert.equal(game.version, initial.version + 1);
+ assert.equal(game.ixSetupCards, null);
+ assert.equal(game.setupStage, 'leaderSkills');
+ assert.ok(game.players.every(p => p.hand.length === 1 && p.traitorChoices.length === 0));
+ for (const [index, auth] of auths.entries()) {
+ const offer = game.leaderSkills!.offers[auth.playerId];
+ const own = await f.restart().readSeatView(code, auth);
+ assert.deepEqual(own.leaderSkills!.offer, offer);
+ assert.equal('offers' in own.leaderSkills!, false);
+ assert.equal('deck' in own.leaderSkills!, false);
+ const p = game.players.find(p => p.id === auth.playerId)!;
+ game = await act(index, { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: p.leaders[0].id });
+ }
+ assert.equal(game.setupStage, 'traitors');
+ const ix = game.players.find(p => p.faction === 'ixians')!;
+ game = await act(0, { type: 'traitor', leader: ix.traitorChoices[0] });
+ assert.equal(game.status, 'playing');
+ assert.equal(game.players.find(p => p.faction === 'tleilaxu')!.faceDancers!.length, 3);
+ const skills = [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(a => a.skill)];
+ assert.equal(skills.length, 14); assert.equal(new Set(skills).size, 14);
+ const cards = [...game.deck, ...game.players.flatMap(p => p.hand)].map(c => c.id);
+ assert.equal(cards.length, 47); assert.equal(new Set(cards).size, 47);
+ const cyborgs = game.players.find(p => p.faction === 'ixians')!.elites!;
+ assert.equal(cyborgs.reserves + cyborgs.tanks + Object.values(cyborgs.forces).reduce((a, b) => a + b, 0), 7);
+ assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
+ } finally { db.close(); }
+});
diff --git a/tests/ix-skills-suk.test.ts b/tests/ix-skills-suk.test.ts
new file mode 100644
index 00000000..ac0a863a
--- /dev/null
+++ b/tests/ix-skills-suk.test.ts
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { quoteSukRescue, sukRescueOptions } from '../game/suk-graduate';
+import { sukRescueLabel } from '../components/suk-graduate';
+
+void test('Ixian Suk rescue explicitly commits ambiguous cyborg reserve origins before substitution', () => {
+ const pool = [{ key: 'wind_pass:14', normal: 2, elite: 1 }, { key: 'wind_pass:15', normal: 2, elite: 2 }];
+ const losses = { normal: 2, elite: 3, paidNormal: 2, paidElite: 3 };
+ const skill = { leader: 'ixians-0', mode: 'skilled' as const };
+ const options = sukRescueOptions(skill, pool, losses, true);
+ const choices = options.filter(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
+ assert.equal(choices.length, 2);
+ assert.deepEqual(choices.map(o => quoteSukRescue(skill, pool, losses, o, true).eliteTanks), [
+ { 'wind_pass:14': 1, 'wind_pass:15': 1 }, { 'wind_pass:15': 2 },
+ ]);
+ assert.match(sukRescueLabel(choices[0]), /cyborgs: 1 from sector 15/);
+ assert.match(sukRescueLabel(choices[1]), /cyborgs: 1 from sector 14/);
+ assert.throws(() => quoteSukRescue(skill, pool, losses, { ...choices[0], eliteReserves: { 'wind_pass:14': 2 } }, true));
+ for (const option of options) {
+ const q = quoteSukRescue(skill, pool, losses, JSON.parse(JSON.stringify(option)), true);
+ assert.equal(Object.values(q.eliteTanks!).reduce((a, b) => a + b, 0), q.tanks.elite);
+ for (const group of q.removed) assert.ok((q.eliteTanks![group.key] ?? 0) <= group.elite);
+ assert.equal(q.tanks.elite + q.reserves.elite + (option.kept?.kind === 'elite' ? 1 : 0), 3);
+ }
+ const legacy = sukRescueOptions(skill, pool, losses);
+ assert.ok(legacy.every(o => o.eliteReserves === undefined));
+ assert.ok(legacy.every(o => quoteSukRescue(skill, pool, losses, o).eliteTanks === undefined));
+});
+
+void test('normal Suk rescue distinguishes both lost-cyborg origins and single-origin choices remain compact', () => {
+ const skill = { leader: 'ixians-0', mode: 'normal' as const };
+ const losses = { normal: 0, elite: 2, paidNormal: 0, paidElite: 2 };
+ const pool = [{ key: 'wind_pass:14', normal: 0, elite: 1 }, { key: 'wind_pass:15', normal: 0, elite: 1 }];
+ const options = sukRescueOptions(skill, pool, losses, true);
+ assert.equal(options.length, 2);
+ assert.ok(options.every(o => !o.kept && o.eliteReserves));
+ assert.deepEqual(options.map(o => quoteSukRescue(skill, pool, losses, o, true).eliteTanks), [{ 'wind_pass:14': 1 }, { 'wind_pass:15': 1 }]);
+ const unique = sukRescueOptions(skill, [{ key: 'wind_pass:14', normal: 0, elite: 2 }], losses, true);
+ assert.deepEqual(unique, [{ normal: 0, elite: 1, kept: null }]);
+ assert.deepEqual(quoteSukRescue(skill, [{ key: 'wind_pass:14', normal: 0, elite: 2 }], losses, unique[0], true).eliteTanks, { 'wind_pass:14': 1 });
+});
diff --git a/tests/planetologist-movement.test.ts b/tests/planetologist-movement.test.ts
index 01aa8e97..7b8ff3ed 100644
--- a/tests/planetologist-movement.test.ts
+++ b/tests/planetologist-movement.test.ts
@@ -247,7 +247,7 @@ void test('two-origin movement keeps separate sources and elites, one shared arr
unchanged(g, { ...a, forces: { [first]: 2 } }, /exactly two/);
const far = fixture();
const selected = gather(far, undefined, 2);
- unchanged(far, selected.a, /more than 1/);
+ unchanged(far, selected.a, /Each selected origin.*own movement range/);
});
void test('Fremen extra range preserves a real Karama response and replacement keeps Planetologist', () => {
diff --git a/tests/tleilaxu-skills-integration.test.ts b/tests/tleilaxu-skills-integration.test.ts
index 21c5d205..3d9cd9d8 100644
--- a/tests/tleilaxu-skills-integration.test.ts
+++ b/tests/tleilaxu-skills-integration.test.ts
@@ -56,7 +56,7 @@ function exchange(g: Game, owner: string) {
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
}
-void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Ixian and module gates remain closed', () => {
+void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Richese and module gates remain closed', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
@@ -64,7 +64,7 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
stable(game);
}
- for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Ixian','ixians');},
+ for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
diff --git a/tools/faction-games.ts b/tools/faction-games.ts
index c5d3090d..d9cc8292 100644
--- a/tools/faction-games.ts
+++ b/tools/faction-games.ts
@@ -26,7 +26,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
-type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills';
+type Profile = 'base' | 'choam' | 'ecaz' | 'combined' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -119,7 +119,14 @@ const TLEILAXU_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(playe
expansions: ['ix'],
roster: (['tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
}));
-const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills';
+const IX_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
+ ordinal: 26 + players - 2,
+ profile: 'ix-skills',
+ rules: 'basic',
+ expansions: ['ix'],
+ roster: (['ixians', 'tleilaxu', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[]).slice(0, players),
+}));
+const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills';
type TraceEntry = {
attempt: number;
@@ -157,10 +164,10 @@ type Result = {
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
- '[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills] ' +
+ '[--seed UINT32] [--profile all|base|choam|ecaz|combined|moritani-skills|tleilaxu-skills|ix-skills] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
- 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills or tleilaxu-skills. Output must be a new private directory outside the checkout.'
+ 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; base and the skill profiles default to their 2–6-player samples. --players requires --profile base, moritani-skills, tleilaxu-skills or ix-skills. Output must be a new private directory outside the checkout.'
);
}
@@ -198,8 +205,8 @@ function scenarioName(scenario: Scenario) {
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
- if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills'].includes(profile))
- throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills or tleilaxu-skills.');
+ if (!['all', 'base', 'choam', 'ecaz', 'combined', 'moritani-skills', 'tleilaxu-skills', 'ix-skills'].includes(profile))
+ throw new Error('--profile must be all, base, choam, ecaz, combined, moritani-skills, tleilaxu-skills or ix-skills.');
return profile as Profile | 'all';
}
@@ -299,7 +306,7 @@ function resumedGame(path: string) {
game.moritaniAssassinateCallEvents
)
throw new Error('--resume sample scenarios exclude optional modules.');
- const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS].find(
+ const scenario = [...SCENARIOS, ...BASE_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS].find(
(candidate) =>
skillProfile(candidate.profile) === !!game.leaderSkills &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
@@ -495,13 +502,13 @@ async function main() {
const rules = parseRules(values.rules);
const players = parsePlayers(values.players);
if (supplied('players') && profile !== 'base' && !skillProfile(profile))
- throw new Error('--players requires --profile base, moritani-skills or tleilaxu-skills.');
+ throw new Error('--players requires --profile base, moritani-skills, tleilaxu-skills or ix-skills.');
if (skillProfile(profile) && rules === 'advanced')
- throw new Error('Moritani and Tleilaxu with Leader Skills currently support only Basic samples.');
+ throw new Error('Expansion Leader Skills profiles currently support only Basic samples.');
const resume = values.resume ? resumedGame(values.resume) : null;
const selected = resume
? [resume.scenario]
- : (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : SCENARIOS).filter(
+ : (profile === 'base' ? BASE_SCENARIOS : profile === 'moritani-skills' ? MORITANI_SKILLS_SCENARIOS : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : SCENARIOS).filter(
(scenario) =>
(profile === 'all' || scenario.profile === profile) &&
(rules === 'both' || scenario.rules === rules) &&