Connect Basic Ixians to the full Leader Skills prototype

Admit genuine Basic Ixian setup with the full fourteen-skill deck, real
starting-card choice, optional Tleilaxu/base opponents and first-Storm HMS.
Keep public starts, Advanced and other module combinations gated.

Share per-origin cyborg movement range across engine, controls and legal bots.
Preserve independent Planetologist range after native Karama cancellation.
Connect Sandmaster force routes through room-local HMS pointer adjacency.
Retain canceled movement's no-op contract while validating actual execution.

Resolve Suk rescue before substitution with explicit ambiguous cyborg rescue
origins and exact remaining Tank losses. Finish Rihani before native
substitution, bind saved physical losses and declared exchange, and resume
mandatory/optional winner disposal on allow, decline or cancellation.
Admit exact green Ix Thumper, Harvester and Amal as Planetologist substitutes.

Validation: types, lint, 5,082 offline tests and production build passed.
Five genuine 2-6 player games completed with zero rejected actions and 54
JSON restores. Authenticated in-memory concurrent starting-card recovery,
private setup, physical card/force custody and independent review passed.
Browser FLGHBSA4 completed real setup, first Storm/HMS placement and a
conserved staged Suk battle. Mobile rescue, refresh and one-for-one
substitution passed. Screenshot capture timed out; full visual acceptance
remains open. All 1,582 prior rooms, 3,350 human seats and auxiliary records
were preserved; one new QA room/seat remains. No restart, reset or automation.

AI work remains limited to legal participation until every non-AI feature
is complete, then targets roughly 75% higher-tier wins for each adjacent pair.
This commit is contained in:
Quintine committed 2026-09-21 14:36:27 +10:00
1 parent d3559f65ad
commit 3efda2128e
28 files changed
+1461 -129

No files matched your search

+3 -1
View File
@@ -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
+9 -1
View File
@@ -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.
</p>
{me.faction === 'ixians' && (
<p className="fine">
Each origin uses its own selected cyborgs for movement range. Cyborgs
from one territory cannot carry suboids from the other territory farther.
</p>
)}
{sources.length ? (
sources.map(([from, owned]) => {
const source = splitLocation(from);
+5 -5
View File
@@ -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 (
<fieldset className="space-y-2" key={from}>
<legend>{placeName(game, from)} route</legend>
<p className="fine">
{route.map((key) => placeName(game, key)).join(' → ')} ·{' '}
{mobileRouteDistance(route)} / {order.range} territories
{sandmasterRouteDistance(game, route)} / {order.range} territories
</p>
<div className="flex flex-wrap gap-2">
<Button
+3 -1
View File
@@ -12,7 +12,9 @@ export function sukRescueLabel(option: SukRescueOption): string {
const kept = option.kept
? `keep 1 ${option.kept.kind === 'normal' ? 'ordinary' : 'elite'} in sector ${splitLocation(option.kept.key).sector}; `
: '';
return `Save ${option.normal} ordinary + ${option.elite} elite: ${kept}return ${total - (option.kept ? 1 : 0)} to reserves`;
const origins = option.eliteReserves ? ` (cyborgs: ${Object.entries(option.eliteReserves)
.map(([key, count]) => `${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 }: {
+20 -23
View File
@@ -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
+10
View File
@@ -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
+84
View File
@@ -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.
+9
View File
@@ -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.
+1
View File
@@ -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) |
+2 -1
View File
@@ -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
+4
View File
@@ -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;
+8 -12
View File
@@ -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,
+112 -59
View File
@@ -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<MovementOrder, 'planetologist' | 'origins' | 'origin' | 'noField' | 'ornithopterEvent' | 'discoveryFlight'>) {
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<string, number>) {
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<Game['pendingIxSubstitution']>) {
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<Game['pendingWinnerDiscards']>) {
@@ -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<string, number>) => {
@@ -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)))
+9 -3
View File
@@ -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<Card, 'id' | 'name' | 'kind' | 'effect'> | undefined,
): boolean {
+11 -1
View File
@@ -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. */
+28
View File
@@ -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<Record<string, number>>,
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')) ||
+24 -1
View File
@@ -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'] },
+31 -4
View File
@@ -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<string, string[]>,
@@ -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 +
+35 -4
View File
@@ -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<string, number>;
};
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<string, number>[] {
const remaining = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
const result: Record<string, number>[] = [];
const visit = (index: number, left: number, selected: Record<string, number>) => {
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<string, number> } : {}),
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),
+20
View File
@@ -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<string, { cards: string[] }>; 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');
+266
View File
@@ -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<Action> = {}, 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);
});
}
+197
View File
@@ -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<IxSkillsFaction, string> = {
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;
}
+439
View File
@@ -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<string, number>, cyborgs: Record<string, number>) {
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);
});
+70
View File
@@ -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<void>(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(); }
});
+41
View File
@@ -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 });
});
+1 -1
View File
@@ -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', () => {
+2 -2
View File
@@ -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);
+17 -10
View File
@@ -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) &&