);
diff --git a/components/suk-graduate.tsx b/components/suk-graduate.tsx
new file mode 100644
index 00000000..3c80047d
--- /dev/null
+++ b/components/suk-graduate.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { useState } from 'react';
+import type { Action, Decision } from '@/game/engine';
+import type { SukRescueOption } from '@/game/suk-graduate';
+import { splitLocation } from '@/game/board';
+import { Button } from '@/components/ui/button';
+
+export function sukRescueLabel(option: SukRescueOption): string {
+ const total = option.normal + option.elite;
+ if (!total) return 'Do not rescue any forces';
+ 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`;
+}
+
+export function SukGraduatePanel({ decision, act, busy }: {
+ decision: Extract;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const [choice, setChoice] = useState(null);
+ return
+
Suk Graduate rescue
+
{decision.mode === 'normal'
+ ? 'Your skilled leader remained face up. Return one of your selected casualties to reserves instead of the Tanks.'
+ : 'Your skilled leader survived. You may save up to three selected casualties. One stays in its original sector; the rest return to reserves.'}
+
Ordinary and elite counters keep their identity. Your paid battle support is unchanged.
+
+ {choice !== null &&
{sukRescueLabel(decision.options[choice])}
}
+
+ ;
+}
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md
index cfa96fac..6ee30195 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -5,7 +5,9 @@ now have sourced names and complete paraphrased normal/battle text, shared
inspectors, private two-card offers and one-card assignment custody. Five role
bonuses, public skilled capture, death return and own revival are connected;
Planetologist now also has movement and base green-Special battle controls;
-eight effects remain missing. This supersedes the historical missing-inventory
+[Suk Graduate](SUK_GRADUATE_RULES.md) redirects actual ordinary/elite casualties
+through readable rescue choices without creating counters. Seven effects remain
+missing. This supersedes the historical missing-inventory
rows below only for Leader Skills. See [source inventory](LEADER_SKILLS_RULES.md),
[capture interpretation](LEADER_SKILLS_CAPTURE.md) and [prototype evidence](LEADER_SKILLS_RUNTIME.md).
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d71a1092..23d4ec99 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -4,6 +4,15 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth
+- [Suk Graduate](SUK_GRADUATE_RULES.md) now connects both casualty-rescue bands,
+ ordinary/elite counter routing, player controls, all four AI profiles and saved
+ continuation. Targeted browser rescue and refresh preserved two board forces,
+ seventeen reserves, one Tank and the private hand. Two Basic/Advanced samples
+ finished but did not encounter an eligible rescue; targeted tests cover that
+ effect. Advanced Atreides selection remains unavailable pending the recorded
+ Kwisatz Haderach question. After the latest reported outage, the server was
+ healthy and all 360 rooms were backed up; the 359 opening rooms and their seat
+ records were unchanged. Checkpoint-wide results are in the commit.
- [Planetologist](PLANETOLOGIST_RULES.md) now has bounded movement and Special-card
battle prototypes. Independent review and **84 focused tests** pass. Two genuine
four-profile Basic/Advanced samples finished with saved continuations and no
@@ -20,7 +29,8 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
- The [first Leader Skills prototype](LEADER_SKILLS_RUNTIME.md) connects all
fourteen physical cards, private setup, five battle disciplines, skilled capture,
death and own revival through controls, legal AI and saved choices. The [Planetologist follow-up](PLANETOLOGIST_RULES.md) adds movement and
- Special-card battle controls; the other eight skills remain **Missing**. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md)
+ Special-card battle controls, and Suk Graduate adds casualty rescue; the other
+ seven skills remain **Missing**. The user-requested [capture search](LEADER_SKILLS_CAPTURE.md)
resolves publicity through the known physical card; captured replacement and
broader combinations remain pending. No mode gate is opened.
- Following the reported power outage, the existing server responded normally,
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index f08dc186..9ac57bc6 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,42 @@
# Dune implementation status
+## 13 September 2026 — Suk Graduate casualty rescue prototype
+
+[Suk Graduate](SUK_GRADUATE_RULES.md) connects both printed bands through shared
+physical casualty quotes, owner controls, all four bot profiles and durable
+battle continuation. A face-up trainer returns exactly one casualty to reserves;
+a surviving selected leader may save up to three, keeping one in its original
+sector and returning the others. Raw casualty choice and paid support remain
+unchanged. Elites retain their identity. Sole legal normal rescues are automatic.
+
+Independent rules and implementation review found two custody defects: a stale
+dead face-up trainer could retain a skill, and a pending rescue was not bound to
+its actual skill assignment. Both now reject before mutation, with regressions.
+Saved receipt, missing continuation, replay, killed/returned captives, ordinary
+and elite rescue, JSON restore and SQLite concurrent submission are covered.
+The final Suk/Leader Skills focused union passed **46 tests**, including the
+private assignment restriction, usable alternate selection and all four bots.
+New Advanced Atreides selection is unavailable while the existing Kwisatz
+Haderach loss-count question is pending; old assigned saves retain preflight
+protection. Seven other skill effects and combined modules remain unfinished.
+
+Genuine module setup in fresh browser room `BD33WTTV` assigned Suk Graduate to
+Hasimir Fenring. A separately staged conserved battle then exercised the actual
+rescue control: three casualties saved, one kept in sector 10, two returned to
+reserves. Refresh preserved two board forces, seventeen reserves, one Tank, the
+private Crysknife/Baliset/Burseg and winner card cleanup. Two four-profile samples
+completed Basic/Advanced games with 92/188 actions and 3/6 JSON restorations,
+respectively. Neither encountered an eligible rescue; targeted tests and browser
+play establish that effect. These are bounded prototype samples, not calibration
+or full rules acceptance.
+
+The latest reported outage left the existing server healthy. A fresh online
+backup captured all 360 rooms. All 359 opening rooms and their seat/recovery
+records remained unchanged; the new QA seat restored after refresh. No restart
+or reset was needed. Required broad checks and final preservation are recorded
+in the checkpoint commit and source-bound private report. Public Advanced,
+expansion, module and publication gates remain closed.
+
## 13 September 2026 — Planetologist movement and green-Special battle prototype
[Planetologist](PLANETOLOGIST_RULES.md) now connects capped extra range, a single
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 8dcbe4dc..63890be8 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -49,9 +49,10 @@ publisher instruction to announce a name.
## Missing and guarded work
-The other nine cards have readable inventory entries but **missing effects**:
-Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer,
-Sandmaster, Smuggler and Planetologist. They are not completed features.
+Seven cards still have readable inventory entries but **missing effects**:
+Bureaucrat, Spice Banker, Diplomat, Mentat, Rihani Decipherer, Sandmaster and
+Smuggler. They are not completed features. Planetologist and Suk Graduate now
+have connected prototypes described below.
Captured-card replacement entitlement remains unresolved. The prototype rejects
own-leader revival while its original skill is captured, before beginning a
@@ -62,7 +63,7 @@ Before enabling them, add response-driven automatic revival continuation
coverage; the current before/after revival observer does not certify deferred
Tleilaxu response chains. The other source-contract timing questions also remain
open. Planetologist now has bounded movement and battle controls as described below.
-The other eight skills still need their payments, information, rescues, retreats
+The other seven skills still need their payments, information, rescues, retreats
and other effects connected.
## Planetologist follow-up
@@ -77,9 +78,19 @@ continuation and revalidate source custody before a response is spent.
Six base factions remain the supported roster. Mixed BG origins are supported
where ordinary rules determine the destination stance. Enemy-only mixed arrival
still needs a combined flip response. Expansion faction/card/module combinations
-and full integrated acceptance remain unfinished. Eight other skill effects are
+and full integrated acceptance remain unfinished. Seven other skill effects are
still missing; this is partial prototype coverage.
+## Suk Graduate follow-up
+
+[Suk Graduate](SUK_GRADUATE_RULES.md) connects the mandatory one-counter
+normal rescue and the surviving selected leader’s optional rescue of up to three
+physical casualties. Normal and elite identity, original sector, paid support,
+private hands and captured-leader return survive the saved continuation. Human
+controls and all four AI profiles use the same server-projected legal choices.
+Advanced Atreides awaits the loss-count ruling; expansion factions and combined
+optional modules remain guarded.
+
## Local prototype entry and evidence
Create a new base-faction local lobby, select its seats and mark them ready.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index a30c876a..52d6f7f8 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -16,7 +16,7 @@ Updated 13 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) |
-| 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 and common lifecycle are prototyped; eight skills and combinations remain 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), [Planetologist](PLANETOLOGIST_RULES.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; seven skills and combinations remain 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), [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) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
@@ -32,6 +32,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat retreat ordering and simultaneous optional timing need resolution before those cases are enabled. These are newly recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources) |
+| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
diff --git a/docs/SUK_GRADUATE_RULES.md b/docs/SUK_GRADUATE_RULES.md
new file mode 100644
index 00000000..10fae2cc
--- /dev/null
+++ b/docs/SUK_GRADUATE_RULES.md
@@ -0,0 +1,100 @@
+# Suk Graduate rescue prototype
+
+13 September 2026. **Prototyped / Partial**. This connects both Suk Graduate
+bands to real battle resolution, player choices, AI and saved continuation.
+Complete module, faction and publication gates remain closed.
+
+## Source contract
+
+Use the physical card and the [Leader Skills source archive](LEADER_SKILLS_RULES.md).
+The [GF9 CHOAM & Richese rulebook](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
+page 9 governs visibility, survival and skill-before-faction ordering; its
+page 12 Q&A gives a captor only the lower band of an actually used captive.
+The [designer walkthrough at 11:10–11:59](https://www.youtube.com/watch?v=XT_azRVLq_0&t=670s)
+expressly distinguishes the normal and selected-leader rescues and says they
+do not accumulate. The archived card photograph supplies the precise quantities.
+
+After an ordinary win with physical casualties, a face-up native Suk Graduate
+returns exactly one chosen casualty to reserves instead of the Tanks. This band
+is mandatory. When the actual skilled leader is used and survives, the player
+may instead save zero through three casualties, limited by the raw casualties.
+If any are saved, exactly one stays in the battle territory and the rest return
+to reserves. Declining this lower band does not trigger the normal band.
+
+A concealed skill with another selected leader grants neither band. A killed
+selected skilled leader grants no rescue. A different selected leader may die
+while the face-up native trainer remains alive and grants the normal rescue.
+A traitor victory loses no winning forces; mutual traitors and explosions have
+no winner. None creates a Suk rescue. The zero-dial case also creates no window.
+
+## Physical settlement and continuation
+
+The existing legal raw normal/elite casualty choices are retained. Choose that
+allocation first; Suk then redirects only those actual counters. Paid support
+is unchanged. Saved elites remain elites in reserves or on the board. The
+normal band resolves automatically when only one counter type can be saved;
+the lower band's optional choice remains with its owner.
+
+The receipt freezes revealed eligibility before the live battle is cleared or
+a used captive returns. It records the battle, raw commitment, source groups,
+physical pool and remaining card cleanup. A separate obligation in the last
+battle context prevents a missing receipt from silently bypassing rescue.
+Actions, views and automatic continuation validate it. A stale event, altered
+allocation, changed physical pool or missing choice rejects without mutation.
+The server alone projects the legal rescue options; the receipt is not exposed.
+
+The current engine assigns a raw casualty mix across sectors in existing
+location order. A saved counter that remains stays in that original sector;
+there is no free sector relocation. This is an explicit implementation inference:
+the card specifies a territory, not how to select sectors. Saved counter types
+and any eligible sector choice remain visible to the player.
+
+Suk settles before winner card choices and faction aftermath, including capture
+and Face Dance. Mandatory loser-card cleanup uses its existing continuation.
+The exact ordering against future combined optional card interruptions still
+needs integrated acceptance. Homeworld custody, Nexus and expansion-faction
+combinations are explicitly rejected before resources or traitor votes commit;
+the genuine Leader Skills initializer currently admits base factions only.
+
+## Atreides loss-count question
+
+The base Advanced rules activate Kwisatz Haderach after seven force losses.
+Suk describes rescued counters as losses redirected away from the Tanks, and
+the designer describes the retained counter as having been killed. Counting
+the raw dialed casualties is therefore the stronger textual inference, but
+the retrieved publisher/designer sources do not expressly settle this combination.
+The user question is pending. New Advanced Atreides setup and revival offers
+retain their physical cards but make Suk unavailable, explain why and allow the
+other offered card. The server, private view, controls and bots share this
+prototype restriction. Already-assigned saved combinations retain the battle
+preflight guard before final traitor votes or resources commit. This availability
+restriction is not an official rule. Other rescued counters are kept out of the
+physical Tank and ordinary battle-loss totals in this bounded prototype.
+
+## Evidence and remaining work
+
+Focused regressions cover genuine module setup followed by conserved targeted
+battles; both bands, no stacking, decline, killed leaders, captured-leader return,
+normal/elite routing, unchanged support and all four bot profiles. JSON and
+SQLite store restart retain the pending choice. Concurrent submissions commit
+one rescue; the stale submission changes no counters. Component checks cover
+clear destinations, required selection and disabled controls.
+
+Fresh browser room `BD33WTTV` used genuine Emperor/Guild module setup and selected
+Suk Graduate/Hasimir Fenring. A separately staged, conserved battle position used
+one physical Baliset from the deck. Actual browser rescue saved three ordinary
+casualties: one stayed in sector 10 and two returned to reserves. The result was
+two board forces, seventeen reserves and one Tank. Refresh after another reported
+outage retained that result, the private Crysknife/Baliset/Burseg and winner card
+cleanup. This was a targeted battle, not a naturally reached full game.
+
+Four-profile samples completed Basic seed 20260918 (92 actions, three JSON
+restorations) and Advanced seed 20260919 (188 actions, six restorations), with no
+rejected actions and conserved cards/forces after each action. Neither sample
+encountered an eligible Suk rescue; effect evidence comes from the targeted
+tests and browser journey. Checkpoint-wide results and final preservation are
+recorded in the commit and source-bound private report.
+
+These tests do not certify the full expansion, every combination,
+AI strength ordering or complete human games. Seven other skill effects remain
+missing; retain the [full scope and pending rulings](CURRENT_STATUS.md).
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index 689d256d..6a41d72a 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -28,6 +28,7 @@ import {
} from './stone-burner';
import { matchingTraitor } from './traitors';
import { auditCount } from './choam-auditor';
+import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
import {
quoteHomeworldBattleRules,
type HomeworldBattleRules,
@@ -140,6 +141,7 @@ export type BattleResolutionQuote = {
homeworldExplosion?: HomeworldExplosionLosses;
/** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */
basicWinnerLosses: number | null;
+ sukGraduate?: SukGraduateSkill;
casualties: {
forces: CombatForces;
dial: number;
@@ -570,10 +572,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
);
let casualties: BattleResolutionQuote['casualties'] = null;
let basicWinnerLosses: number | null = null;
+ const sukGraduate = winner && result === 'normal' && winner.plan.dial > 0
+ ? sukGraduateSkill(winner.leaderSkills ?? [], winner.leader,
+ !(winner === a ? deaths.attacker : deaths.defender))
+ : null;
if (winner && result === 'normal') {
if (
input.advanced ||
input.typedCasualties ||
+ sukGraduate ||
homeworld ||
winner.faction === 'ixians'
) {
@@ -744,6 +751,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
? { homeworldExplosion: homeworld.explosion }
: {}),
basicWinnerLosses,
+ ...(sukGraduate ? { sukGraduate } : {}),
casualties,
played,
discarded,
diff --git a/game/bots.ts b/game/bots.ts
index 0af937d8..ecd93281 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -2394,6 +2394,13 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
+ if (d.kind === 'sukRescue') {
+ return d.options.map((option, choice) => ({ choice,
+ score: option.normal + option.elite * (level > 0 ? 2.5 : 1) +
+ (option.kept ? 1 + (option.kept.kind === 'elite' && level > 0 ? 0.5 : 0) : 0),
+ })).sort((a, b) => b.score - a.score)
+ .map(({ choice }) => ({ type: 'decision', event: d.event, choice }));
+ }
if (d.kind === 'battleLosses') {
const choices = d.options
.map((o, choice) => ({
@@ -3530,10 +3537,11 @@ export function botActions(g: GameView): Action[] {
if (skill.offer && ((g.status === 'setup' && g.setupStage === 'leaderSkills') || (g.decision?.kind === 'leaderSkillRevival' && g.decision.player === g.me))) {
const offer = skill.offer;
if (!offer.cards.length) return [{type:'leaderSkill',event:offer.event,mode:'draw'}];
+ const available = offer.cards.filter((card) => !skill.unavailableSkills?.[card]);
const direct = ['warmaster','master-of-assassins','swordmaster-of-ginaz','killer-medic','prana-bindu-adept'];
- const chosen = offer.cards.find((card) => direct.includes(card)) ?? offer.cards[0];
+ const chosen = available.find((card) => direct.includes(card)) ?? available[0];
const leader = offer.leader ?? [...skill.eligibleLeaders].sort((a,b) => (g.allLeaders.find((l) => l.id === b.id)?.strength ?? 0) - (g.allLeaders.find((l) => l.id === a.id)?.strength ?? 0))[0]?.id;
- if (leader) return [{type:'leaderSkill',event:offer.event,skill:chosen,leader}];
+ if (leader && chosen) return [{type:'leaderSkill',event:offer.event,skill:chosen,leader}];
}
}
if (g.nexusTraitors?.pending)
diff --git a/game/engine.ts b/game/engine.ts
index 701032e3..6324dc89 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,11 +1,12 @@
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
-import { leaderSkillCard } from './leader-skill-cards';
+import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
import {
canUsePlanetologistBattleSpecial,
isPlanetologistBattleSpecialCard,
validLeaderSkillBattleCardPair,
type BattleLeaderSkill,
} from './leader-skill-combat';
+import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
@@ -709,6 +710,7 @@ export type Decision =
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
| { kind: 'poisonTooth'; player: string }
| { kind: 'stoneBurner'; player: string; event: string }
+ | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
| { kind: 'fullPlanOffer'; player: string }
| { kind: 'fullPlanRead'; player: string; target: string }
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
@@ -1252,6 +1254,7 @@ export type Game = {
biddingEnd?: BiddingEnd | null;
ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[];
pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null;
+ pendingSukRescue?: SukRescueReceipt | null;
pendingChoamMarketGhola?: ChoamMarketGhola | null;
choamTradeTurn?: number;
inflation?: Inflation | null;
@@ -1465,6 +1468,7 @@ export type Game = {
cardRoles?: Record>>;
cardRolesSignature?: string;
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
+ sukRescue?: { signature: string; completed: boolean };
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
} | null;
@@ -4309,6 +4313,7 @@ function treacheryDiscardIntegrity(g: Game) {
!!c.casualties ===
(c.result === 'normal' &&
(g.advanced ||
+ !!g.pendingSukRescue ||
!!g.homeworlds ||
g.players.find((p) => p.id === c.winner)?.faction ===
'ixians')),
@@ -4887,8 +4892,25 @@ function skillEligibleLeaders(g: Game, owner: string): string[] {
)
.map((leader) => leader.id);
}
+const ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK =
+ 'Suk Graduate is unavailable to Advanced Atreides while the Kwisatz Haderach loss-count ruling is pending.';
+function leaderSkillAssignmentUnavailable(
+ g: Game,
+ owner: string,
+ skill: string,
+): string | null {
+ return g.advanced &&
+ getPlayer(g, owner).faction === 'atreides' &&
+ skill === 'suk-graduate'
+ ? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
+ : null;
+}
function leaderSkillsIntegrity(g: Game) {
+ sukRescueIntegrity(g);
if (!g.leaderSkills) return;
+ requireRule(g.leaderSkills.assignments.every((assignment) =>
+ g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)),
+ 'A saved Leader Skill cannot remain assigned to a dead or missing leader.');
skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players));
requireRule(
!g.players.some((p) => p.leaders.some((l) => l.gholaBy)),
@@ -5013,6 +5035,12 @@ function projectedLeaderSkills(
const offer = g.leaderSkills.offers[owner] ?? null;
const eligible = skillEligibleLeaders(g, owner);
const assignment = nativeLeaderSkill(g, owner);
+ const unavailableSkills = Object.fromEntries(
+ (offer?.cards ?? []).flatMap((skill) => {
+ const reason = leaderSkillAssignmentUnavailable(g, owner, skill);
+ return reason ? [[skill, reason]] : [];
+ }),
+ ) as Partial>;
return {
assignments: g.leaderSkills.assignments.map((a) => {
const controller = leaderSkillController(g, a);
@@ -5025,6 +5053,7 @@ function projectedLeaderSkills(
};
}),
offer,
+ ...(Object.keys(unavailableSkills).length ? { unavailableSkills } : {}),
eligibleLeaders: getPlayer(g, owner)
.leaders.filter((l) => eligible.includes(l.id))
.map((l) => ({ id: l.id, name: l.name })),
@@ -14059,6 +14088,13 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
pendingAuditorPresent: !!g.pendingAuditor,
pendingRetentionPresent: !!g.moritaniRetention,
});
+ if (quote.sukGraduate) requireRule(
+ !g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
+ 'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
+ if (quote.sukGraduate) requireRule(
+ !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
+ g.players.every((p) => faction(p.faction).expansion === 'base'),
+ 'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.');
for (const id of quote.destroyedArmies)
if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory));
else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity);
@@ -14340,6 +14376,16 @@ function resolveBattle(g: Game) {
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
};
+ if (winner && quote.sukGraduate && casualtyCommitment) {
+ requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.');
+ g.pendingSukRescue = {
+ event: g.lastBattleContext.event, turn: g.turn, player: winner.id,
+ territory: b.territory, skill: quote.sukGraduate,
+ commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory),
+ cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
+ };
+ updateSukReceipt(g);
+ }
if (g.homeworlds?.custody && winner?.faction === 'atreides' &&
(quote.result === 'normal' || quote.result === 'traitor')) {
requireRule(!g.homeworldVictoryReinforcement || g.homeworldVictoryReinforcement.stage === 'complete',
@@ -14435,6 +14481,20 @@ function settleWinnerCasualties(
choice: Casualties,
automatic = false,
) {
+ if (g.pendingSukRescue) {
+ const pending = g.pendingSukRescue;
+ requireRule(pending.player === p.id && pending.territory === to &&
+ JSON.stringify(cards) === JSON.stringify(pending.cards) && !pending.losses &&
+ pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(choice)),
+ 'Choose the original Suk Graduate casualty allocation.');
+ pending.losses = { ...choice };
+ updateSukReceipt(g);
+ const options = sukRescueOptions(pending.skill, pending.pool, choice);
+ 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 };
+ return;
+ }
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id);
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
@@ -14473,6 +14533,85 @@ function settleWinnerCasualties(
};
} else finishWinner(g, p, to, cards);
}
+function sukForcePool(p: Player, territory: string): SukForceGroup[] {
+ return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
+ .map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }));
+}
+function sukPhysicalSignature(p: Player) {
+ return JSON.stringify({ forces: p.forces, elites: p.elites ?? null,
+ reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses });
+}
+function updateSukReceipt(g: Game) {
+ const pending = g.pendingSukRescue!;
+ pending.signature = sukReceiptSignature(pending);
+ g.lastBattleContext!.sukRescue = { signature: pending.signature, completed: false };
+}
+function sukRescueIntegrity(g: Game) {
+ const pending = g.pendingSukRescue, context = g.lastBattleContext;
+ const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'sukRescue');
+ if (!pending) {
+ requireRule(!decisions.length && (!context?.sukRescue || context.sukRescue.completed),
+ 'The saved Suk Graduate rescue receipt is missing.');
+ return;
+ }
+ const player = g.players.find((p) => p.id === pending.player);
+ requireRule(player && g.leaderSkills && g.phase === 6 && !g.battle &&
+ g.leaderSkills.assignments.some((a) => a.skill === 'suk-graduate' && a.leader === pending.skill.leader) &&
+ context?.event === pending.event && context.turn === pending.turn && g.turn === pending.turn &&
+ context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory &&
+ context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
+ pending.signature === sukReceiptSignature(pending) &&
+ pending.physical === sukPhysicalSignature(player) &&
+ 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) &&
+ JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
+ pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)),
+ 'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.');
+ if (pending.losses) {
+ 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!))),
+ '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);
+ const discard = g.pendingTreacheryDiscard?.continuation;
+ requireRule(!decisions.length && (lossDecision ? lossDecision.kind === 'battleLosses' &&
+ lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(pending.commitment.options) &&
+ JSON.stringify(lossDecision.cards) === JSON.stringify(pending.cards) :
+ discard?.kind === 'battleResolved' && discard.event === pending.event &&
+ JSON.stringify(discard.casualties) === JSON.stringify(pending.commitment)),
+ 'The Suk Graduate rescue lost its casualty continuation.');
+ }
+}
+function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
+ const pending = g.pendingSukRescue!;
+ 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);
+ for (const group of quote.removed) {
+ player.forces[group.key] -= group.normal + group.elite;
+ if (!player.forces[group.key]) delete player.forces[group.key];
+ if (player.elites) {
+ player.elites.forces[group.key] = (player.elites.forces[group.key] ?? 0) - group.elite;
+ if (!player.elites.forces[group.key]) delete player.elites.forces[group.key];
+ }
+ }
+ player.reserves += quote.reserves.normal + quote.reserves.elite;
+ player.tanks += quote.tanks.normal + quote.tanks.elite;
+ player.battleLosses += quote.tanks.normal + quote.tanks.elite;
+ if (player.elites) {
+ player.elites.reserves += quote.reserves.elite;
+ player.elites.tanks += quote.tanks.elite;
+ }
+ g.pendingSukRescue = null;
+ 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);
+ finishWinner(g, player, pending.territory, pending.cards);
+}
function winnerDiscardSignature(pending: NonNullable) {
const { event, turn, territory, player, cards, optional } = pending;
return JSON.stringify({ event, turn, territory, player, cards, optional });
@@ -19205,7 +19344,10 @@ function applyActionInner(
else if (action.mode === 'decline') g.leaderSkills = skillRule(() => declineRevivedLeaderSkill(g.leaderSkills!, id, stringField(action.event)));
else {
requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.');
- g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), stringField(action.skill), stringField(action.leader), skillEligibleLeaders(g,id), random));
+ const skill = stringField(action.skill);
+ const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
+ requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
+ g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random));
log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'});
}
g.decision = null;
@@ -20063,6 +20205,11 @@ function applyActionInner(
requireRule(action.event === decision.event, 'This Homeworld casualty choice belongs to a different battle.');
const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')];
settleHomeworldExplosion(g, choice, false);
+ } else if (decision.kind === 'sukRescue') {
+ requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice'].includes(key)),
+ 'Choose only the current Suk Graduate rescue.');
+ const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')];
+ settleSukRescue(g, option, false);
} else if (decision.kind === 'battleLosses') {
const choice =
decision.options[
@@ -20485,6 +20632,8 @@ function applyActionInner(
if (t === 'leaderSkill') {
requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.');
const skill = stringField(action.skill), leader = stringField(action.leader);
+ const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
+ requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random));
log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' });
} else if (t === 'advisorSetup') {
diff --git a/game/leader-skills.ts b/game/leader-skills.ts
index 63f96d6e..497778c6 100644
--- a/game/leader-skills.ts
+++ b/game/leader-skills.ts
@@ -24,6 +24,8 @@ export type LeaderSkillsView = {
faceUp: boolean;
})[];
offer: LeaderSkillOffer | null;
+ /** Private reasons that this owner cannot select a card in the current offer. */
+ unavailableSkills?: Partial>;
eligibleLeaders: { id: string; name: string }[];
/** Only this player's genuine public choice is included. */
battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
diff --git a/game/reference.ts b/game/reference.ts
index e4c27e0a..d2cb8edd 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Fourteen physical skills, private assignment choices, five battle disciplines and Planetologist movement/battle effects in development tables. Other effects and combined-module acceptance remain unfinished.',
+ summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
@@ -3571,16 +3571,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
- 'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster and Smuggler have readable cards but their effects are still missing. The complete deck remains present; this development prototype does not enable public module starts.',
+ 'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
+ 'Bureaucrat, Spice Banker, Diplomat, Mentat, Rihani Decipherer, Sandmaster and Smuggler have readable cards but their effects are still missing. The complete deck remains present; 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: ['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 and Planetologist movement/Special substitution. Other eight skills, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.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 and Special weapon selection are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/game-table.tsx'] },
- { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
- { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md'] },
- { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival and Planetologist movement/cancellation checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts'] },
+ { 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. Other seven skills, foreign gholas and optional-module combinations remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.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. Other effect controls remain missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/game-table.tsx'] },
+ { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
+ { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md'] },
+ { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts'] },
],
},
...[
diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts
index 0fa6a10e..9aeb1c0c 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
const DECISIONS = {
leaderSkillVisibility: true,
leaderSkillRevival: true,
+ sukRescue: true,
choamAudit: true,
choamAuditPayment: true,
strongholdCopy: true,
diff --git a/game/suk-graduate.ts b/game/suk-graduate.ts
new file mode 100644
index 00000000..2fba11c3
--- /dev/null
+++ b/game/suk-graduate.ts
@@ -0,0 +1,101 @@
+import type { Casualties, CombatForces } from './combat';
+import type { BattleLeaderSkill } from './leader-skill-combat';
+
+export type SukGraduateSkill = { leader: string; mode: 'normal' | 'skilled' };
+export type SukForceGroup = { key: string; normal: number; elite: number };
+export type SukRescueOption = {
+ normal: number;
+ elite: number;
+ /** The saved counter never leaves its original sector. */
+ kept: { key: string; kind: 'normal' | 'elite' } | null;
+};
+export type SukRescueReceipt = {
+ event: string;
+ turn: number;
+ player: string;
+ territory: string;
+ skill: SukGraduateSkill;
+ commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[] };
+ pool: SukForceGroup[];
+ cards: string[];
+ physical: string;
+ losses: Casualties | null;
+ signature: string;
+};
+
+export function sukReceiptSignature(receipt: SukRescueReceipt): string {
+ const { signature: _signature, ...value } = receipt;
+ return JSON.stringify(value);
+}
+
+/** Freeze entitlement while the revealed plan and captured controller still exist. */
+export function sukGraduateSkill(
+ assignments: readonly BattleLeaderSkill[],
+ leader: { id: string; dead: boolean } | undefined,
+ survives: boolean,
+): SukGraduateSkill | null {
+ const skill = assignments.find((a) => a.skill === 'suk-graduate');
+ if (!skill) return null;
+ if (skill.leader === leader?.id)
+ return !leader.dead && survives && (!skill.faceUp || skill.captured)
+ ? { leader: skill.leader, mode: 'skilled' }
+ : null;
+ return skill.faceUp && !skill.captured
+ ? { leader: skill.leader, mode: 'normal' }
+ : null;
+}
+
+/** Match the engine's ordinary physical casualty allocation in location order. */
+export function sukCasualtyGroups(pool: readonly SukForceGroup[], losses: Casualties): SukForceGroup[] {
+ let normal = losses.normal, elite = losses.elite;
+ if (![normal, elite, ...pool.flatMap((g) => [g.normal, g.elite])].every((n) => Number.isSafeInteger(n) && n >= 0) ||
+ new Set(pool.map((g) => g.key)).size !== pool.length)
+ throw new Error('Invalid Suk Graduate physical casualty pool.');
+ const groups = pool.map((g) => {
+ const n = Math.min(normal, g.normal), e = Math.min(elite, g.elite);
+ normal -= n; elite -= e;
+ return { key: g.key, normal: n, elite: e };
+ });
+ if (normal || elite) throw new Error('The Suk Graduate casualties are no longer available.');
+ return groups;
+}
+
+/** Only the actual dialed counters can be rescued; the two bands never add. */
+export function sukRescueOptions(skill: SukGraduateSkill, pool: readonly SukForceGroup[], losses: Casualties): SukRescueOption[] {
+ const groups = sukCasualtyGroups(pool, losses);
+ const total = losses.normal + losses.elite;
+ if (!total) return [];
+ const options: SukRescueOption[] = [];
+ if (skill.mode === 'skilled') options.push({ normal: 0, elite: 0, kept: null });
+ const maximum = skill.mode === 'normal' ? 1 : Math.min(3, total);
+ for (let saved = 1; saved <= maximum; saved++) {
+ for (let elite = 0; elite <= Math.min(saved, losses.elite); elite++) {
+ const normal = saved - elite;
+ if (normal > losses.normal) continue;
+ if (skill.mode === 'normal') options.push({ normal, elite, kept: null });
+ else for (const group of groups)
+ for (const kind of ['normal', 'elite'] as const)
+ if (group[kind] > 0 && (kind === 'normal' ? normal : elite) > 0)
+ options.push({ normal, elite, kept: { key: group.key, kind } });
+ }
+ }
+ return options;
+}
+
+/** 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)))
+ throw new Error('Choose an available Suk Graduate rescue.');
+ const groups = sukCasualtyGroups(pool, losses);
+ return {
+ 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),
+ })),
+ reserves: {
+ normal: option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
+ elite: option.elite - (option.kept?.kind === 'elite' ? 1 : 0),
+ },
+ tanks: { normal: losses.normal - option.normal, elite: losses.elite - option.elite },
+ };
+}
diff --git a/tests/leader-skills-controls.test.ts b/tests/leader-skills-controls.test.ts
index 6898dfee..f8595716 100644
--- a/tests/leader-skills-controls.test.ts
+++ b/tests/leader-skills-controls.test.ts
@@ -129,6 +129,26 @@ void test('a private setup offer renders only its two physical cards and eligibl
assert.equal(disabledControls(disabled), 4);
});
+void test('an unavailable offered skill remains inspectable while the other card is selected', () => {
+ const reason = 'Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.';
+ const html = markup(view({
+ offer: { event: 'guarded-offer', cards: ['suk-graduate', 'warmaster'], leader: null },
+ eligibleLeaders: [leaders[0]],
+ unavailableSkills: { 'suk-graduate': reason },
+ }));
+ const blocked = html.match(/]*value="suk-graduate"[^>]*>/)?.[0];
+ const available = html.match(/]*value="warmaster"[^>]*>/)?.[0];
+ assert.ok(blocked && available);
+ assert.match(blocked, /disabled=""/);
+ assert.doesNotMatch(blocked, /checked=""/);
+ assert.match(available, /checked=""/);
+ assert.doesNotMatch(available, /disabled=""/);
+ assert.ok(html.includes(reason));
+ assert.match(html, /Inspect Suk Graduate/);
+ assert.match(html, /Assign selected skill/);
+ assert.equal(disabledControls(html), 1, 'the legal assignment remains usable');
+});
+
void test('revival asks before preview and does not offer a decline after cards are drawn', () => {
const undrawn = markup(
view({
diff --git a/tests/planetologist-battle.test.ts b/tests/planetologist-battle.test.ts
index 1f9e8e8e..72ac719a 100644
--- a/tests/planetologist-battle.test.ts
+++ b/tests/planetologist-battle.test.ts
@@ -509,6 +509,6 @@ void test('a skilled disc that dies between traitor votes cannot supply a stale
game,
'd',
{ type: 'traitorCall', call: false },
- /assigned Planetologist leader’s weapon/,
+ /dead or missing leader/,
);
});
diff --git a/tests/planetologist-movement.test.ts b/tests/planetologist-movement.test.ts
index ea449dce..01aa8e97 100644
--- a/tests/planetologist-movement.test.ts
+++ b/tests/planetologist-movement.test.ts
@@ -353,7 +353,7 @@ void test('captured or dead Planetologist gives neither side a native movement b
delete leader.capturedBy;
leader.dead = true;
assert.equal(planetologistLeader(g, 'p'), null);
- unchanged(g, move(2, 'range'), /available Planetologist/);
+ unchanged(g, move(2, 'range'), /dead or missing leader/);
});
void test('Bene Gesserit gather resolves determined mixed-source stances and preserves advisor locks', () => {
diff --git a/tests/suk-graduate-controls.test.tsx b/tests/suk-graduate-controls.test.tsx
new file mode 100644
index 00000000..c2b8711c
--- /dev/null
+++ b/tests/suk-graduate-controls.test.tsx
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { SukGraduatePanel, sukRescueLabel } from '../components/suk-graduate';
+
+void test('Suk controls explain physical destinations, offer decline only where legal, and require a choice', () => {
+ const options = [{ normal: 0, elite: 0, kept: null },
+ { normal: 2, elite: 1, kept: { key: 'wind_pass:14', kind: 'elite' as const } }];
+ const markup = renderToStaticMarkup(createElement(SukGraduatePanel, {
+ decision: { kind: 'sukRescue', player: 'a', event: 'battle-1', territory: 'wind_pass', mode: 'skilled', options },
+ act: () => { throw new Error('render must not submit'); }, busy: true,
+ }));
+ assert.match(markup, /Forces to save with Suk Graduate/);
+ assert.match(markup, /Do not rescue any forces/);
+ assert.match(markup, /keep 1 elite in sector 14; return 2 to reserves/);
+ assert.match(markup, /paid battle support is unchanged/);
+ assert.match(markup, /