Connect Suk Graduate casualty rescues and saved continuation

Implement both rescue bands with legal physical casualty routing, owner controls,
all four bot profiles and durable battle receipts. Preserve paid support, elite
identity, killed/returned leader custody and replay rejection. New Advanced
Atreides skill choices exclude Suk while the KH loss-count ruling is pending;
legacy assigned combinations retain their preflight guard. Public gates remain.

Independent review fixed stale dead trainer assignments and detached rescue
receipts. Final focused Suk/Leader Skills union: 46 passed. Typecheck, lint,
4,452 offline tests, production build and 43 HTTP tests passed on unchanged
source tree 3b76239a46d0ba874299495e64295a5eaaa8f2415c3e9a041bee944bbce50c5a.

Actual browser rescue and refresh retained counters, private hand and pending
winner card cleanup. Two four-profile Basic/Advanced smoke games completed
with JSON restoration and no rejected actions; neither exercised a Suk rescue.
After the outage report the server was healthy. All 360 pre-check rooms and
original seat/recovery records are unchanged; checks added 33 rooms. SQLite
integrity and foreign-key checks pass. No server restart or database reset.
This commit is contained in:
Quintine committed 2026-09-13 19:02:32 +10:00
1 parent 5082c81353
commit af360c836b
23 files changed
+987 -26

No files matched your search

+5
View File
@@ -48,6 +48,7 @@ import { RicheseGift } from './richese-gift';
import { Distrans } from './distrans';
import { JuiceOfSapho } from './juice-of-sapho';
import { LeaderSkillsPanel } from './leader-skills';
import { SukGraduatePanel } from './suk-graduate';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
@@ -2080,6 +2081,8 @@ export function GameTable({
? 'Position your skilled leader'
: g.decision.kind === 'leaderSkillRevival'
? 'Choose a skill for the revived leader'
: g.decision.kind === 'sukRescue'
? 'Save your battle casualties'
: g.decision.kind === 'homeworldRevivalDeployment'
? 'Revival deployment'
: g.decision.kind === 'ecazSpice'
@@ -2878,6 +2881,8 @@ export function GameTable({
</div>
))}
</>
) : g.decision.kind === 'sukRescue' ? (
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'battleLosses' ? (
<>
<p className="muted">
+9 -5
View File
@@ -85,9 +85,11 @@ export function LeaderSkillsPanel({
if (!skills) return null;
const offer = skills.offer;
const availableSkills =
offer?.cards.filter((skill) => !skills.unavailableSkills?.[skill]) ?? [];
const activeSkill =
offer?.cards.find((skill) => skill === selectedSkill) ??
offer?.cards[0] ??
availableSkills.find((skill) => skill === selectedSkill) ??
availableSkills[0] ??
null;
const activeLeader =
offer?.leader ??
@@ -110,7 +112,7 @@ export function LeaderSkillsPanel({
This development-only module currently connects the card-role strength
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
Special-card battle use. Other Leader Skill effects are
Special-card battle use and Suk Graduate force rescue. Other Leader Skill effects are
unfinished.
</p>
</div>
@@ -197,6 +199,7 @@ export function LeaderSkillsPanel({
<div className="grid gap-3">
{offer.cards.map((skill) => {
const card = leaderSkillCard(skill);
const unavailable = skills.unavailableSkills?.[skill];
return (
<div key={skill} className="space-y-2">
<label className="flex min-h-11 items-center gap-2">
@@ -205,14 +208,15 @@ export function LeaderSkillsPanel({
name="leader-skill-card"
value={skill}
checked={activeSkill === skill}
disabled={busy}
disabled={busy || !!unavailable}
onChange={() => setSelectedSkill(skill)}
/>
Keep {card.name}
</label>
{unavailable && <p className="fine">{unavailable}</p>}
<LeaderSkillCard
skill={skill}
context="Your private offer"
context={unavailable ? 'Your private offer · Unavailable' : 'Your private offer'}
/>
</div>
);
+42
View File
@@ -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<Decision, { kind: 'sukRescue' }>;
act: (action: Action) => void;
busy: boolean;
}) {
const [choice, setChoice] = useState<number | null>(null);
return <section aria-label="Suk Graduate rescue" className="space-y-3">
<h3>Suk Graduate rescue</h3>
<p>{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.'}</p>
<p className="muted">Ordinary and elite counters keep their identity. Your paid battle support is unchanged.</p>
<label className="block">Forces to save
<select aria-label="Forces to save with Suk Graduate" className="w-full" disabled={busy}
value={choice ?? ''} onChange={(event) => setChoice(event.target.value === '' ? null : Number(event.target.value))}>
<option value="">Choose your rescue</option>
{decision.options.map((option, index) => <option key={index} value={index}>{sukRescueLabel(option)}</option>)}
</select>
</label>
{choice !== null && <p>{sukRescueLabel(decision.options[choice])}</p>}
<Button disabled={busy || choice === null} onClick={() => {
if (choice !== null && !busy) act({ type: 'decision', event: decision.event, choice });
}}>Resolve rescue</Button>
</section>;
}
+3 -1
View File
@@ -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).
+11 -1
View File
@@ -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,
+37
View File
@@ -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
+16 -5
View File
@@ -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.
+2 -1
View File
@@ -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) |
+100
View File
@@ -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).
+8
View File
@@ -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,
+10 -2
View File
@@ -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)
+151 -2
View File
@@ -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<string, Record<string, Omit<EcazPoisonDiscard, 'card'>>>;
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<Record<LeaderSkillId, string>>;
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<Game['pendingWinnerDiscards']>) {
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<typeof leaderSkillCard>[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<typeof leaderSkillCard>[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' });
} else if (t === 'advisorSetup') {
+2
View File
@@ -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<Record<LeaderSkillId, string>>;
eligibleLeaders: { id: string; name: string }[];
/** Only this player's genuine public choice is included. */
battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
+8 -7
View File
@@ -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'] },
],
},
...[
+1
View File
@@ -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,
+101
View File
@@ -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 },
};
}
+20
View File
@@ -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(/<input\b[^>]*value="suk-graduate"[^>]*>/)?.[0];
const available = html.match(/<input\b[^>]*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({
+1 -1
View File
@@ -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/,
);
});
+1 -1
View File
@@ -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', () => {
+20
View File
@@ -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, /<button[^>]*disabled/);
assert.equal(sukRescueLabel({ normal: 1, elite: 0, kept: null }), 'Save 1 ordinary + 0 elite: return 1 to reserves');
});
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
function dealtSukGame(native: 'emperor' | 'atreides' | 'harkonnen', advanced: boolean): Game {
let game = createGame('SUKTEST', newPlayer('a', 'Rescuer', native), advanced);
joinGame(game, newPlayer('d', 'Opponent', 'guild'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'suk-graduate');
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === index ? 0 : 0xffffffff;
return array;
});
try { game = initializeLeaderSkillsGameForAudit(game); }
finally { mock.restoreAll(); }
assert.equal(game.leaderSkills!.offers.a.cards[0], 'suk-graduate');
return game;
}
export function advancedAtreidesSukOffer(): Game {
return dealtSukGame('atreides', true);
}
/** Genuine module setup, then a conserved, explicitly staged battle position. */
export function sukBattle({ hide = true, advanced = false, elite = false, bluff = false, captured = false, weaponKills = false, dial = 4, atreides = false } = {}): Game {
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
let game = dealtSukGame(native, advanced);
for (const id of ['a', 'd']) {
const offer = game.leaderSkills!.offers[id];
const skill = id === 'a' && advanced && atreides ? offer.cards[1] : offer.cards[0];
game = applyAction(game, id, { type: 'leaderSkill', event: offer.event,
skill, leader: id === 'a' ? `${native}-0` : 'guild-0' });
}
if (advanced && atreides) {
// Stage an old saved assignment only after setup legally returned Suk to the deck.
const assignment = game.leaderSkills!.assignments.find((a) => a.owner === 'a')!;
const sukIndex = game.leaderSkills!.deck.indexOf('suk-graduate');
assert.ok(sukIndex >= 0);
game.leaderSkills!.deck[sukIndex] = assignment.skill;
assignment.skill = 'suk-graduate';
validateLeaderSkills(game.leaderSkills!, game.players);
}
for (let i = 0; game.status === 'setup' && i < 10; i++) {
const owner = game.players.find((p) => p.traitorChoices.length);
assert.ok(owner);
game = applyAction(game, owner.id, { type: 'traitor', leader: owner.traitorChoices[0] });
}
assert.equal(game.status, 'playing');
if (captured) {
const [own, other] = game.leaderSkills!.assignments;
[own.skill, other.skill] = [other.skill, own.skill];
game.players[1].leaders[0].capturedBy = 'a';
}
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.forces = { 'arrakeen:10': 5 }; player.reserves = 15; player.tanks = 0;
player.spice = 20; player.battleLosses = 0;
if (player.elites) player.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 };
}
if (elite) game.players[0].elites = { forces: { 'arrakeen:10': 2 }, reserves: 3, tanks: 0, revived: 0 };
const cardIndex = game.deck.findIndex((card) => card.kind === 'worthless');
assert.ok(cardIndex >= 0);
const [played] = game.deck.splice(cardIndex, 1);
game.players[0].hand.push(played);
let enemyWeapon: string | undefined;
if (weaponKills) {
const index = game.deck.findIndex((card) => card.kind === 'projectile');
assert.ok(index >= 0);
const [weapon] = game.deck.splice(index, 1);
game.players[1].hand.push(weapon); enemyWeapon = weapon.id;
}
Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null });
game = applyAction(game, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'd' });
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event,
hide: decision.player === 'a' ? hide : false });
}
while (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0,
leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id });
game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon });
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
return game;
}
export function resolveSuk(game: Game): Game {
return applyAction(game, 'd', { type: 'traitorCall', call: false });
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { resolveSuk, sukBattle } from './suk-graduate-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restart preserves Suk rescue and concurrent submissions save physical counters exactly once', async () => {
const f = unitStore();
const sqlite: DatabaseSync = f.sqlite;
try {
const created = await f.rooms.createRoom('Rescuer', 'emperor', false, []);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Opponent', 'guild');
const tokens = [created.token!, joined.token!];
const old = await Promise.all(tokens.map((token) => f.rooms.authenticate(code, token)));
for (const [i, auth] of old.entries())
sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?').run(i ? 'd' : 'a', code, auth.playerId);
const auths = await Promise.all(tokens.map((token) => f.restart().authenticate(code, token)));
const state = resolveSuk(sukBattle());
state.code = code;
state.version = (await f.rooms.readRoom(code)).version;
sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(state), state.version, code);
const own = await f.restart().readSeatView(code, auths[0]);
const other = await f.restart().readSeatView(code, auths[1]);
assert.equal(own.decision?.kind, 'sukRescue');
assert.deepEqual(own.decision, other.decision, 'public casualty routing reveals no private hand');
assert.equal('pendingSukRescue' in other, false);
assert.equal(other.players.find((p) => p.id === 'a')!.hand, undefined);
assert.deepEqual(await f.restart().readRoom(code), state);
const decision = own.decision;
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
const choice = decision.options.findIndex((o) => o.normal + o.elite === 3);
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 action = { type: 'decision' as const, event: decision.event, choice };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], state.version, action, clock),
f.restart().act(code, auths[0], state.version, action, 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);
const after = await f.restart().readRoom(code);
assert.equal(after.version, state.version + 1);
assert.equal(after.pendingSukRescue, null);
assert.equal(after.players[0].reserves, 17);
assert.equal(after.players[0].tanks, 1);
assert.equal(after.players[0].forces['arrakeen:10'], 2);
await assert.rejects(() => f.restart().act(code, auths[0], state.version, action, clock));
assert.deepEqual(await f.restart().readRoom(code), after);
} finally { sqlite.close(); }
});
+286
View File
@@ -0,0 +1,286 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { sukGraduateSkill, sukRescueOptions, quoteSukRescue } from '../game/suk-graduate';
import { validateLeaderSkills } from '../game/leader-skills';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advancedAtreidesSukOffer, sukBattle, resolveSuk } from './suk-graduate-fixture';
function conserved(game: Game) {
for (const player of game.players) {
assert.equal(Object.values(player.forces).reduce((a, b) => a + b, 0) + player.reserves + player.tanks, 20);
if (player.elites) assert.equal(Object.values(player.elites.forces).reduce((a, b) => a + b, 0) + player.elites.reserves + player.elites.tanks, 5);
}
validateLeaderSkills(game.leaderSkills!, game.players);
}
function unchanged(game: Game, player: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, player, action));
assert.equal(JSON.stringify(game), before);
}
function chooseRescue(game: Game, count: number, keepElite = false): Game {
const d = game.decision;
assert.equal(d?.kind, 'sukRescue');
if (d?.kind !== 'sukRescue') throw new Error('Missing rescue');
const choice = d.options.findIndex((o) => o.normal + o.elite === count && (!keepElite || o.kept?.kind === 'elite'));
assert.ok(choice >= 0);
return applyAction(game, 'a', { type: 'decision', event: d.event, choice });
}
void test('Suk entitlement distinguishes public, selected, bluff, killed and captured leaders', () => {
const assignment = { skill: 'suk-graduate' as const, leader: 'skilled', faceUp: true, captured: false };
assert.equal(sukGraduateSkill([assignment], { id: 'other', dead: false }, false)?.mode, 'normal');
assert.equal(sukGraduateSkill([{ ...assignment, faceUp: false }], { id: 'other', dead: false }, true), null);
assert.equal(sukGraduateSkill([{ ...assignment, faceUp: false }], { id: 'skilled', dead: false }, false), null);
assert.equal(sukGraduateSkill([{ ...assignment, captured: true }], { id: 'other', dead: false }, true), null);
assert.equal(sukGraduateSkill([{ ...assignment, captured: true }], { id: 'skilled', dead: false }, true)?.mode, 'skilled');
});
void test('new Advanced Atreides setup and revival keep the physical Suk offer private but reject its assignment immutably', () => {
const game = advancedAtreidesSukOffer();
const ownerView = viewGame(game, 'a');
const otherView = viewGame(game, 'd');
const offer = ownerView.leaderSkills!.offer!;
assert.equal(offer.cards.length, 2);
assert.ok(offer.cards.includes('suk-graduate'));
assert.match(
ownerView.leaderSkills!.unavailableSkills?.['suk-graduate'] ?? '',
/Kwisatz Haderach loss-count ruling is pending/,
);
assert.equal(otherView.leaderSkills?.unavailableSkills, undefined);
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const blockedAction: Action = {
type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: 'atreides-0',
};
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(game), before);
validateLeaderSkills(game.leaderSkills!, game.players);
const assigned = applyAction(game, 'a', { ...blockedAction, skill: available });
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
assert.ok(assigned.leaderSkills!.deck.includes('suk-graduate'));
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
const revival = structuredClone(game);
revival.status = 'playing';
revival.setupStage = undefined;
revival.leaderSkills!.offers.a.leader = 'atreides-0';
revival.decision = { kind: 'leaderSkillRevival', player: 'a', event: offer.event };
const revivalBefore = JSON.stringify(revival);
assert.throws(() => applyAction(revival, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(revival), revivalBefore);
validateLeaderSkills(revival.leaderSkills!, revival.players);
});
void test('all four Advanced Atreides bots choose the available other skill', () => {
for (const difficulty of DIFFICULTIES) {
const game = advancedAtreidesSukOffer();
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const offer = view.leaderSkills!.offer!;
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const action = botActions(view)[0];
assert.equal(action?.type, 'leaderSkill', difficulty);
assert.equal(action?.skill, available, difficulty);
const assigned = applyAction(game, 'a', action);
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
}
});
void test('Suk redirects only selected physical casualties and keeps the original sector', () => {
const pool = [{ key: 'wind_pass:14', normal: 2, elite: 1 }, { key: 'wind_pass:15', normal: 1, elite: 1 }];
const losses = { normal: 2, elite: 2, paidNormal: 2, paidElite: 2 };
const skill = { leader: 'skilled', mode: 'skilled' as const };
const options = sukRescueOptions(skill, pool, losses);
assert.ok(options.some((o) => o.normal + o.elite === 0));
assert.ok(options.every((o) => o.normal + o.elite <= 3));
assert.ok(options.every((o) => o.kept?.key !== 'wind_pass:15' || o.kept.kind === 'elite'));
for (const option of options) {
const q = quoteSukRescue(skill, pool, losses, option);
assert.equal(q.removed.reduce((n, g) => n + g.normal + g.elite, 0), 4 - (option.kept ? 1 : 0));
assert.equal(q.reserves.normal + q.reserves.elite + q.tanks.normal + q.tanks.elite + (option.kept ? 1 : 0), 4);
}
const normal = sukRescueOptions({ ...skill, mode: 'normal' }, pool, losses);
assert.equal(normal.length, 2);
assert.ok(normal.every((o) => o.normal + o.elite === 1 && !o.kept));
assert.throws(() => quoteSukRescue(skill, pool, losses, { normal: 4, elite: 0, kept: { key: 'wind_pass:14', kind: 'normal' } }));
});
void test('Basic normal rescue is automatic; concealed bluff grants no rescue', () => {
const normal = resolveSuk(sukBattle({ hide: false }));
assert.equal(normal.pendingSukRescue, null);
assert.equal(normal.players[0].tanks, 3);
assert.equal(normal.players[0].reserves, 16);
assert.equal(normal.players[0].forces['arrakeen:10'], 1);
conserved(normal);
const bluff = resolveSuk(sukBattle({ bluff: true }));
assert.equal(bluff.pendingSukRescue, undefined);
assert.equal(bluff.players[0].tanks, 4);
assert.equal(bluff.players[0].reserves, 15);
conserved(bluff);
});
void test('skilled rescue saves up to three without stacking, and survives JSON with strict ownership', () => {
const pending = resolveSuk(sukBattle());
assert.equal(pending.decision?.kind, 'sukRescue');
assert.equal(pending.players[0].tanks, 0, 'raw casualties remain pending');
assert.equal(pending.players[0].forces['arrakeen:10'], 5);
const restored = JSON.parse(JSON.stringify(pending)) as Game;
assert.deepEqual(normalizeAutomaticGame(restored), restored);
assert.deepEqual(viewGame(restored, 'a').decision, viewGame(pending, 'a').decision);
unchanged(restored, 'd', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 1 });
unchanged(restored, 'a', { type: 'decision', event: 'stale', choice: 1 });
unchanged(restored, 'a', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 100 });
const done = chooseRescue(restored, 3);
assert.equal(done.players[0].tanks, 1);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].forces['arrakeen:10'], 2);
assert.equal(done.players[0].spice, 20);
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
assert.equal(done.pendingSukRescue, null);
conserved(done);
unchanged(done, 'a', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 3 });
const declined = chooseRescue(pending, 0);
assert.equal(declined.players[0].tanks, 4);
assert.equal(declined.players[0].reserves, 15);
assert.equal(declined.players[0].forces['arrakeen:10'], 1);
conserved(declined);
});
void test('corrupt Suk receipt, force pool, options or missing continuation fail without mutation', () => {
const good = resolveSuk(sukBattle());
for (const corrupt of [
(g: Game) => { g.pendingSukRescue = null; },
(g: Game) => { g.players[0].reserves++; },
(g: Game) => { g.pendingSukRescue!.skill.mode = 'normal'; },
(g: Game) => { g.pendingSukRescue!.losses!.normal--; },
(g: Game) => { if (g.decision?.kind === 'sukRescue') g.decision.options.pop(); },
(g: Game) => { g.decision = null; },
(g: Game) => {
const assignment = g.leaderSkills!.assignments.find((a) => a.skill === 'suk-graduate')!;
assignment.skill = g.leaderSkills!.deck[0];
g.leaderSkills!.deck[0] = 'suk-graduate';
},
]) {
const bad = structuredClone(good); corrupt(bad);
const before = JSON.stringify(bad);
assert.throws(() => viewGame(bad, 'a'));
assert.throws(() => normalizeAutomaticGame(bad));
unchanged(bad, 'a', { type: 'advanceBots' });
assert.equal(JSON.stringify(bad), before);
}
});
void test('a stale dead face-up trainer cannot rescue casualties or appear as an active skill', () => {
const game = sukBattle({ hide: false });
game.players[0].leaders[0].dead = true;
const before = JSON.stringify(game);
assert.throws(() => viewGame(game, 'a'), /dead or missing leader/);
assert.throws(() => normalizeAutomaticGame(game), /dead or missing leader/);
unchanged(game, 'd', { type: 'traitorCall', call: false });
assert.equal(JSON.stringify(game), before);
});
void test('Advanced rescue preserves paid support, elite identity and all four legal AI paths', () => {
let pending = resolveSuk(sukBattle({ advanced: true, elite: true }));
if (pending.decision?.kind === 'battleLosses') {
const choice = pending.decision.options.findIndex((o) => o.elite > 0 && o.normal > 0);
assert.ok(choice >= 0);
pending = applyAction(pending, 'a', { type: 'decision', choice });
}
assert.equal(pending.decision?.kind, 'sukRescue');
const done = chooseRescue(pending, 3, true);
assert.equal(done.players[0].spice, 17, 'support remains paid for raw dial');
assert.equal(done.players[0].tanks, 0);
assert.equal(done.players[0].elites?.forces['arrakeen:10'], 2);
assert.equal(done.players[0].elites?.reserves, 3);
conserved(done);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'a');
view.players.find((p) => p.id === 'a')!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, difficulty);
const result = applyAction(pending, 'a', actions[0]);
assert.equal(result.pendingSukRescue, null);
assert.equal(result.players[0].tanks, 0);
conserved(result);
}
});
void test('used captive returns with its skill before the captor resolves the frozen lower-band rescue', () => {
const pending = resolveSuk(sukBattle({ captured: true }));
assert.equal(pending.decision?.kind, 'sukRescue');
assert.equal(pending.pendingSukRescue?.skill.mode, 'skilled');
assert.equal(pending.players[1].leaders[0].capturedBy, undefined);
assert.equal(pending.leaderSkills!.assignments.find((a) => a.skill === 'suk-graduate')?.owner, 'd');
const done = chooseRescue(JSON.parse(JSON.stringify(pending)) as Game, 3);
assert.equal(done.players[0].tanks, 1);
assert.equal(done.players[0].reserves, 17);
conserved(done);
});
void test('a killed skilled winner gets no rescue, but a separate face-up trainer still rescues', () => {
const killed = resolveSuk(sukBattle({ weaponKills: true }));
assert.equal(killed.lastBattleContext?.winner, 'a');
assert.equal(killed.players[0].leaders[0].dead, true);
assert.equal(killed.pendingSukRescue, undefined);
assert.equal(killed.players[0].tanks, 4);
conserved(killed);
const otherKilled = resolveSuk(sukBattle({ hide: false, weaponKills: true }));
assert.equal(otherKilled.players[0].leaders[1].dead, true);
assert.equal(otherKilled.players[0].leaders[0].dead, false);
assert.equal(otherKilled.players[0].tanks, 3);
assert.equal(otherKilled.players[0].reserves, 16);
conserved(otherKilled);
});
void test('zero dial and a traitor victory have no Suk loss or optional window', () => {
const zero = resolveSuk(sukBattle({ dial: 0 }));
assert.equal(zero.pendingSukRescue, undefined);
assert.equal(zero.players[0].tanks, 0);
const state = sukBattle();
state.battle!.traitorCalls.a = undefined as unknown as boolean;
state.players[0].traitors = [state.battle!.plans.d.leader!];
const called = applyAction(state, 'a', { type: 'traitorCall', call: true });
const win = resolveSuk(called);
assert.equal(win.lastBattleContext?.result, 'traitor');
assert.equal(win.pendingSukRescue, undefined);
assert.equal(win.players[0].tanks, 0);
conserved(win);
});
void test('Advanced normal rescue offers the actual casualty types and returns the chosen elite to reserves', () => {
let game = resolveSuk(sukBattle({ hide: false, advanced: true, elite: true }));
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex((o) => o.normal === 2 && o.elite === 1);
assert.ok(choice >= 0);
game = applyAction(game, 'a', { type: 'decision', choice });
}
const decision = game.decision;
assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
assert.equal(decision.mode, 'normal');
assert.ok(decision.options.every((o) => o.normal + o.elite === 1 && !o.kept));
const choice = decision.options.findIndex((o) => o.elite === 1);
game = applyAction(game, 'a', { type: 'decision', event: decision.event, choice });
assert.equal(game.players[0].elites?.reserves, 4);
assert.equal(game.players[0].elites?.tanks, 0);
assert.equal(game.players[0].elites?.forces['arrakeen:10'], 1);
assert.equal(game.players[0].tanks, 2);
conserved(game);
});
void test('Advanced Atreides pending ruling rejects before the final traitor vote; Basic rescue remains playable', () => {
const blocked = sukBattle({ advanced: true, atreides: true });
const before = JSON.stringify(blocked);
assert.throws(() => resolveSuk(blocked), /Kwisatz Haderach loss-count ruling/);
assert.equal(JSON.stringify(blocked), before);
const basic = resolveSuk(sukBattle({ atreides: true }));
assert.equal(basic.decision?.kind, 'sukRescue');
conserved(chooseRescue(basic, 3));
});