diff --git a/components/game-table.tsx b/components/game-table.tsx
index a0ae2310..22070e13 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -47,6 +47,7 @@ import { RicheseSpecialKarama } from './richese-special-karama';
import { RicheseGift } from './richese-gift';
import { Distrans } from './distrans';
import { JuiceOfSapho } from './juice-of-sapho';
+import { LeaderSkillsPanel } from './leader-skills';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
@@ -202,7 +203,11 @@ export function GameTable({
.join(', ');
const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10;
const setupStatus =
- setupStage === 'prediction'
+ setupStage === 'leaderSkills'
+ ? g.leaderSkills?.offer
+ ? 'Keep one of your two Leader Skill cards and assign it to an eligible leader. Traitor selection follows when every faction has chosen.'
+ : `Your skill is assigned. Waiting for ${setupPendingNames || 'the remaining players'} to choose.`
+ : setupStage === 'prediction'
? setupSisterhood?.id === me.id
? 'Seal your secret prediction before traitors or treachery cards are dealt.'
: `Waiting for ${setupSisterhood?.name ?? 'Bene Gesserit'} to seal the secret prediction.`
@@ -217,13 +222,13 @@ export function GameTable({
? setupFremen.id === me.id
? g.advanced && setupSisterhood
? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.'
- : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
+ : g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
: `Waiting for ${setupFremen.name} to place the Fremen starting forces.`
: g.advanced && setupSisterhood && !setupSisterhood.advisorSetup
? setupSisterhood.id === me.id
? 'Choose a printed board territory and sector for your starting advisor.'
: `Waiting for ${setupSisterhood.name} to place the starting advisor.`
- : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.'
+ : g.leaderSkills ? 'Starting placements are finishing. The Storm phase opens automatically.' : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.'
: 'Complete your starting choices. Play begins when everyone is ready.';
const automaticEvents = g.log.flatMap((entry) =>
entry.automatic
@@ -1448,6 +1453,7 @@ export function GameTable({
+
@@ -1556,7 +1562,9 @@ export function GameTable({
) : g.status === 'setup' && !g.decision ? (
<>
- {setupStage === 'prediction'
+ {setupStage === 'leaderSkills'
+ ? 'Assign your Leader Skill'
+ : setupStage === 'prediction'
? 'Seal the prediction'
: setupStage === 'traitors'
? 'Choose your traitor'
@@ -1569,8 +1577,9 @@ export function GameTable({
{setupStage && (
- Setup order: prediction, traitors, starting forces, then the
- automatic treachery-card deal and Storm phase.
+ {g.leaderSkills
+ ? 'Setup order: prediction, starting Treachery Cards, Leader Skills, traitors, starting forces, then Storm.'
+ : 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
)}
{(!setupStage || setupStage === 'forces') &&
@@ -2058,6 +2067,10 @@ export function GameTable({
? 'Caladan victory reinforcement'
: g.decision.kind === 'grummanCollection'
? 'Grumman Collection'
+ : g.decision.kind === 'leaderSkillVisibility'
+ ? 'Position your skilled leader'
+ : g.decision.kind === 'leaderSkillRevival'
+ ? 'Choose a skill for the revived leader'
: g.decision.kind === 'homeworldRevivalDeployment'
? 'Revival deployment'
: g.decision.kind === 'ecazSpice'
@@ -2339,6 +2352,8 @@ export function GameTable({
act={act}
busy={busy}
/>
+ ) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
+ Use the Leader Skills controls above to make your choice.
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
) : g.decision.kind === 'caladanReinforcement' ? (
@@ -5128,7 +5143,7 @@ export function GameTable({
{!me.hand?.length && (
{setupStage
- ? 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
+ ? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
: g.status === 'lobby'
? 'Your opening cards are dealt after starting force placement.'
: 'Your Treachery hand is empty.'}
diff --git a/components/leader-skills.tsx b/components/leader-skills.tsx
new file mode 100644
index 00000000..9f1108d5
--- /dev/null
+++ b/components/leader-skills.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useState } from 'react';
+import type { Action } from '@/game/engine';
+import { leaderSkillCard, type LeaderSkillId } from '@/game/leader-skill-cards';
+import type { LeaderSkillsView } from '@/game/leader-skills';
+import { Button } from '@/components/ui/button';
+
+type NamedIdentity = Readonly<{ id: string; name: string }>;
+
+export type LeaderSkillsPanelProps = {
+ skills: LeaderSkillsView | null;
+ leaders: readonly NamedIdentity[];
+ players: readonly NamedIdentity[];
+ act: (action: Action) => void;
+ busy: boolean;
+};
+
+function LeaderSkillCard({
+ skill,
+ context,
+}: {
+ skill: LeaderSkillId;
+ context?: string;
+}) {
+ const card = leaderSkillCard(skill);
+ return (
+
+
+ Leader Skill
+ {card.name}
+ {context && (
+
+ {context}
+
+ )}
+
+
+
+ Inspect {card.name}
+
+
+
+ Normal effect
+ {card.normal.map((line) => (
+
+ {line}
+
+ ))}
+
+
+ Skilled leader in battle
+ {card.battle.map((line) => (
+
+ {line}
+
+ ))}
+
+
+
+
+ );
+}
+
+export function LeaderSkillsPanel({
+ skills,
+ leaders,
+ players,
+ act,
+ busy,
+}: LeaderSkillsPanelProps) {
+ const [selectedSkill, setSelectedSkill] = useState(
+ null,
+ );
+ const [selectedLeader, setSelectedLeader] = useState(null);
+ if (!skills) return null;
+
+ const offer = skills.offer;
+ const activeSkill =
+ offer?.cards.find((skill) => skill === selectedSkill) ??
+ offer?.cards[0] ??
+ null;
+ const activeLeader =
+ offer?.leader ??
+ skills.eligibleLeaders.find((leader) => leader.id === selectedLeader)?.id ??
+ skills.eligibleLeaders[0]?.id ??
+ null;
+ const leaderName = (id: string) =>
+ leaders.find((leader) => leader.id === id)?.name ?? id;
+ const playerName = (id: string) =>
+ players.find((player) => player.id === id)?.name ?? id;
+
+ return (
+
+
+
Leader Skills
+
+ 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. Other Leader Skill effects are
+ unfinished.
+
+
+
+ {!!skills.assignments.length && (
+
+
Public assignments
+
+ {skills.assignments.map((assignment) => (
+
+ ))}
+
+
+ )}
+
+ {offer && offer.cards.length === 0 && offer.leader && (
+
+
+
Skill for {leaderName(offer.leader)}
+
+ Decide whether to draw before seeing either private card. Drawing
+ two requires you to keep one.
+
+
+
+
+ act({ type: 'leaderSkill', event: offer.event, mode: 'draw' })
+ }
+ >
+ Draw two skills
+
+
+ act({
+ type: 'leaderSkill',
+ event: offer.event,
+ mode: 'decline',
+ })
+ }
+ >
+ Continue without a skill
+
+
+
+ )}
+
+ {offer && offer.cards.length > 0 && (
+
+ )}
+
+ {skills.battleChoice && (
+
+
+
{leaderName(skills.battleChoice.leader)} in this battle
+
+ Choose publicly whether the skilled leader remains face up or
+ moves behind your shield before selecting a Battle Plan leader.
+
+
+
+
+ act({
+ type: 'leaderSkillVisibility',
+ event: skills.battleChoice!.event,
+ hide: false,
+ })
+ }
+ >
+ Keep leader face up
+
+
+ act({
+ type: 'leaderSkillVisibility',
+ event: skills.battleChoice!.event,
+ hide: true,
+ })
+ }
+ >
+ Move leader behind shield
+
+
+
+ )}
+
+ );
+}
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md
index f3c9575a..e465c213 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -1,5 +1,13 @@
# Component inventory and verification ledger
+**Leader Skills update, 13 September 2026:** all fourteen unique physical cards
+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;
+nine 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).
+
**Guild Nexus Secret Ally update, 10 September 2026:** the existing card now supplies an explicit paid shipment choice, including physical cross/return selection and typed reserve or world-to-world sources. The holder keeps its own faction identity, forces and ordinary movement. No component is duplicated; existing full-card inspection remains shared. [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records the supported routes and verification. Homeworld-return interpretation, concealed transport, Betrayal and complete module acceptance remain unfinished.
**Guild Nexus Cunning update, 10 September 2026:** the existing native card now offers an immediate second paid shipment after the original combined turn. No new forces, markers or cards are created; the normal physical reserve, cross/return and Homeworld controls retain their source counts and price. A separate skip choice and unused Hajr control preserve actual movement history. [NEXUS_GUILD_CUNNING_RUNTIME.md](NEXUS_GUILD_CUNNING_RUNTIME.md) records runtime, interruption and recovery evidence. Guild Secret Ally, Betrayal, physical Ornithopter combinations and full module acceptance remain unfinished.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 1a76abff..b4a0037f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -10,14 +10,21 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
private controls, all four AI profiles and saved continuation use the same
server option. Other Sapho timing questions remain pending. Independent
review and focused checks pass; broad results are in the checkpoint commit.
-- The [Leader Skills source contract](LEADER_SKILLS_RULES.md) records all fourteen
- cards, setup/lifecycle and printed clarifications for the next prototype batch.
- Runtime, controls and AI remain **Missing**; unresolved edge cases stay explicit.
+- 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 other
+ nine effects 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,
the database integrity check passed with 291 rooms, and all 218 rooms in the
preceding handover baseline were unchanged. The saved QA seat restored its
same private hand and pending Storm decision after refresh. A new private
backup protects all 291 rooms. No server restart or database reset was needed.
+- A second reported outage later stopped the server. All 292 rooms survived
+ integrity and preservation checks. A fresh backup preceded the necessary
+ server start; the Leader Skills QA seat restored its same private hand,
+ assignments and pending Storm choice. No database reset was performed.
- The current [Discovery prototype](DISCOVERY_PROTOTYPE.md) adds unambiguous
Jacurutu battle income, sole-occupant Ecological Testing Station adjustment and
Shrine card conversion. They connect engine effects, private controls, legal
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index cdfc46be..4ffbab0e 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,44 @@
# Dune implementation status
+## 13 September 2026 — Leader Skills setup, five disciplines and skilled capture
+
+The [first connected prototype](LEADER_SKILLS_RUNTIME.md) adds all fourteen
+physical cards and readable faces, source-ordered private setup, public battle
+posture, five card-role strength modifiers, death return and own-leader revival
+through controls, all four AI profiles and saved actions. The other nine effects
+remain missing; combined modules and public starts remain gated.
+
+The user's requested [capture research](LEADER_SKILLS_CAPTURE.md) resolved
+publicity through the known card's required movement with the leader. Harkonnen
+gets only its lower battle effect; surviving return and death retain physical
+custody. This is a component-based source inference. Captured replacement
+entitlement stays separately unresolved and explicitly guarded.
+
+Independent review found and fixed a Voice/mandatory-leader deadlock and invalid
+simultaneous assignment/offer custody. Focused capture review found no remaining
+blocker. The focused union passed **66 tests**, including genuine setup, scoring,
+capture and SQLite restart/concurrent setup and revival selections. Three sample
+games finished; their exact scope and source fingerprints are recorded separately.
+Browser setup, card inspection, AI assignment and refresh were exercised in new
+room `REF6YHN7`; narrow sidebar cards were made readable. All 291 opening rooms
+were backed up before QA. Final preservation and broad check results are recorded
+in the checkpoint commit and private report.
+
+The first broad run passed types/lint and 4,410 of 4,411 offline tests. Its one
+failure was an existing Discovery privacy test treating `treachery-32` as a
+substring match for hidden `treachery-3`. The exact physical pair now forms a
+deterministic scenario and assertions compare complete JSON string identities;
+the opponent's own card remains visible. Independent review and all six
+Discovery runtime tests pass. The broad run was repeated after this correction.
+During the follow-up, another user-reported outage stopped the server. All 292
+games survived; a fresh backup and necessary server start restored the same
+private prototype seat and pending Storm choice.
+
+The first broad run also exposed a pre-existing random Discovery test failure:
+the hidden identifier `treachery-3` matched the viewer's distinct `treachery-32`
+through a substring assertion. The regression now stages both physical cards
+and checks their complete identifiers; no production privacy behavior changed.
+
## 13 September 2026 — Sapho first after completed movement turns
Extends the existing clean-boundary movement contract to let Sapho's holder
diff --git a/docs/LEADER_SKILLS_CAPTURE.md b/docs/LEADER_SKILLS_CAPTURE.md
new file mode 100644
index 00000000..6c416235
--- /dev/null
+++ b/docs/LEADER_SKILLS_CAPTURE.md
@@ -0,0 +1,85 @@
+# Leader Skills and Harkonnen capture
+
+Updated 13 September 2026. This note resolves the public-state boundary for a
+skilled leader captured by Harkonnen. It records a source interpretation for
+implementation; it does not claim the Leader Skills module is complete.
+
+## Strongest source-supported answer
+
+A Harkonnen capture is not universally secret in the published base rules.
+The explicit face-down procedure applies to the branch where Harkonnen kills
+the randomly selected leader for 2 spice: the leader is placed “face down into
+the Tleilaxu Tanks.” The alternative branch says Harkonnen keeps the leader for
+one later battle, but does not instruct players to turn that retained leader
+face down or conceal its identity. [GF9 base rulebook, p. 17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
+
+The official FAQ's restricted-view answer is likewise framed around the
+leader Harkonnen chose to kill and place face down in the Tanks. It authorizes
+Harkonnen, the leader's original owner and Tleilaxu to inspect that dead disc;
+it does not establish a general secrecy rule for every retained captive.
+[GF9 FAQ, p. 6](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
+
+Leader Skills adds a later, component-specific rule. A faction's one chosen
+skill and its assigned leader begin face up together, and “If a skilled leader
+is captured, the skill goes with them.” The expansion Q&A then says Harkonnen
+may use only that leader's battle skill, and only by using the captured leader
+in battle. [GF9 *CHOAM & Richese* rulebook, pp. 9, 12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
+
+The rules never literally instruct Harkonnen to announce the captured skilled
+leader's name. The physical procedure nevertheless makes that identity public
+or necessarily inferable. The assignment was already public, the unique skill
+card must travel with the captured leader, and the card must leave play and be
+shuffled into the Leader Skill deck if that leader dies. Concealing the leader
+while exposing truthful custody of its unique public card is impossible without
+adding a proxy or decoy component that the rules do not provide.
+
+The source-supported implementation boundary is therefore:
+
+- capturing the skilled leader publicly transfers that leader's attached skill
+ to Harkonnen custody, which identifies that one captured leader;
+- Harkonnen loses the normal/top effect and may use only the battle/lower effect
+ when actually using that captured leader in a battle;
+- an unskilled retained captive keeps the ordinary base-game privacy treatment;
+- a captive killed for 2 spice still follows the FAQ's face-down Tanks access
+ rule, although killing the skilled captive also returns its unique public
+ skill card to the deck and therefore makes that particular identity
+ inferable; and
+- if a used skilled captive survives, the leader and attached skill return to
+ the original faction together.
+
+This is a necessary inference from the published component movements, rather
+than an express publisher sentence saying “reveal the captured skilled
+leader.” It is narrower than a rule that all Harkonnen captures are public.
+
+## Source record
+
+The [Future Pastimes expansion page](https://futurepastimes.com/dune-choam-richese)
+links the GF9 expansion rules and identifies the official design/publisher
+lineage. A fresh search of that designer site, the GF9 rulebook and FAQ, and
+Jack Reda's official Leader Skills walkthrough found no contrary instruction
+that the attached skill should be concealed or detached during capture.
+
+The persistent local source archive is
+`/home/quintine/.local/share/dune/sources`:
+
+- `dune-rulebook-gf9-mirror.pdf` and its `.txt` extraction preserve the
+ publisher-authored 2019 base rulebook. SHA-256:
+ `02660bcce906d13dde65e0eb7599b49543af4d76e8cf6c7617bc19117887933c`.
+ The readable mirror was retrieved from
+ [1jour-1jeu's document archive](https://cdn.1j1ju.com/medias/37/57/71-dune-rulebook.pdf).
+- `choam-richese-rulebook-en.pdf` and its `.txt` extraction preserve the
+ publisher-authored expansion rules.
+- `leader-skills-designer-video-auto-transcript.txt` preserves Jack Reda's
+ card walkthrough. The walkthrough explains the skill bands but does not add
+ a capture-privacy exception.
+
+The GF9 URLs now redirect or reject some direct clients, but their indexed
+publisher text and the byte-stable local copies agree on the quoted rules.
+
+## Remaining separate ruling
+
+Capture does not settle whether the original faction counts as having no skill
+card while its only card is attached to a captive, and therefore whether a
+later own-leader revival may create a replacement assignment. That entitlement
+should remain gated until separately ruled. It does not affect the custody and
+visibility conclusion above.
diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md
index 22d9b3ef..9969343c 100644
--- a/docs/LEADER_SKILLS_RULES.md
+++ b/docs/LEADER_SKILLS_RULES.md
@@ -1,8 +1,9 @@
# Leader Skills source contract
Updated 13 September 2026. This is a primary-source contract for the optional
-Leader Skills module from *CHOAM & Richese*. It does not record a runtime
-prototype, enable the module, or certify its combinations.
+Leader Skills module from *CHOAM & Richese*. The later [first runtime prototype](LEADER_SKILLS_RUNTIME.md)
+records connected behavior and remaining gaps. This contract does not enable
+the module or certify its combinations.
## Sources and local evidence
@@ -58,7 +59,7 @@ also alternatives rather than a cumulative four-force rescue
| 2 | **Spice Banker** | Once in each phase, when another player pays at least 4 spice to the Spice Bank, place 1 spice in front of this faction's shield for collection in the Mentat Pause. | Commit 1–3 spice with the Battle Plan and add the same amount to this leader's strength. This payment is separate from spice used to make forces full strength. |
| 3 | **Diplomat** | A single Worthless card in the owner's Battle Plan may stand in for the defense used by the opponent in that battle, provided the owner played no defense; discard the Worthless card afterward. | If this faction loses, retreat up to this leader's strength in **undialed** forces to one empty adjacent territory that is not a stronghold. |
| 4 | **Mentat** | Before Battle Plans, name a specific weapon and ask whether the opponent holds it. They reveal that card if held; otherwise they reveal a different Treachery Card. Revealing the named weapon does not commit it to the plan. | Add 2 to this leader's strength. |
-| 5 | **Suk Graduate** | After winning a battle in which the winner loses forces, return 1 such force to reserves instead of the Tleilaxu Tanks. | After winning and losing forces, save up to 3 from the Tanks: leave 1 in the battle territory and return the remainder to reserves. |
+| 5 | **Suk Graduate** | After winning a battle in which the winner loses forces, return 1 such force to reserves instead of the Tleilaxu Tanks. | After winning and losing forces, prevent up to 3 from going to the Tanks: leave 1 in the battle territory and return the remainder to reserves. |
| 6 | **Rihani Decipherer** | After winning a battle, secretly inspect 2 random cards from the Traitor Deck, then reshuffle them into that deck. | After winning, draw 2 Traitor Cards. Keep 1 by revealing an unused Traitor Card already held, then shuffle that revealed card and the unkept drawn card into the deck. |
| 7 | **Sandmaster** | When moving any forces into or through a territory containing spice, the faction may immediately collect 1 spice there, once for each territory traversed. | After winning a battle in a territory that contains spice, add 3 spice to that territory. |
| 8 | **Smuggler** | When shipping forces from off-planet to an empty territory, one additional force may accompany that shipment for free. | When Battle Plans are revealed, collect spice from the battle territory equal to this leader's strength, limited by the spice present. The general survival rule still applies. |
@@ -118,6 +119,11 @@ Prescience.
## Capture and official clarifications
+The user's requested fresh search resolves the skilled capture's public state
+through its unique, publicly assigned card's required movement. See the
+[capture interpretation and its precise limits](LEADER_SKILLS_CAPTURE.md).
+Captured-card replacement entitlement remains a separate unresolved question.
+
When Harkonnen captures a skilled leader, the skill card travels with that
leader. The captor may use only the lower battle effect and only when using
that captured leader in battle. Thus a captured Spice Banker cannot collect
@@ -191,6 +197,5 @@ Keep the six unresolved boundaries above gated or represented as explicit
pending decisions. In particular, do not pay Smuggler spice irreversibly at
plan reveal, transfer Spice Banker escrow on capture, or issue a replacement
skill while the original card is captured without a recorded ruling. This
-source contract is ready for breadth-first implementation; it is not evidence
-that Leader Skills is already prototyped or that the optional module can be
-enabled publicly.
+source contract supports the bounded [runtime prototype](LEADER_SKILLS_RUNTIME.md);
+it is not evidence that the optional module can be enabled publicly.
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
new file mode 100644
index 00000000..9f905685
--- /dev/null
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -0,0 +1,121 @@
+# Leader Skills first functional prototype
+
+13 September 2026. This is **Prototyped**, with **Partial** rules coverage.
+Normal mode starts and publication remain gated. The [source contract](LEADER_SKILLS_RULES.md)
+contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md)
+records the fresh publisher/designer search requested by the user.
+
+## Connected boundary
+
+Genuine Basic or Advanced setup supports two through six base factions. Starting
+Treachery Cards, including Harkonnen's extra card, precede simultaneous private
+two-card skill offers. Every faction selects one physical skill and an eligible
+own leader, returns the other card to the shuffled deck, and publicly assigns
+the selected card before traitor choices. No reduced or duplicate skill deck is
+substituted. Expansion rosters and other optional modules remain separate work.
+
+Human controls show each permitted card's normal and battle text, select its
+leader, choose battle posture, and handle an optional own-leader revival draw.
+An undrawn revival offer shows no preview; drawing obliges a selection. All four
+AI profiles follow the same private offers and legal actions, prioritizing the
+five connected disciplines and hiding the assigned leader for battle. This
+simple policy is not calibrated skill strategy.
+
+Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and
+Prana-Bindu Adept apply +1 to another selected leader from a face-up native
+trainer, or +3 to the surviving assigned leader. They use the physical card's
+actual weapon or defense role, including a legally supplied Portable Snooper.
+They do not grant +4 or change printed strength, leader bounty or Zoal's source
+value. Existing traitor, explosion and Stone Burner precedence is preserved.
+Cheap Hero receives the normal +1 through ordinary leader substitution; this
+is a general-rule inference, without a separately located explicit skill FAQ.
+
+A face-up assigned disc cannot enter a plan. Concealing it while selecting
+another leader gives no skill bonus. Later Voice or card use can remove the
+last alternative: the mandatory remaining skilled disc then moves behind the
+shield automatically, without replaying battle powers. A surviving assignment
+returns face up after battle. Death returns its physical card exactly once.
+An own Ghola revival during unfinished battle preparation retains that stage
+through the new private draw and public posture choice.
+
+Harkonnen capture keeps the card attached to its original leader and publicly
+shows the current captor. Only the captive's lower battle effect can apply;
+neither faction receives its normal effect while captured. A surviving used
+captive returns with the same card. Execution returns the card to the deck;
+that unique formerly public card makes this skilled leader's identity inferable.
+Other captures retain the existing privacy behavior. This narrow publicity
+interpretation follows the physical component procedure, not an express
+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.
+
+Captured-card replacement entitlement remains unresolved. The prototype rejects
+own-leader revival while its original skill is captured, before beginning a
+paid revival or consuming the Ghola/special-Karama action. This is an explicit
+unsupported boundary, not a ruling that the physical game forbids that revival.
+Foreign gholas, expansion rosters and combined optional modules remain gated.
+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. Skill-specific movement, payments, information, rescues and retreats need
+their own connected controls and saved choices.
+
+## Local prototype entry and evidence
+
+Create a new base-faction local lobby, select its seats and mark them ready.
+Keep its browser cookie. Use the existing backup and atomic version-check tool:
+
+```sh
+node --import tsx tools/start-prototype.ts --profile leader-skills \
+ --db PATH_TO_LOCAL_D1_SQLITE --room EIGHT_CHARACTER_CODE \
+ --version CURRENT_LOBBY_VERSION --out /private/new-prototype-checkpoint
+```
+
+Only that fresh named lobby is initialized; every other room and all seats stay
+intact. Refresh to continue through ordinary authenticated actions. No HTTP
+endpoint dispatches the initializer; do not restart or reset the database.
+
+Focused tests cover physical inventory, setup ordering, private views, all five
+bonuses, Voice legality, capture and return, death, revival and JSON restoration.
+SQLite tests use production room actions across restarted stores and concurrent
+selection: one choice commits, the selected card has one owner, the rejected
+card returns once, and private offers remain owner-only. The local prototype
+writer is separately checked for stale/redeal rejection and preserved seats.
+
+Two seeded four-player simulations (20260913 Basic; 20260914 Advanced) completed
+through genuine setup in 265 and 211 accepted actions with no rejected bot
+candidates. They used Easy, Medium, Hard and Brutal, checked physical skills and
+forces, and restored JSON views at fourteen sampled boundaries in total.
+These were first-batch runs before the later capture and Voice review fixes;
+their private report fingerprints the exact files exercised. They do not prove
+all fourteen effects, Harkonnen capture or every interaction was played.
+
+A later Advanced run including Harkonnen (seed 20260915) completed in 360
+accepted actions, four turns and ten sampled JSON restores, with no rejected
+bot candidates. It exercised the current controller/Voice integration. Exact
+captured-card outcomes are proved by the focused capture scenarios, rather
+than inferred from this sample game's completion.
+
+Browser room `REF6YHN7` was created normally with a human Atreides and Medium
+Harkonnen, then initialized through the backed-up local tool. The human inspected
+both effect bands, assigned Master of Assassins to Thufir Hawat, selected a
+private traitor and reached Storm. Refresh retained that assignment, the AI's
+Swordmaster on Feyd-Rautha, the same Snooper hand and the pending Storm dial.
+Visual inspection caught two squeezed columns in the table sidebar; single-column
+cards restore readable text. This was a focused desktop setup/recovery journey,
+not a complete human game or mobile acceptance.
+
+The second reported power outage stopped the development server. All 292 rooms
+were present with a clean SQLite integrity/foreign-key check; the 291 opening
+rooms matched their hashes. A new online backup preceded the necessary server
+start. The same browser seat then restored both skills, Snooper, private Thufir
+traitor, ten spice and its pending Storm decision. No database reset or old
+game restoration was performed.
+
+Checkpoint-wide check/build/HTTP results and the final source fingerprint are
+recorded in the commit and private verification report. Full module and combined
+game acceptance remain unfinished.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 98f528df..d537fdef 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. This is a contract, not an implemented module. | [Source contract](LEADER_SKILLS_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 and common lifecycle are prototyped; nine effects 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) |
| 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) |
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index b260fcc0..180db803 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -33,6 +33,11 @@ import {
type HomeworldBattleRules,
type HomeworldExplosionLosses,
} from './homeworld-battle-rules';
+import {
+ leaderSkillBattleBonus,
+ type BattleLeaderSkill,
+ type LeaderSkillBattleBonus,
+} from './leader-skill-combat';
export class BattleResolutionQuoteError extends Error {
constructor(message: string) {
@@ -68,6 +73,8 @@ export type ResolutionCombatant = ResolutionParticipant & {
lateDefense?: string;
poisonTooth?: boolean;
stoneMode?: 'kill' | 'ignore';
+ /** Current authorized assignments only; captured assignments grant no normal band. */
+ leaderSkills?: readonly BattleLeaderSkill[];
/** The current aidFor result: this escrow has already left the donor's balance. */
aid?: { donor: string; amount: number };
};
@@ -110,6 +117,10 @@ export type BattleResolutionQuote = {
defenderTraitor: boolean;
revelations: { player: string; identity: string }[];
leaderStrengths: { attacker: number; defender: number };
+ leaderSkillBonuses: {
+ attacker: LeaderSkillBattleBonus;
+ defender: LeaderSkillBattleBonus;
+ };
leaderDeaths: { attacker: boolean; defender: boolean };
scores: { attacker: number; defender: number } | null;
stone: StoneBurnerComparison | null;
@@ -428,6 +439,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
let scores: BattleResolutionQuote['scores'] = null;
let deaths = { attacker: false, defender: false };
let bounty: BattleResolutionQuote['bounty'] = null;
+ let leaderSkillBonuses: BattleResolutionQuote['leaderSkillBonuses'] = {
+ attacker: { bonus: 0, applied: [] },
+ defender: { bonus: 0, applied: [] },
+ };
if (result === 'mutualTraitors' || result === 'explosion')
deaths = { attacker: true, defender: true };
else if (result === 'traitor') {
@@ -453,19 +468,45 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
attacker: effects.attackerDead || stoneKills,
defender: effects.defenderDead || stoneKills,
};
+ const selectedLeader = (side: ResolutionCombatant) =>
+ side.leader
+ ? { id: side.leader.id, kind: 'disc' as const }
+ : card(side, side.plan.leader)?.kind === 'hero'
+ ? { id: side.plan.leader!, kind: 'hero' as const }
+ : undefined;
+ leaderSkillBonuses = {
+ attacker: leaderSkillBattleBonus({
+ assignments: a.leaderSkills ?? [],
+ selectedLeader: selectedLeader(a),
+ weapon: aw,
+ defense: ad,
+ skilledLeaderSurvives: !deaths.attacker,
+ }),
+ defender: leaderSkillBattleBonus({
+ assignments: d.leaderSkills ?? [],
+ selectedLeader: selectedLeader(d),
+ weapon: dw,
+ defense: dd,
+ skilledLeaderSurvives: !deaths.defender,
+ }),
+ };
scores = {
attacker:
a.plan.dial +
(a.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.attacker || effects.stunned
? 0
- : strengths.attacker + (a.plan.kwisatz ? 2 : 0)),
+ : strengths.attacker +
+ leaderSkillBonuses.attacker.bonus +
+ (a.plan.kwisatz ? 2 : 0)),
defender:
d.plan.dial +
(d.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.defender || effects.stunned
? 0
- : strengths.defender + (d.plan.kwisatz ? 2 : 0)),
+ : strengths.defender +
+ leaderSkillBonuses.defender.bonus +
+ (d.plan.kwisatz ? 2 : 0)),
};
winner = stone
? stone.winner === 'attacker'
@@ -636,6 +677,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
defenderTraitor: dc,
revelations,
leaderStrengths: strengths,
+ leaderSkillBonuses,
leaderDeaths: deaths,
scores,
stone,
diff --git a/game/bots.ts b/game/bots.ts
index daf0b4d3..40036620 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1375,6 +1375,7 @@ function policyActions(g: GameView): Action[] {
if (g.decision) {
if (g.decision.player !== me.id) return [];
const d = g.decision;
+ if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival') return [];
if (d.kind === 'choamAudit')
return [{ type: 'decision', event: d.event, audit: true }];
if (d.kind === 'choamAuditPayment') {
@@ -3387,6 +3388,19 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
+ const skill = g.leaderSkills;
+ if (skill && !g.truthtrance && !g.response && !g.phaseOpening) {
+ if (skill.battleChoice)
+ return [{type:'leaderSkillVisibility',event:skill.battleChoice.event,hide:true}];
+ 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 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 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 (g.nexusTraitors?.pending)
return g.truthtrance
? policyActions({ ...g, decision: null })
diff --git a/game/engine.ts b/game/engine.ts
index 52a6de28..7fb7f0df 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,5 @@
+import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
+import { leaderSkillCard } from './leader-skill-cards';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -562,6 +564,8 @@ export type PlanField = Exclude<
'support' | 'kwisatz' | 'allyPayment'
>;
export type Battle = {
+ /** Public face-up/behind-shield choice precedes faction battle powers. */
+ leaderSkillHidden?: Record;
nexusInspection?: NexusInspection;
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
nexusInspectionUsed?: string;
@@ -620,6 +624,8 @@ export type Auction = {
allyPayment?: number;
};
export type Decision =
+ | { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean }
+ | { kind: 'leaderSkillRevival'; player: string; event: string }
| { kind: 'homeworldRevivalDeployment'; player: string; event: string }
| { kind: 'grummanCollection'; player: string; event: string }
| { kind: 'caladanReinforcement'; player: string; event: string }
@@ -1389,7 +1395,8 @@ export type Game = {
host: string;
status: 'lobby' | 'setup' | 'playing' | 'finished';
/** Absent on legacy rooms whose starting cards were already dealt. */
- setupStage?: 'prediction' | 'traitors' | 'forces';
+ setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
+ leaderSkills?: LeaderSkillsState;
advanced: boolean;
expansions: string[];
players: Player[];
@@ -4841,6 +4848,7 @@ function start(g: Game) {
requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
requireRule(!g.discoveryEnabled, 'Discoveries are still being implemented.');
+ requireRule(!g.leaderSkills, 'Leader Skills are still being implemented.');
requireRule(
g.players.every((p) => faction(p.faction).expansion === 'base'),
'Expansion factions are still being implemented.',
@@ -4851,6 +4859,185 @@ function start(g: Game) {
);
initializeSetup(g);
}
+function skillRule(operation: () => T): T {
+ try {
+ return operation();
+ } catch (error) {
+ if (error instanceof LeaderSkillError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function skillEligibleLeaders(g: Game, owner: string): string[] {
+ return getPlayer(g, owner)
+ .leaders.filter(
+ (leader) =>
+ !leader.dead &&
+ !leader.capturedBy &&
+ !leader.gholaBy &&
+ !isAuditorLeader(leader) &&
+ !g.leaderSkills?.assignments.some(
+ (assignment) => assignment.leader === leader.id,
+ ),
+ )
+ .map((leader) => leader.id);
+}
+function leaderSkillsIntegrity(g: Game) {
+ if (!g.leaderSkills) return;
+ skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players));
+ requireRule(
+ !g.players.some((p) => p.leaders.some((l) => l.gholaBy)),
+ 'Leader Skills with foreign gholas await their custody integration.',
+ );
+ if (g.battle?.leaderSkillHidden)
+ requireRule(
+ Object.entries(g.battle.leaderSkillHidden).every(
+ ([id, hidden]) =>
+ [g.battle!.attacker, g.battle!.defender].includes(id) &&
+ typeof hidden === 'boolean',
+ ),
+ 'Invalid saved skilled-leader visibility.',
+ );
+ if (g.decision?.kind === 'leaderSkillVisibility')
+ requireRule(
+ g.battle?.event === g.decision.event &&
+ g.leaderSkills.assignments.some((a) => a.owner === g.decision!.player),
+ 'The saved skilled-leader choice lost its battle or assigned leader.',
+ );
+ if (g.decision?.kind === 'leaderSkillRevival')
+ requireRule(
+ g.leaderSkills.offers[g.decision.player]?.event === g.decision.event,
+ 'The saved revival choice lost its physical skill offer.',
+ );
+}
+function leaderSkillController(
+ g: Game,
+ assignment: { owner: string; leader: string },
+): string {
+ return (
+ getPlayer(g, assignment.owner).leaders.find(
+ (l) => l.id === assignment.leader,
+ )!.capturedBy ?? assignment.owner
+ );
+}
+function nativeLeaderSkill(g: Game, owner: string) {
+ return g.leaderSkills?.assignments.find(
+ (a) => a.owner === owner && leaderSkillController(g, a) === owner,
+ );
+}
+function requireLeaderSkillRevival(g: Game, owner: string) {
+ requireRule(
+ !g.leaderSkills?.assignments.some(
+ (a) => a.owner === owner && leaderSkillController(g, a) !== owner,
+ ),
+ 'Reviving another leader while your Leader Skill is captured awaits the replacement-skill ruling.',
+ );
+}
+function finishLeaderSkillCustody(g: Game, before?: Game) {
+ if (!g.leaderSkills) return;
+ const result = skillRule(() =>
+ returnDeadLeaderSkills(g.leaderSkills!, g.players, random),
+ );
+ g.leaderSkills = result.state;
+ for (const assignment of result.returned) {
+ if (g.battle?.leaderSkillHidden)
+ delete g.battle.leaderSkillHidden[assignment.owner];
+ log(
+ g,
+ `${leaderSkillCard(assignment.skill).name} returned to the shuffled skill deck because its assigned leader died.`,
+ {
+ faction: getPlayer(g, assignment.owner).faction,
+ name: 'Leader Skill lost',
+ },
+ );
+ }
+ if (before?.leaderSkills)
+ for (const player of g.players)
+ for (const leader of player.leaders)
+ if (
+ !leader.dead &&
+ !leader.capturedBy &&
+ !leader.gholaBy &&
+ !isAuditorLeader(leader) &&
+ before.players
+ .find((p) => p.id === player.id)
+ ?.leaders.find((l) => l.id === leader.id)?.dead
+ ) {
+ requireLeaderSkillRevival(g, player.id);
+ g.leaderSkills = skillRule(() =>
+ offerRevivedLeaderSkill(
+ g.leaderSkills!,
+ player.id,
+ leader.id,
+ crypto.randomUUID(),
+ ),
+ );
+ }
+}
+function projectedLeaderSkills(
+ g: Game,
+ owner: string,
+): LeaderSkillsView | null {
+ if (!g.leaderSkills) return null;
+ const offer = g.leaderSkills.offers[owner] ?? null;
+ const eligible = skillEligibleLeaders(g, owner);
+ const assignment = nativeLeaderSkill(g, owner);
+ return {
+ assignments: g.leaderSkills.assignments.map((a) => {
+ const controller = leaderSkillController(g, a);
+ return {
+ ...a,
+ controller,
+ captured: controller !== a.owner,
+ faceUp:
+ controller === a.owner && !g.battle?.leaderSkillHidden?.[a.owner],
+ };
+ }),
+ offer,
+ eligibleLeaders: getPlayer(g, owner)
+ .leaders.filter((l) => eligible.includes(l.id))
+ .map((l) => ({ id: l.id, name: l.name })),
+ battleChoice:
+ assignment &&
+ g.decision?.kind === 'leaderSkillVisibility' &&
+ g.decision.player === owner
+ ? {
+ event: g.decision.event,
+ leader: assignment.leader,
+ skill: assignment.skill,
+ }
+ : null,
+ };
+}
+/** Later Voice/card use can remove the last alternative to the skilled disc. */
+function finishMandatorySkillVisibility(g: Game) {
+ const b = g.battle;
+ if (!g.leaderSkills || !b || b.revealed) return;
+ for (const owner of [b.attacker, b.defender]) {
+ if (b.plans[owner] || b.leaderSkillHidden?.[owner] !== false) continue;
+ const assignment = nativeLeaderSkill(g, owner);
+ if (!assignment) continue;
+ const p = getPlayer(g, owner);
+ const available = controlledLeaders(g, p).filter(
+ (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
+ );
+ const heroAvailable =
+ p.hand.some((c) => c.kind === 'hero') &&
+ !(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must);
+ if (
+ available.length === 1 &&
+ available[0].id === assignment.leader &&
+ !heroAvailable
+ ) {
+ b.leaderSkillHidden![owner] = true;
+ log(
+ g,
+ `${p.name} moved the skilled leader behind the shield because the remaining legal Battle Plan must use that leader.`,
+ { faction: p.faction, name: 'Leader Skill' },
+ );
+ }
+ }
+}
+
function initializeSetup(g: Game) {
if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random);
if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
@@ -4887,7 +5074,9 @@ function initializeSetup(g: Game) {
g,
byFaction(g, 'beneGesserit')
? 'Player positions are fixed. Bene Gesserit must lock its prediction before private cards are dealt.'
- : 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.',
+ : g.leaderSkills
+ ? 'Player positions are fixed. Starting Treachery Cards and Leader Skills precede traitor selection in this variant.'
+ : 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.',
);
}
function initializeStartingForces(g: Game) {
@@ -4955,7 +5144,7 @@ function dealStartingTreachery(g: Game) {
}
log(
g,
- `${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} after force placement.`,
+ `${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} ${g.leaderSkills ? 'before Leader Skill assignment' : 'after force placement'}.`,
{ faction: p.faction, name: 'Starting cards' },
);
}
@@ -4963,7 +5152,9 @@ function dealStartingTreachery(g: Game) {
if (ixians)
log(
g,
- 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.',
+ g.leaderSkills
+ ? 'Ixians must choose their starting card before the remaining cards and Leader Skills are dealt.'
+ : 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.',
);
}
function setupPending(g: Game): string[] {
@@ -4972,6 +5163,8 @@ function setupPending(g: Game): string[] {
const bg = byFaction(g, 'beneGesserit');
return bg && !bg.prediction ? [bg.id] : [];
}
+ if (g.setupStage === 'skillTreachery') return g.ixSetupCards ? [byFaction(g, 'ixians')!.id] : [];
+ if (g.setupStage === 'leaderSkills') return Object.keys(g.leaderSkills!.offers);
if (g.setupStage === 'traitors')
return g.players
.filter((p) => p.traitorChoices.length > 0)
@@ -4985,6 +5178,20 @@ function advanceSetup(g: Game) {
if (!g.setupStage || g.status !== 'setup') return;
if (g.setupStage === 'prediction') {
if (setupPending(g).length) return;
+ if (g.leaderSkills) {
+ g.setupStage = 'skillTreachery';
+ dealStartingTreachery(g);
+ if (g.decision) return;
+ }
+ }
+ if (g.setupStage === 'skillTreachery') {
+ if (g.ixSetupCards || g.decision) return;
+ g.leaderSkills = skillRule(() => dealLeaderSkills(g.leaderSkills!, g.players.map((p) => p.id), crypto.randomUUID()));
+ g.setupStage = 'leaderSkills';
+ log(g, 'Each faction received two private Leader Skill cards. Keep one and assign an eligible leader before traitors are dealt.');
+ }
+ if (g.setupStage === 'leaderSkills' && setupPending(g).length) return;
+ if (g.setupStage === 'prediction' || g.setupStage === 'leaderSkills') {
const traitors = shuffle(
traitorDeck(g.players, g.expansions.includes('ix')),
);
@@ -4999,7 +5206,9 @@ function advanceSetup(g: Game) {
g.setupStage = 'traitors';
log(
g,
- 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.',
+ g.leaderSkills
+ ? 'Leader Skills are assigned publicly. Traitor Cards were dealt privately; choose before starting force placement.'
+ : 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.',
);
}
if (g.setupStage === 'traitors') {
@@ -5008,12 +5217,14 @@ function advanceSetup(g: Game) {
g.setupStage = 'forces';
log(
g,
- 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.',
+ g.leaderSkills
+ ? 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement to begin play.'
+ : 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.',
);
}
if (g.setupStage === 'forces' && !setupPending(g).length) {
delete g.setupStage;
- dealStartingTreachery(g);
+ if (!g.leaderSkills) dealStartingTreachery(g);
}
}
/** Offline-only test seam. No player action or room API dispatches this function. */
@@ -5047,7 +5258,14 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
'Enable Discoveries in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, false, false, false, true);
}
-function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false): Game {
+/** Gated development setup; never dispatched by a player action or room route. */
+export function initializeLeaderSkillsGameForAudit(state: Game): Game {
+ requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
+ const g = structuredClone(state);
+ g.leaderSkills = createLeaderSkills(random);
+ return initializeSetupGameForAudit(g, false, false, false, false, true);
+}
+function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -5070,6 +5288,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
);
requireRule(
(homeworlds || nexus || ix || g.expansions.length === 0) &&
+ (leaderSkills || !g.leaderSkills) &&
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
!g.techTokens &&
@@ -6454,6 +6673,25 @@ function strongholdCopyChoices(g: Game, player: string): StrongholdId[] {
}
function beginBattlePowers(g: Game) {
const b = g.battle!;
+ if (g.leaderSkills) {
+ b.leaderSkillHidden ??= {};
+ const pending = [b.attacker, b.defender].find((id) =>
+ nativeLeaderSkill(g,id) && b.leaderSkillHidden![id] === undefined,
+ );
+ if (pending) {
+ const p = getPlayer(g, pending);
+ const trained = nativeLeaderSkill(g,pending)!;
+ const available = controlledLeaders(g, p).filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory));
+ if (available.length === 1 && available[0].id === trained.leader && !p.hand.some((c) => c.kind === 'hero')) {
+ b.leaderSkillHidden[pending] = true;
+ log(g, `${p.name} moved the skilled leader behind the shield because it is their only available battle leader.`, {faction:p.faction,name:'Leader Skill'});
+ beginBattlePowers(g);
+ return;
+ }
+ g.decision = {kind:'leaderSkillVisibility',player:pending,event:b.event!};
+ return;
+ }
+ }
const choam = byFaction(g, 'choam');
if (
g.advanced &&
@@ -12413,6 +12651,7 @@ function validatePlan(
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
);
const l = available.find((l) => l.id === plan.leader);
+ requireRule(!l || !g.leaderSkills?.assignments.some((assignment) => assignment.owner === p.id && assignment.leader === l.id) || !!b.leaderSkillHidden?.[p.id], 'Move your skilled leader behind the shield before using it in your battle plan.');
const hero = cardOf(p, plan.leader)?.kind === 'hero';
requireRule(
!plan.leader || l || hero,
@@ -12755,6 +12994,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
`${p.name} revived Kwisatz Haderach with Ghola for use again this turn. No spice or normal leader-revival allowance was spent.`,
);
} else if (action.leader) {
+ requireLeaderSkillRevival(g,p.id);
requireRule(
action.leader !== DUKE_VIDAL_ID || p.faction === 'ecaz',
'Only Ecaz may revive Duke Vidal, including with Ghola.',
@@ -13680,6 +13920,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
hand: p.hand,
plan,
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
+ leaderSkills: g.leaderSkills?.assignments.filter((assignment) => leaderSkillController(g,assignment) === p.id).map((assignment) => ({skill:assignment.skill,leader:assignment.leader,faceUp:assignment.owner === p.id && !b.leaderSkillHidden?.[p.id],captured:assignment.owner !== p.id})),
forces: combatForces(g, p, b.territory, opponent),
stronghold: strongholdEffect(g, p.id),
lateDefense: b.lateDefense?.[p.id],
@@ -13871,6 +14112,10 @@ function resolveBattle(g: Game) {
if (dk) dead(dl);
const av = quote.scores!.attacker,
dv = quote.scores!.defender;
+ if (!stoneResult && !quote.effects?.stunned)
+ for (const [player, receipt, died] of [[a, quote.leaderSkillBonuses.attacker, ak], [d, quote.leaderSkillBonuses.defender, dk]] as const)
+ for (const bonus of died ? [] : receipt.applied)
+ log(g, `${player.name} gained ${bonus.amount} battle strength from ${leaderSkillCard(bonus.skill).name}${bonus.mode === 'normal' ? ' while their skilled leader remained face up' : ' by using their surviving skilled leader'}.`, {faction:player.faction,name:'Leader Skill'});
const loser = winner === a ? d : a;
if (quote.bounty) winner!.spice += quote.bounty.amount;
killTerritory(g, loser, b.territory, Infinity, true);
@@ -14964,6 +15209,7 @@ function finishRevival(
if (quote.nextResponse) g.response = quote.nextResponse;
}
function beginRevival(g: Game, revival: PendingRevival) {
+ if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player);
requireRule(
!revivalPrevented(g, revival.player),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
@@ -15908,6 +16154,10 @@ function finishResponse(g: Game, canceled: boolean) {
usedAt: l.usedAt,
};
l.capturedBy = capture.player;
+ const capturedSkill = g.leaderSkills?.assignments.find((a) => a.leader === l.id);
+ // This unique card and its leader were publicly assigned. The expansion
+ // requires the card to accompany the captive, identifying that capture.
+ if (capturedSkill) delete l.concealed;
g.decision = {
kind: 'capturedLeader',
player: capture.player,
@@ -15917,7 +16167,9 @@ function finishResponse(g: Game, canceled: boolean) {
};
log(
g,
- `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`,
+ capturedSkill
+ ? `${getPlayer(g, capture.player).name} captured ${l.name} with ${leaderSkillCard(capturedSkill.skill).name}. Its known skill travels with the leader; only its battle effect is available to the captor.`
+ : `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`,
);
}
} else if (response.kind === 'choamAudit') {
@@ -17759,6 +18011,7 @@ export function prepareSpecialKaramaIntent(
'The Emperor special Karama is used during Revival.',
);
if (action.leader) {
+ requireLeaderSkillRevival(g,p.id);
const l = p.leaders.find((l) => l.id === action.leader);
requireRule(
l?.dead && !l.capturedBy && !l.gholaBy,
@@ -18178,6 +18431,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -18238,6 +18492,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ finishLeaderSkillCustody(g, state);
observeOccupation(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -18327,6 +18582,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
}
}
finishActionContinuations(g);
+ finishLeaderSkillCustody(g, state);
+ leaderSkillsIntegrity(g);
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
@@ -18343,6 +18600,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g;
}
function finishActionContinuations(g: Game) {
+ finishLeaderSkillCustody(g);
+ finishMandatorySkillVisibility(g);
if (g.pendingNullentropy) return;
// Losing cleanup may finish casualties and produce the winner's mandatory
// discard. Retire that next physical batch before exposing optional choices.
@@ -18355,6 +18614,10 @@ function finishActionContinuations(g: Game) {
resumeMarketGhola(g);
resumeGrummanCollection(g);
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
+ if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) {
+ const owner = g.order.find((id) => g.leaderSkills!.offers[id]);
+ if (owner) g.decision = {kind:'leaderSkillRevival',player:owner,event:g.leaderSkills.offers[owner].event};
+ }
if (
!g.truthtrance &&
g.pendingTerrorEntry?.stage === 'discard' &&
@@ -18428,6 +18691,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
@@ -18818,6 +19082,32 @@ function applyActionInner(
g.decision.player === id
)
g.decision = null;
+ if (t === 'leaderSkillVisibility' || (t === 'leaderSkill' && g.status === 'playing')) {
+ const decision = g.decision;
+ requireRule(decision && (decision.kind === 'leaderSkillVisibility' || decision.kind === 'leaderSkillRevival') && decision.player === id && decision.event === action.event, 'This Leader Skill decision is not waiting for you.');
+ if (t === 'leaderSkillVisibility') {
+ requireRule(decision.kind === 'leaderSkillVisibility' && g.battle?.event === decision.event && typeof action.hide === 'boolean', 'Choose whether your skilled leader remains face up or moves behind the shield.');
+ requireRule(Object.keys(action).every((key) => ['type','event','hide'].includes(key)), 'Choose only the current skill visibility decision.');
+ (g.battle.leaderSkillHidden ??= {})[id] = action.hide;
+ g.decision = null;
+ log(g, `${p.name} ${action.hide ? 'moved their skilled leader and card behind the shield; it may still be a bluff' : 'kept their skilled leader and card face up; that leader cannot be in their Battle Plan'}.`, {faction:p.faction,name:'Leader Skill'});
+ if (decision.resumePowers !== false) beginBattlePowers(g);
+ } else {
+ requireRule(decision.kind === 'leaderSkillRevival' && g.leaderSkills, 'No revived leader is awaiting a skill choice.');
+ requireRule(Object.keys(action).every((key) => ['type','event','mode','skill','leader'].includes(key)), 'Choose only the current revival skill action.');
+ if (action.mode === 'draw') g.leaderSkills = skillRule(() => drawRevivedLeaderSkills(g.leaderSkills!, id, stringField(action.event)));
+ 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));
+ 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;
+ if (g.battle && !g.battle.revealed && !g.battle.plans[id] && [g.battle.attacker,g.battle.defender].includes(id) && g.leaderSkills.assignments.some((a) => a.owner === id) && g.battle.leaderSkillHidden?.[id] === undefined)
+ g.decision = {kind:'leaderSkillVisibility',player:id,event:g.battle.event!,resumePowers:false};
+ }
+ return g;
+ }
if (g.decision) {
const decision = g.decision;
requireRule(
@@ -18825,6 +19115,7 @@ function applyActionInner(
'Waiting for the player with the pending decision.',
);
g.decision = null;
+ requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'homeworldRevivalDeployment') {
decideHomeworldRevivalReturn(g, p, action);
return g;
@@ -20077,13 +20368,20 @@ function applyActionInner(
? ['predict']
: g.setupStage === 'traitors'
? ['traitor']
+ : g.setupStage === 'leaderSkills'
+ ? ['leaderSkill']
: ['fremenSetup', 'advisorSetup'];
requireRule(
expected.includes(t) && setupPending(g).includes(id),
`Wait for the current setup step: ${g.setupStage}.`,
);
}
- if (t === 'advisorSetup') {
+ 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);
+ 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') {
requireRule(
g.advanced && p.faction === 'beneGesserit' && !p.advisorSetup,
'Advisor setup is not available.',
@@ -21800,6 +22098,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ leaderSkillsIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -21895,6 +22194,7 @@ export function viewGame(state: Game, id: string) {
status: g.status,
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
setupPending: setupPending(g),
+ leaderSkills: projectedLeaderSkills(g, id),
advanced: g.advanced,
dukeVidal: g.dukeVidal
? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
@@ -22333,6 +22633,7 @@ export function viewGame(state: Game, id: string) {
: null,
decision:
g.decision?.kind === 'capturedLeader' &&
+ !g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
id,
)
diff --git a/game/leader-skill-cards.ts b/game/leader-skill-cards.ts
new file mode 100644
index 00000000..7164949a
--- /dev/null
+++ b/game/leader-skill-cards.ts
@@ -0,0 +1,150 @@
+/** Canonical summaries of the fourteen physical CHOAM & Richese Leader Skill
+ * cards. This reference catalog does not enable the optional module or execute
+ * its effects. Source and lifecycle contract: docs/LEADER_SKILLS_RULES.md. */
+export const LEADER_SKILL_CARDS = [
+ {
+ id: 'bureaucrat',
+ name: 'Bureaucrat',
+ normal: [
+ 'Once per phase, when another player pays at least 5 spice to a player other than you, you may redirect 2 of that payment to the Spice Bank.',
+ ],
+ battle: [
+ 'Your opponent subtracts 1 from their battle total for each stronghold they occupy; Bene Gesserit advisors do not count as occupation for this penalty.',
+ ],
+ },
+ {
+ id: 'spice-banker',
+ name: 'Spice Banker',
+ normal: [
+ 'Once per phase, when another player pays at least 4 spice to the Spice Bank, place 1 spice in front of your shield and collect it during the Mentat Pause.',
+ ],
+ battle: [
+ 'Commit 1–3 spice with your Battle Plan and add the same amount to this leader’s strength.',
+ 'This payment is separate from spice paid to make forces fight at full strength.',
+ ],
+ },
+ {
+ id: 'diplomat',
+ name: 'Diplomat',
+ normal: [
+ 'When you play no defense, one Worthless card in your Battle Plan may count as the defense your opponent used in that battle; discard the Worthless card afterward.',
+ ],
+ battle: [
+ 'If you lose, retreat undialed forces up to this leader’s strength to one empty adjacent territory that is not a stronghold.',
+ ],
+ },
+ {
+ id: 'mentat',
+ name: 'Mentat',
+ normal: [
+ 'Before Battle Plans, name a specific weapon and ask whether your opponent holds it. They reveal that card if held, or a different Treachery Card otherwise.',
+ 'Revealing the named weapon does not require your opponent to use it.',
+ ],
+ battle: ['Add 2 to this leader’s strength.'],
+ },
+ {
+ id: 'suk-graduate',
+ name: 'Suk Graduate',
+ normal: [
+ 'After you win a battle and lose forces, return 1 such force to reserves instead of the Tleilaxu Tanks.',
+ ],
+ battle: [
+ 'After you win and lose forces, save up to 3 of them from going to the Tleilaxu Tanks: leave 1 in the battle territory and return the rest to reserves.',
+ ],
+ },
+ {
+ id: 'rihani-decipherer',
+ name: 'Rihani Decipherer',
+ normal: [
+ 'After you win a battle, secretly inspect 2 random cards from the Traitor Deck, then reshuffle them into that deck.',
+ ],
+ battle: [
+ 'After you win, draw 2 Traitor Cards and keep 1 by revealing an unused Traitor Card you already hold.',
+ 'Shuffle the revealed card and the unkept drawn card into the Traitor Deck.',
+ ],
+ },
+ {
+ id: 'sandmaster',
+ name: 'Sandmaster',
+ normal: [
+ 'When moving forces into or through a territory containing spice, you may immediately collect 1 spice there, once per territory traversed.',
+ ],
+ battle: [
+ 'After winning a battle in a territory that contains spice, add 3 spice to that territory.',
+ ],
+ },
+ {
+ id: 'smuggler',
+ name: 'Smuggler',
+ normal: [
+ 'When shipping forces from off-planet to an empty territory, one additional force may accompany that shipment for free.',
+ ],
+ battle: [
+ 'When Battle Plans are revealed, collect spice from the battle territory equal to this leader’s strength, limited by the spice present and subject to the skilled leader surviving the battle.',
+ ],
+ },
+ {
+ id: 'planetologist',
+ name: 'Planetologist',
+ normal: [
+ 'For a movement, either add 1 to movement range, capped at 3, or move forces from two different territories when both groups share one destination.',
+ ],
+ battle: [
+ 'In place of a weapon, reveal a green Special card other than a Cheap Hero and add 2 to this leader’s strength.',
+ 'Discard that Special card after the battle.',
+ ],
+ },
+ {
+ id: 'warmaster',
+ name: 'Warmaster',
+ normal: [
+ 'Add 1 to your other leader discs when their Battle Plan contains at least one Worthless card.',
+ ],
+ battle: [
+ 'Add 3 to this leader’s strength when its Battle Plan contains at least one Worthless card.',
+ ],
+ },
+ {
+ id: 'master-of-assassins',
+ name: 'Master of Assassins',
+ normal: ['Add 1 to your other leader discs when they use a Poison Weapon.'],
+ battle: ['Add 3 to this leader’s strength when it uses a Poison Weapon.'],
+ },
+ {
+ id: 'swordmaster-of-ginaz',
+ name: 'Swordmaster of Ginaz',
+ normal: [
+ 'Add 1 to your other leader discs when they use a Projectile Weapon.',
+ ],
+ battle: [
+ 'Add 3 to this leader’s strength when it uses a Projectile Weapon.',
+ ],
+ },
+ {
+ id: 'killer-medic',
+ name: 'Killer Medic',
+ normal: [
+ 'Add 1 to your other leader discs when they use a Poison Defense.',
+ ],
+ battle: ['Add 3 to this leader’s strength when it uses a Poison Defense.'],
+ },
+ {
+ id: 'prana-bindu-adept',
+ name: 'Prana Bindu Adept',
+ normal: [
+ 'Add 1 to your other leader discs when they use a Projectile Defense.',
+ ],
+ battle: [
+ 'Add 3 to this leader’s strength when it uses a Projectile Defense.',
+ ],
+ },
+] as const;
+
+export type LeaderSkillCard = (typeof LEADER_SKILL_CARDS)[number];
+export type LeaderSkillId = LeaderSkillCard['id'];
+
+export function leaderSkillCard(id: LeaderSkillId): LeaderSkillCard {
+ const card = LEADER_SKILL_CARDS.find((candidate) => candidate.id === id);
+ if (!card) throw new Error('Unknown Leader Skill Card.');
+ return card;
+}
diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts
new file mode 100644
index 00000000..209a2307
--- /dev/null
+++ b/game/leader-skill-combat.ts
@@ -0,0 +1,95 @@
+import type { Card } from './cards';
+import { defenseTypes, weaponTypes } from './battle-cards';
+import type { LeaderSkillId } from './leader-skill-cards';
+
+export type DirectLeaderSkillId =
+ | 'warmaster'
+ | 'master-of-assassins'
+ | 'swordmaster-of-ginaz'
+ | 'killer-medic'
+ | 'prana-bindu-adept';
+
+export type BattleLeaderSkill = {
+ skill: LeaderSkillId;
+ leader: string;
+ /** Ordinary assignments are publicly face up until deliberately concealed. */
+ faceUp: boolean;
+ /** A captured skill grants only its attached leader's lower battle effect. */
+ captured: boolean;
+};
+
+export type AppliedLeaderSkill = {
+ skill: DirectLeaderSkillId;
+ amount: 1 | 3;
+ mode: 'normal' | 'skilled';
+};
+
+export type LeaderSkillBattleBonus = {
+ bonus: number;
+ applied: AppliedLeaderSkill[];
+};
+
+const directSkills = new Set([
+ 'warmaster',
+ 'master-of-assassins',
+ 'swordmaster-of-ginaz',
+ 'killer-medic',
+ 'prana-bindu-adept',
+]);
+
+function isDirectSkill(skill: string): skill is DirectLeaderSkillId {
+ return directSkills.has(skill as LeaderSkillId);
+}
+
+function qualifies(
+ skill: DirectLeaderSkillId,
+ weapon: Card | undefined,
+ defense: Card | undefined,
+) {
+ if (skill === 'warmaster')
+ return [weapon, defense].some((card) => card?.kind === 'worthless');
+ if (skill === 'master-of-assassins')
+ return weaponTypes(weapon).includes('poison');
+ if (skill === 'swordmaster-of-ginaz')
+ return weaponTypes(weapon).includes('projectile');
+ if (skill === 'killer-medic')
+ return defenseTypes(defense).includes('snooper');
+ return defenseTypes(defense).includes('shield');
+}
+
+/**
+ * Quote the five card-role strength families after weapon survival is known.
+ * Printed disc strength and bounty remain outside this additive score receipt.
+ */
+export function leaderSkillBattleBonus(input: {
+ assignments: readonly BattleLeaderSkill[];
+ /** Cheap Hero substitutes for another leader for normal +1 training only. */
+ selectedLeader: { id: string; kind: 'disc' | 'hero' } | undefined;
+ weapon: Card | undefined;
+ defense: Card | undefined;
+ skilledLeaderSurvives: boolean;
+}): LeaderSkillBattleBonus {
+ if (!input.selectedLeader) return { bonus: 0, applied: [] };
+ const applied: AppliedLeaderSkill[] = [];
+ for (const assignment of input.assignments) {
+ if (!isDirectSkill(assignment.skill)) continue;
+ if (!qualifies(assignment.skill, input.weapon, input.defense)) continue;
+ if (
+ input.selectedLeader.kind === 'disc' &&
+ assignment.leader === input.selectedLeader.id &&
+ (!assignment.faceUp || assignment.captured) &&
+ input.skilledLeaderSurvives
+ )
+ applied.push({ skill: assignment.skill, amount: 3, mode: 'skilled' });
+ else if (
+ assignment.leader !== input.selectedLeader.id &&
+ assignment.faceUp &&
+ !assignment.captured
+ )
+ applied.push({ skill: assignment.skill, amount: 1, mode: 'normal' });
+ }
+ return {
+ bonus: applied.reduce((sum, receipt) => sum + receipt.amount, 0),
+ applied,
+ };
+}
diff --git a/game/leader-skills.ts b/game/leader-skills.ts
new file mode 100644
index 00000000..63f96d6e
--- /dev/null
+++ b/game/leader-skills.ts
@@ -0,0 +1,280 @@
+import { LEADER_SKILL_CARDS, type LeaderSkillId } from './leader-skill-cards';
+
+export type LeaderSkillOffer = {
+ event: string;
+ /** An empty revival offer is the choice whether to draw, not a free preview. */
+ cards: LeaderSkillId[];
+ /** Revival fixes the eligible leader; setup lets the owner choose. */
+ leader: string | null;
+};
+export type LeaderSkillAssignment = {
+ skill: LeaderSkillId;
+ leader: string;
+ owner: string;
+};
+export type LeaderSkillsState = {
+ deck: LeaderSkillId[];
+ offers: Record;
+ assignments: LeaderSkillAssignment[];
+};
+export type LeaderSkillsView = {
+ assignments: (LeaderSkillAssignment & {
+ controller: string;
+ captured: boolean;
+ faceUp: boolean;
+ })[];
+ offer: LeaderSkillOffer | null;
+ eligibleLeaders: { id: string; name: string }[];
+ /** Only this player's genuine public choice is included. */
+ battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
+};
+export type SkillLeader = {
+ id: string;
+ dead: boolean;
+ capturedBy?: string;
+ gholaBy?: string;
+};
+export type SkillRoster = readonly {
+ id: string;
+ leaders: readonly SkillLeader[];
+}[];
+export class LeaderSkillError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'LeaderSkillError';
+ }
+}
+function ensure(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new LeaderSkillError(message);
+}
+function shuffled(cards: readonly T[], random: () => number): T[] {
+ const result = [...cards];
+ for (let i = result.length - 1; i > 0; i--) {
+ const roll = random();
+ ensure(
+ Number.isFinite(roll) && roll >= 0 && roll < 1,
+ 'Invalid skill shuffle.',
+ );
+ const j = Math.floor(roll * (i + 1));
+ [result[i], result[j]] = [result[j], result[i]];
+ }
+ return result;
+}
+export function createLeaderSkills(random: () => number): LeaderSkillsState {
+ return {
+ deck: shuffled(
+ LEADER_SKILL_CARDS.map((card) => card.id),
+ random,
+ ),
+ offers: {},
+ assignments: [],
+ };
+}
+/** Validate physical custody without treating a hidden skill as a new copy. */
+export function validateLeaderSkills(
+ state: LeaderSkillsState,
+ roster: SkillRoster,
+): void {
+ ensure(
+ state &&
+ Array.isArray(state.deck) &&
+ Array.isArray(state.assignments) &&
+ state.offers &&
+ typeof state.offers === 'object' &&
+ !Array.isArray(state.offers),
+ 'Invalid saved Leader Skills state.',
+ );
+ const cards: string[] = [...state.deck];
+ const assignedLeaders = new Set();
+ const originalOwners = new Set();
+ for (const assignment of state.assignments) {
+ const owner = roster.find((p) => p.id === assignment.owner);
+ ensure(
+ owner?.leaders.some((leader) => leader.id === assignment.leader),
+ 'A skill must remain attached to its original leader.',
+ );
+ ensure(
+ !assignedLeaders.has(assignment.leader),
+ 'A leader cannot hold two skills.',
+ );
+ assignedLeaders.add(assignment.leader);
+ ensure(
+ !originalOwners.has(assignment.owner),
+ 'A faction cannot acquire a second original skill while the captured replacement ruling is unresolved.',
+ );
+ originalOwners.add(assignment.owner);
+ cards.push(assignment.skill);
+ }
+ for (const [owner, offer] of Object.entries(state.offers)) {
+ ensure(
+ !originalOwners.has(owner),
+ 'A faction with an assigned skill cannot hold another skill offer.',
+ );
+ ensure(
+ roster.some((p) => p.id === owner) &&
+ offer &&
+ typeof offer.event === 'string' &&
+ offer.event.length > 0 &&
+ Array.isArray(offer.cards) &&
+ (offer.cards.length === 2 ||
+ (offer.leader !== null && offer.cards.length === 0)),
+ 'Invalid saved skill choice.',
+ );
+ ensure(
+ offer.leader === null ||
+ roster
+ .find((p) => p.id === owner)
+ ?.leaders.some((l) => l.id === offer.leader),
+ 'Invalid revival skill leader.',
+ );
+ cards.push(...offer.cards);
+ }
+ const canonical = LEADER_SKILL_CARDS.map((c) => c.id);
+ ensure(
+ cards.length === canonical.length &&
+ new Set(cards).size === canonical.length &&
+ canonical.every((id) => cards.includes(id)),
+ 'Leader Skills must preserve all fourteen physical cards exactly once.',
+ );
+}
+/** Deal every initial two-card hand before any rejected card returns to the deck. */
+export function dealLeaderSkills(
+ state: LeaderSkillsState,
+ players: readonly string[],
+ event: string,
+): LeaderSkillsState {
+ ensure(
+ !state.assignments.length &&
+ !Object.keys(state.offers).length &&
+ state.deck.length === LEADER_SKILL_CARDS.length,
+ 'Leader Skills have already been dealt.',
+ );
+ ensure(
+ players.length >= 2 &&
+ players.length <= 6 &&
+ new Set(players).size === players.length &&
+ !!event,
+ 'Choose two through six distinct skill recipients.',
+ );
+ const next = structuredClone(state);
+ for (const player of players)
+ next.offers[player] = {
+ event,
+ cards: next.deck.splice(0, 2),
+ leader: null,
+ };
+ return next;
+}
+/** Callers supply the living own leader choices after excluding Auditor/captives. */
+export function chooseLeaderSkill(
+ state: LeaderSkillsState,
+ owner: string,
+ event: string,
+ skill: string,
+ leader: string,
+ eligibleLeaders: readonly string[],
+ random: () => number,
+): LeaderSkillsState {
+ const offer = state.offers[owner];
+ ensure(
+ !state.assignments.some((assignment) => assignment.owner === owner),
+ 'This faction already has an assigned Leader Skill.',
+ );
+ ensure(
+ offer &&
+ offer.event === event &&
+ offer.cards.includes(skill as LeaderSkillId),
+ 'Choose one of your current Leader Skill cards.',
+ );
+ ensure(
+ eligibleLeaders.includes(leader) &&
+ (!offer.leader || offer.leader === leader),
+ 'Choose an eligible living leader for this skill.',
+ );
+ ensure(
+ !state.assignments.some((assignment) => assignment.leader === leader),
+ 'This leader already has a skill.',
+ );
+ const next = structuredClone(state);
+ next.assignments.push({ owner, leader, skill: skill as LeaderSkillId });
+ next.deck = shuffled(
+ [...next.deck, ...offer.cards.filter((card) => card !== skill)],
+ random,
+ );
+ delete next.offers[owner];
+ return next;
+}
+/** Death returns the physical card once; capture leaves it attached to the disc. */
+export function returnDeadLeaderSkills(
+ state: LeaderSkillsState,
+ roster: SkillRoster,
+ random: () => number,
+): { state: LeaderSkillsState; returned: LeaderSkillAssignment[] } {
+ const returned = state.assignments.filter(
+ (assignment) =>
+ roster
+ .find((p) => p.id === assignment.owner)
+ ?.leaders.find((l) => l.id === assignment.leader)?.dead,
+ );
+ if (!returned.length) return { state, returned };
+ const next = structuredClone(state);
+ next.assignments = next.assignments.filter(
+ (assignment) => !returned.some((dead) => dead.leader === assignment.leader),
+ );
+ next.deck = shuffled(
+ [...next.deck, ...returned.map((assignment) => assignment.skill)],
+ random,
+ );
+ return { state: next, returned };
+}
+/** The capture/replacement question stays guarded while an original skill exists. */
+export function offerRevivedLeaderSkill(
+ state: LeaderSkillsState,
+ owner: string,
+ leader: string,
+ event: string,
+): LeaderSkillsState {
+ if (
+ state.assignments.some((assignment) => assignment.owner === owner) ||
+ state.offers[owner]
+ )
+ return state;
+ ensure(
+ state.deck.length >= 2 && !!event,
+ 'Two physical cards are required for a revived leader skill choice.',
+ );
+ const next = structuredClone(state);
+ next.offers[owner] = { event, cards: [], leader };
+ return next;
+}
+export function drawRevivedLeaderSkills(
+ state: LeaderSkillsState,
+ owner: string,
+ event: string,
+): LeaderSkillsState {
+ const offer = state.offers[owner];
+ ensure(
+ offer?.leader &&
+ offer.event === event &&
+ offer.cards.length === 0 &&
+ state.deck.length >= 2,
+ 'Only a current undrawn revival offer may draw skill cards.',
+ );
+ const next = structuredClone(state);
+ next.offers[owner].cards = next.deck.splice(0, 2);
+ return next;
+}
+export function declineRevivedLeaderSkill(
+ state: LeaderSkillsState,
+ owner: string,
+ event: string,
+): LeaderSkillsState {
+ const offer = state.offers[owner];
+ ensure(
+ offer?.leader && offer.event === event && offer.cards.length === 0,
+ 'Only an undrawn revival skill offer may be declined.',
+ );
+ const next = structuredClone(state);
+ delete next.offers[owner];
+ return next;
+}
diff --git a/game/reference.ts b/game/reference.ts
index 31b08cc3..08908786 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4,6 +4,7 @@ import { FACTIONS, PHASES } from './catalog';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
+import { LEADER_SKILL_CARDS } from './leader-skill-cards';
export const RULE_CHECKLIST_AREAS = [
'Implementation',
@@ -3556,6 +3557,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
+ {
+ id: 'leader-skills',
+ title: 'Leader Skills',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Fourteen physical skills, private assignment choices and five connected battle disciplines 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.',
+ 'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
+ '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.',
+ 'Bureaucrat, Spice Banker, Diplomat, Mentat, Suk Graduate, Rihani Decipherer, Sandmaster, Smuggler and Planetologist 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. Other nine effects, 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 controls are connected. Other effect controls remain missing.',evidence:['components/leader-skills.tsx'] },
+ { area:'AI',status:'Partial',detail:'All four profiles use private offers and legal visibility/revival actions. 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'] },
+ { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup and revival 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'] },
+ ],
+ },
...[
[
'ix-modules',
@@ -3602,7 +3628,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'setup',
'advanced-combat',
...(id === 'ix-modules' ? ['tech-tokens'] : []),
- ...(id === 'choam-modules' ? ['stronghold-cards'] : []),
+ ...(id === 'choam-modules' ? ['stronghold-cards', 'leader-skills'] : []),
...(id === 'choam-modules' ? ['richese-cards'] : []),
...(id === 'ecaz-modules'
? [
diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts
index c412638b..0fa6a10e 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -4,6 +4,8 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
// Exhaustive typed discriminants for the existing engine control unions. These
// are envelope checks, not a duplicate implementation of each future choice.
const DECISIONS = {
+ leaderSkillVisibility: true,
+ leaderSkillRevival: true,
choamAudit: true,
choamAuditPayment: true,
strongholdCopy: true,
diff --git a/tests/discovery-runtime.test.ts b/tests/discovery-runtime.test.ts
index 25479f23..105e810e 100644
--- a/tests/discovery-runtime.test.ts
+++ b/tests/discovery-runtime.test.ts
@@ -109,7 +109,17 @@ void test('Collection look remains private, deferred reveal survives reload, and
void test('Treachery Card Stash draws past a full hand, keeps the draw private and accepts every held discard through recovery', () => {
let g = discoveryFixture();
const token = enterDiscoveryCollection(g, 'treachery-card-stash');
+ // Exercise the previously flaky ID prefix collision with two distinct cards.
+ const take = (id: string) => {
+ const pile = [g.deck, g.discard, ...g.players.map((p) => p.hand)]
+ .find((cards) => cards.some((card) => card.id === id))!;
+ return pile.splice(pile.findIndex((card) => card.id === id), 1)[0];
+ };
+ const privateCard = take('treachery-3');
+ const visibleSibling = take('treachery-32');
+ g.players.find((p) => p.id === 'f')!.hand.push(visibleSibling);
while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!);
+ g.deck.unshift(privateCard);
const original = g.players[0].hand.map((c) => c.id),
drawn = g.deck[0];
g = applyAction(g, 'a', {
@@ -120,7 +130,9 @@ void test('Treachery Card Stash draws past a full hand, keeps the draw private a
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
assert.equal(g.decision?.kind, 'discoveryDiscard');
assert.equal(g.players[0].hand.length, 5);
- assert.equal(JSON.stringify(viewGame(g, 'f')).includes(drawn.id), false);
+ const otherView = JSON.stringify(viewGame(g, 'f'));
+ assert.equal(otherView.includes(JSON.stringify(visibleSibling.id)), true);
+ assert.equal(otherView.includes(JSON.stringify(drawn.id)), false);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[...original, drawn.id],
diff --git a/tests/leader-skill-cards.test.ts b/tests/leader-skill-cards.test.ts
new file mode 100644
index 00000000..d626d35d
--- /dev/null
+++ b/tests/leader-skill-cards.test.ts
@@ -0,0 +1,126 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ LEADER_SKILL_CARDS,
+ leaderSkillCard,
+ type LeaderSkillId,
+} from '../game/leader-skill-cards';
+
+const EXPECTED_IDS: readonly LeaderSkillId[] = [
+ 'bureaucrat',
+ 'spice-banker',
+ 'diplomat',
+ 'mentat',
+ 'suk-graduate',
+ 'rihani-decipherer',
+ 'sandmaster',
+ 'smuggler',
+ 'planetologist',
+ 'warmaster',
+ 'master-of-assassins',
+ 'swordmaster-of-ginaz',
+ 'killer-medic',
+ 'prana-bindu-adept',
+];
+
+void test('the canonical inventory has exactly fourteen unique physical identities', () => {
+ assert.equal(LEADER_SKILL_CARDS.length, 14);
+ assert.deepEqual(
+ LEADER_SKILL_CARDS.map((card) => card.id),
+ EXPECTED_IDS,
+ );
+ assert.equal(new Set(LEADER_SKILL_CARDS.map((card) => card.id)).size, 14);
+ assert.equal(new Set(LEADER_SKILL_CARDS.map((card) => card.name)).size, 14);
+ for (const card of LEADER_SKILL_CARDS) {
+ assert.deepEqual(Object.keys(card), ['id', 'name', 'normal', 'battle']);
+ assert.ok(card.normal.length > 0);
+ assert.ok(card.battle.length > 0);
+ assert.ok(card.normal.every((line) => line.length > 20));
+ assert.ok(card.battle.every((line) => line.length > 20));
+ assert.match(card.id, /^[a-z]+(?:-[a-z]+)*$/);
+ }
+});
+
+void test('lookup is total for canonical IDs and rejects unknown runtime input', () => {
+ for (const card of LEADER_SKILL_CARDS)
+ assert.equal(leaderSkillCard(card.id), card);
+ assert.throws(
+ () => leaderSkillCard('not-a-physical-skill' as LeaderSkillId),
+ /Unknown Leader Skill Card/,
+ );
+});
+
+void test('the four weapon and defense disciplines preserve their distinct roles and values', () => {
+ const roles = [
+ ['master-of-assassins', 'Poison Weapon'],
+ ['swordmaster-of-ginaz', 'Projectile Weapon'],
+ ['killer-medic', 'Poison Defense'],
+ ['prana-bindu-adept', 'Projectile Defense'],
+ ] as const;
+ for (const [id, role] of roles) {
+ const card = leaderSkillCard(id);
+ assert.match(card.normal.join(' '), new RegExp(`Add 1.*other.*${role}`));
+ assert.match(card.battle.join(' '), new RegExp(`Add 3.*${role}`));
+ }
+});
+
+void test('named economic, custody and information effects retain their source numbers', () => {
+ assert.match(
+ leaderSkillCard('bureaucrat').normal.join(' '),
+ /per phase.*at least 5.*2.*Spice Bank/,
+ );
+ assert.match(
+ leaderSkillCard('spice-banker').normal.join(' '),
+ /per phase.*at least 4.*1 spice.*Mentat Pause/,
+ );
+ assert.match(
+ leaderSkillCard('spice-banker').battle.join(' '),
+ /1–3.*separate.*full strength/,
+ );
+ assert.match(
+ leaderSkillCard('rihani-decipherer').battle.join(' '),
+ /draw 2 Traitor Cards.*unused Traitor Card.*unkept drawn card/,
+ );
+ assert.match(
+ leaderSkillCard('mentat').normal.join(' '),
+ /specific weapon.*different Treachery Card.*does not require/,
+ );
+});
+
+void test('movement and battle-loss cards remain materially different', () => {
+ assert.match(
+ leaderSkillCard('planetologist').normal.join(' '),
+ /capped at 3.*two different territories.*one destination/,
+ );
+ assert.match(
+ leaderSkillCard('planetologist').battle.join(' '),
+ /green Special.*other than a Cheap Hero.*add 2.*Discard/,
+ );
+ assert.match(
+ leaderSkillCard('smuggler').normal.join(' '),
+ /off-planet.*empty territory.*additional force.*free/,
+ );
+ assert.match(
+ leaderSkillCard('sandmaster').normal.join(' '),
+ /into or through.*1 spice.*once per territory/,
+ );
+ assert.match(
+ leaderSkillCard('diplomat').battle.join(' '),
+ /lose.*undialed forces.*empty adjacent.*not a stronghold/,
+ );
+ assert.match(
+ leaderSkillCard('suk-graduate').normal.join(' '),
+ /return 1.*reserves.*instead of the Tleilaxu Tanks/,
+ );
+ assert.match(
+ leaderSkillCard('suk-graduate').battle.join(' '),
+ /up to 3.*leave 1.*return the rest/,
+ );
+});
+
+void test('Warmaster uses one Worthless card for either nonstacking band', () => {
+ const warmaster = leaderSkillCard('warmaster');
+ assert.match(warmaster.normal.join(' '), /Add 1.*other.*at least one/);
+ assert.match(warmaster.battle.join(' '), /Add 3.*at least one/);
+ assert.doesNotMatch(JSON.stringify(warmaster), /at least two|Add 4/);
+});
diff --git a/tests/leader-skill-combat.test.ts b/tests/leader-skill-combat.test.ts
new file mode 100644
index 00000000..8461f402
--- /dev/null
+++ b/tests/leader-skill-combat.test.ts
@@ -0,0 +1,295 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+import {
+ leaderSkillBattleBonus,
+ type BattleLeaderSkill,
+ type DirectLeaderSkillId,
+} from '../game/leader-skill-combat';
+import {
+ quoteBattleResolution,
+ type BattleResolutionInput,
+ type ResolutionCombatant,
+} from '../game/battle-resolution-quote';
+
+const catalog = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
+function one(predicate: (card: Card) => boolean) {
+ const card = catalog().find(predicate);
+ assert.ok(card);
+ return card;
+}
+const selected = { id: 'selected-disc', kind: 'disc' as const };
+const assignment = (
+ skill: DirectLeaderSkillId,
+ leader = selected.id,
+ faceUp = false,
+ captured = false,
+): BattleLeaderSkill => ({ skill, leader, faceUp, captured });
+
+void test('all five direct families use the physical selected card role for normal +1 or skilled +3 without +4', () => {
+ const cases: [DirectLeaderSkillId, Card | undefined, Card | undefined][] = [
+ ['warmaster', one((card) => card.kind === 'worthless'), undefined],
+ [
+ 'master-of-assassins',
+ one((card) => card.kind === 'chemistry'),
+ undefined,
+ ],
+ [
+ 'swordmaster-of-ginaz',
+ one((card) => card.kind === 'weirdingWay'),
+ undefined,
+ ],
+ ['killer-medic', undefined, one((card) => card.kind === 'chemistry')],
+ [
+ 'prana-bindu-adept',
+ undefined,
+ one((card) => card.kind === 'weirdingWay'),
+ ],
+ ];
+ for (const [skill, weapon, defense] of cases) {
+ assert.deepEqual(
+ leaderSkillBattleBonus({
+ assignments: [assignment(skill)],
+ selectedLeader: selected,
+ weapon,
+ defense,
+ skilledLeaderSurvives: true,
+ }),
+ { bonus: 3, applied: [{ skill, amount: 3, mode: 'skilled' }] },
+ );
+ assert.deepEqual(
+ leaderSkillBattleBonus({
+ assignments: [assignment(skill, 'trainer', true)],
+ selectedLeader: selected,
+ weapon,
+ defense,
+ skilledLeaderSurvives: true,
+ }),
+ { bonus: 1, applied: [{ skill, amount: 1, mode: 'normal' }] },
+ );
+ }
+});
+
+void test('dead, bluffed, face-up selected and captured assignments preserve their distinct effect bands', () => {
+ const weapon = one((card) => card.kind === 'projectile');
+ const quote = (skill: BattleLeaderSkill, survives = true) =>
+ leaderSkillBattleBonus({
+ assignments: [skill],
+ selectedLeader: selected,
+ weapon,
+ defense: undefined,
+ skilledLeaderSurvives: survives,
+ });
+ assert.equal(quote(assignment('swordmaster-of-ginaz'), false).bonus, 0);
+ assert.equal(
+ quote(assignment('swordmaster-of-ginaz', 'trainer', false)).bonus,
+ 0,
+ 'A concealed bluff supplies neither effect band.',
+ );
+ assert.equal(
+ quote(assignment('swordmaster-of-ginaz', selected.id, true)).bonus,
+ 0,
+ 'A still-public skilled disc cannot receive its lower battle effect.',
+ );
+ assert.equal(
+ quote(assignment('swordmaster-of-ginaz', 'trainer', true, true)).bonus,
+ 0,
+ 'A captured assignment cannot train another leader.',
+ );
+ assert.equal(
+ quote(assignment('swordmaster-of-ginaz', selected.id, false, true)).bonus,
+ 3,
+ 'A surviving captured skilled leader retains only its lower effect.',
+ );
+ assert.equal(
+ leaderSkillBattleBonus({
+ assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
+ selectedLeader: { id: 'cheap-hero', kind: 'hero' },
+ weapon,
+ defense: undefined,
+ skilledLeaderSurvives: true,
+ }).bonus,
+ 1,
+ 'Cheap Hero substitutes for another leader for the public normal band.',
+ );
+ assert.equal(
+ leaderSkillBattleBonus({
+ assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
+ selectedLeader: undefined,
+ weapon,
+ defense: undefined,
+ skilledLeaderSurvives: true,
+ }).bonus,
+ 0,
+ );
+});
+
+function battle(): BattleResolutionInput {
+ const side = (
+ id: string,
+ faction: 'emperor' | 'guild',
+ strength: number,
+ ): ResolutionCombatant => {
+ const leader = { ...leaders(faction)[0], strength };
+ return {
+ id,
+ faction,
+ spice: 10,
+ hand: [],
+ leader,
+ plan: {
+ dial: 2,
+ support: 0,
+ leader: leader.id,
+ weapon: null,
+ defense: null,
+ },
+ forces: {
+ normal: 5,
+ elite: 0,
+ eliteStrength: 2,
+ freeSupport: false,
+ },
+ };
+ };
+ return {
+ advanced: false,
+ turn: 2,
+ territory: 'arrakeen',
+ attacker: side('a', 'emperor', 3),
+ defender: side('d', 'guild', 2),
+ voters: [
+ { id: 'a', beneficiary: 'a', called: false, traitors: [] },
+ { id: 'd', beneficiary: 'd', called: false, traitors: [] },
+ ],
+ participants: [
+ { id: 'a', faction: 'emperor' },
+ { id: 'd', faction: 'guild' },
+ ],
+ physicalCards: catalog(),
+ pendingAuditorPresent: false,
+ pendingRetentionPresent: false,
+ };
+}
+function play(
+ input: BattleResolutionInput,
+ side: 'attacker' | 'defender',
+ slot: 'leader' | 'weapon' | 'defense' | 'lateDefense',
+ predicate: (card: Card) => boolean,
+) {
+ const card = one(predicate);
+ input[side].hand = [...input[side].hand, card];
+ if (slot === 'lateDefense') input[side].lateDefense = card.id;
+ else input[side].plan[slot] = card.id;
+ if (slot === 'leader') delete input[side].leader;
+ return card;
+}
+
+void test('battle quote adds only surviving skill receipts to scores while preserving printed strength and bounty', () => {
+ const input = battle();
+ const worthless = play(
+ input,
+ 'attacker',
+ 'weapon',
+ (card) => card.kind === 'worthless',
+ );
+ input.attacker.leaderSkills = [
+ assignment('warmaster', input.attacker.leader!.id),
+ ];
+ const before = structuredClone(input);
+ const quote = quoteBattleResolution(input);
+ assert.deepEqual(quote.leaderStrengths, { attacker: 3, defender: 2 });
+ assert.deepEqual(quote.leaderSkillBonuses.attacker, {
+ bonus: 3,
+ applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }],
+ });
+ assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
+ assert.deepEqual(input, before);
+
+ const killed = structuredClone(input);
+ play(killed, 'defender', 'weapon', (card) => card.kind === 'poison');
+ const resolved = quoteBattleResolution(killed);
+ assert.equal(resolved.winner, 'd');
+ assert.equal(resolved.leaderSkillBonuses.attacker.bonus, 0);
+ assert.equal(resolved.scores!.attacker, 2);
+ assert.equal(resolved.bounty!.amount, 3);
+ assert.equal(killed.attacker.plan.weapon, worthless.id);
+});
+
+void test('a supplemental physical defense activates its matching surviving skill role', () => {
+ const input = battle();
+ const snooper = play(
+ input,
+ 'attacker',
+ 'lateDefense',
+ (card) => card.kind === 'snooper',
+ );
+ input.attacker.leaderSkills = [
+ assignment('killer-medic', input.attacker.leader!.id),
+ ];
+ const quote = quoteBattleResolution(input);
+ assert.equal(input.attacker.plan.defense, null);
+ assert.equal(input.attacker.lateDefense, snooper.id);
+ assert.deepEqual(quote.leaderSkillBonuses.attacker, {
+ bonus: 3,
+ applied: [{ skill: 'killer-medic', amount: 3, mode: 'skilled' }],
+ });
+ assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
+});
+
+void test('normal training follows a Cheap Hero substitution but traitor precedence suppresses skill scoring', () => {
+ const input = battle();
+ const hero = play(
+ input,
+ 'attacker',
+ 'leader',
+ (card) => card.kind === 'hero',
+ );
+ play(input, 'attacker', 'weapon', (card) => card.kind === 'projectile');
+ input.attacker.leaderSkills = [
+ assignment('swordmaster-of-ginaz', 'public-trainer', true),
+ ];
+ let quote = quoteBattleResolution(input);
+ assert.equal(quote.leaderStrengths.attacker, 0);
+ assert.equal(quote.leaderSkillBonuses.attacker.bonus, 1);
+ assert.equal(quote.scores!.attacker, 3);
+
+ input.voters = [
+ input.voters[0],
+ {
+ id: 'd',
+ beneficiary: 'd',
+ called: true,
+ traitors: [CHEAP_HERO_TRAITOR],
+ },
+ ];
+ quote = quoteBattleResolution(input);
+ assert.equal(quote.result, 'traitor');
+ assert.equal(quote.winner, 'd');
+ assert.equal(quote.scores, null);
+ assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
+ assert.equal(quote.bounty!.amount, 0);
+ assert.equal(hero.kind, 'hero');
+});
+
+void test('Stone Burner comparison remains independent of a surviving skilled bonus', () => {
+ const input = battle();
+ input.attacker.forces.freeSupport = true;
+ input.defender.forces.freeSupport = true;
+ input.attacker.plan.dial = 4;
+ input.defender.plan.dial = 1;
+ play(input, 'attacker', 'weapon', (card) => card.effect === 'stoneBurner');
+ play(input, 'attacker', 'defense', (card) => card.kind === 'snooper');
+ input.attacker.stoneMode = 'ignore';
+ input.attacker.leaderSkills = [
+ assignment('killer-medic', input.attacker.leader!.id),
+ ];
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.result, 'normal');
+ assert.equal(quote.leaderSkillBonuses.attacker.bonus, 3);
+ assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
+ assert.equal(quote.stone?.winner, 'defender');
+ assert.equal(quote.winner, 'd');
+});
diff --git a/tests/leader-skills-capture.test.ts b/tests/leader-skills-capture.test.ts
new file mode 100644
index 00000000..af5d1d4f
--- /dev/null
+++ b/tests/leader-skills-capture.test.ts
@@ -0,0 +1,483 @@
+import assert from 'node:assert/strict';
+import { mock } from 'node:test';
+import test from 'node:test';
+import { baseDeck, type Card } from '../game/cards';
+import {
+ applyAction,
+ createGame,
+ initializeLeaderSkillsGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { placeFixtureHand } from './fixture-hand';
+
+const SEATS = [
+ ['e', 'Emperor seat', 'emperor'],
+ ['h', 'Harkonnen seat', 'harkonnen'],
+ ['a', 'Atreides seat', 'atreides'],
+ ['b', 'Bene Gesserit seat', 'beneGesserit'],
+ ['f', 'Fremen seat', 'fremen'],
+ ['g', 'Guild seat', 'guild'],
+] as const;
+
+function assertSkillCustody(g: Game) {
+ assert.ok(g.leaderSkills);
+ const physical = [
+ ...g.leaderSkills.deck,
+ ...g.leaderSkills.assignments.map((assignment) => assignment.skill),
+ ...Object.values(g.leaderSkills.offers).flatMap((offer) => offer.cards),
+ ];
+ assert.equal(physical.length, 14);
+ assert.equal(new Set(physical).size, 14);
+ assert.deepEqual(
+ [...physical].sort(),
+ LEADER_SKILL_CARDS.map((card) => card.id).sort(),
+ );
+}
+
+/** Put Warmaster in the Emperor's genuine two-card offer. */
+function beginSetup(): Game {
+ let g = createGame(
+ 'SKILLCAP',
+ newPlayer(SEATS[0][0], SEATS[0][1], SEATS[0][2]),
+ );
+ for (const [id, name, faction] of SEATS.slice(1))
+ joinGame(g, newPlayer(id, name, faction));
+ for (const [id] of SEATS) g = applyAction(g, id, { type: 'ready' });
+
+ const targetIndex = LEADER_SKILL_CARDS.findIndex(
+ (card) => card.id === 'warmaster',
+ );
+ let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
+ mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
+ array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
+ return array;
+ });
+ try {
+ g = initializeLeaderSkillsGameForAudit(g);
+ } finally {
+ mock.restoreAll();
+ }
+
+ assert.equal(g.setupStage, 'prediction');
+ g = applyAction(g, 'b', {
+ type: 'predict',
+ faction: 'atreides',
+ turn: 4,
+ });
+ assert.equal(g.setupStage, 'leaderSkills');
+ assert.ok(g.leaderSkills?.offers.e.cards.includes('warmaster'));
+ assertSkillCustody(g);
+ return g;
+}
+
+function finishSetup(
+ state: Game,
+ emperorSkillLeader: 'emperor-1' | 'emperor-2',
+): Game {
+ let g = state;
+ for (const [id] of SEATS) {
+ const view = viewGame(g, id);
+ const offer = view.leaderSkills!.offer!;
+ g = applyAction(g, id, {
+ type: 'leaderSkill',
+ event: offer.event,
+ skill: id === 'e' ? 'warmaster' : offer.cards[0],
+ leader:
+ id === 'e'
+ ? emperorSkillLeader
+ : view.leaderSkills!.eligibleLeaders[0].id,
+ });
+ }
+ assert.equal(g.setupStage, 'traitors');
+ while (g.setupStage === 'traitors') {
+ const owner = g.players.find((player) => player.traitorChoices.length);
+ assert.ok(owner);
+ g = applyAction(g, owner.id, {
+ type: 'traitor',
+ leader: owner.traitorChoices[0],
+ });
+ }
+ assert.equal(g.setupStage, 'forces');
+ g = applyAction(g, 'f', {
+ type: 'fremenSetup',
+ placements: { sietch_tabr: 10 },
+ });
+ assert.equal(g.status, 'playing');
+ assertSkillCustody(g);
+ return g;
+}
+
+function takeCard(kind: Card['kind'], excluded: readonly string[] = []): Card {
+ const card = baseDeck().find(
+ (candidate) => candidate.kind === kind && !excluded.includes(candidate.id),
+ );
+ assert.ok(card, `expected a physical ${kind} card`);
+ return card;
+}
+
+function passResponses(state: Game): Game {
+ let g = state;
+ while (g.response) {
+ const owner = g.players.find(
+ (player) => !g.response!.passed.includes(player.id),
+ );
+ assert.ok(owner);
+ g = applyAction(g, owner.id, { type: 'passResponse' });
+ }
+ return g;
+}
+
+function clearBattlePreparation(state: Game): Game {
+ let g = state;
+ while (
+ g.decision?.kind === 'leaderSkillVisibility' ||
+ g.decision?.kind === 'fullPlanOffer' ||
+ g.battle?.preparation
+ )
+ if (g.decision?.kind === 'leaderSkillVisibility')
+ g = applyAction(g, g.decision.player, {
+ type: 'leaderSkillVisibility',
+ event: g.decision.event,
+ hide: false,
+ });
+ else if (g.decision?.kind === 'fullPlanOffer')
+ g = applyAction(g, g.decision.player, {
+ type: 'decision',
+ decline: true,
+ });
+ else
+ g = applyAction(g, g.battle!.preparation!.owner, {
+ type: 'declineBattlePower',
+ });
+ return g;
+}
+
+function resolveBattle(state: Game): Game {
+ let g = applyAction(state, 'h', { type: 'traitorCall', call: false });
+ g = applyAction(g, 'e', { type: 'traitorCall', call: false });
+ while (
+ g.decision?.kind === 'battleLosses' ||
+ g.decision?.kind === 'battleCards'
+ )
+ g = applyAction(
+ g,
+ g.decision.player,
+ g.decision.kind === 'battleLosses'
+ ? { type: 'decision', choice: 0 }
+ : { type: 'decision', discard: [] },
+ );
+ return g;
+}
+
+function captureOffer(
+ emperorSkillLeader: 'emperor-1' | 'emperor-2' = 'emperor-1',
+): Game {
+ let g = finishSetup(beginSetup(), emperorSkillLeader);
+ const emperor = g.players.find((player) => player.id === 'e')!;
+ const harkonnen = g.players.find((player) => player.id === 'h')!;
+ for (const player of g.players) {
+ g.deck.push(...player.hand);
+ player.hand = [];
+ player.traitors = [];
+ }
+ Object.assign(g, {
+ advanced: true,
+ phase: 6,
+ active: 'h',
+ order: ['h', 'e', 'a', 'b', 'f', 'g'],
+ ready: [],
+ storm: 18,
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ harkonnen.forces = { 'red_chasm:7': 10 };
+ harkonnen.reserves = 10;
+ emperor.forces = { 'red_chasm:7': 10 };
+ emperor.reserves = 10;
+ for (const leader of emperor.leaders.slice(2)) {
+ leader.dead = true;
+ leader.deaths = 1;
+ }
+ if (emperorSkillLeader === 'emperor-2') {
+ emperor.leaders[2].dead = false;
+ emperor.leaders[2].deaths = 0;
+ emperor.leaders[2].usedAt = 'arrakeen';
+ }
+ const poison = takeCard('poison');
+ placeFixtureHand(g, 1, [poison]);
+
+ g = applyAction(g, 'h', {
+ type: 'chooseBattle',
+ territory: 'red_chasm',
+ target: 'e',
+ });
+ g = clearBattlePreparation(g);
+ assert.equal(g.decision, null, JSON.stringify(g.decision));
+ g = applyAction(g, 'h', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'harkonnen-1',
+ weapon: poison.id,
+ });
+ g = applyAction(g, 'e', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'emperor-0',
+ });
+ g = resolveBattle(g);
+ assert.equal(g.decision?.kind, 'captureOffer');
+ return g;
+}
+
+function acceptCapture(state: Game): Game {
+ let g = applyAction(state, 'h', { type: 'decision', accept: true });
+ g = passResponses(g);
+ assert.equal(g.decision?.kind, 'capturedLeader');
+ return g;
+}
+
+void test('the genuine audit setup offers eligible leaders to all six base factions including Harkonnen', () => {
+ const g = beginSetup();
+ assert.deepEqual(
+ new Set(g.players.map((player) => player.faction)),
+ new Set(SEATS.map(([, , faction]) => faction)),
+ );
+ for (const [id, , faction] of SEATS) {
+ const view = viewGame(g, id);
+ assert.equal(view.leaderSkills?.offer?.cards.length, 2, faction);
+ assert.equal(view.leaderSkills?.eligibleLeaders.length, 5, faction);
+ assert.ok(
+ view.leaderSkills!.eligibleLeaders.every((leader) =>
+ leader.id.startsWith(`${faction}-`),
+ ),
+ faction,
+ );
+ }
+ assert.doesNotThrow(() => finishSetup(g, 'emperor-1'));
+});
+
+void test('a captured skilled leader and attached card become public while replacement revival remains rejected atomically', () => {
+ const g = acceptCapture(captureOffer());
+ for (const [viewer] of SEATS) {
+ const view = viewGame(g, viewer);
+ assert.deepEqual(
+ view.leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === 'e',
+ ),
+ {
+ owner: 'e',
+ leader: 'emperor-1',
+ skill: 'warmaster',
+ controller: 'h',
+ captured: true,
+ faceUp: false,
+ },
+ );
+ assert.equal(
+ view.players
+ .find((player) => player.id === 'e')!
+ .leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
+ 'h',
+ );
+ assert.equal(
+ view.decision?.kind === 'capturedLeader' && view.decision.leader,
+ 'emperor-1',
+ );
+ }
+ assert.ok(
+ g.log.some(
+ (entry) =>
+ entry.text.includes('Captain Aramsham') &&
+ entry.text.includes('Warmaster'),
+ ),
+ );
+
+ const kept = applyAction(g, 'h', { type: 'decision', mode: 'keep' });
+ kept.phase = 4;
+ kept.decision = null;
+ kept.response = null;
+ const emperor = kept.players.find((player) => player.id === 'e')!;
+ emperor.spice = 20;
+ emperor.leaderRevived = false;
+ const before = structuredClone(kept);
+ assert.throws(
+ () =>
+ applyAction(kept, 'e', {
+ type: 'reviveLeader',
+ leader: 'emperor-0',
+ }),
+ /replacement-skill ruling/,
+ );
+ assert.deepEqual(kept, before);
+ assertSkillCustody(kept);
+});
+
+void test('the captor gets only the lower battle bonus and a surviving captive returns with the same skill', () => {
+ let g = applyAction(acceptCapture(captureOffer()), 'h', {
+ type: 'decision',
+ mode: 'keep',
+ });
+ const emperor = g.players.find((player) => player.id === 'e')!;
+ const harkonnen = g.players.find((player) => player.id === 'h')!;
+ emperor.leaders[2].dead = false;
+ emperor.leaders[2].deaths = 0;
+ delete emperor.leaders[2].usedAt;
+ Object.assign(g, {
+ phase: 6,
+ active: 'h',
+ ready: [],
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ harkonnen.forces = { 'red_chasm:7': 10 };
+ harkonnen.reserves = 10;
+ emperor.forces = { 'red_chasm:7': 10 };
+ emperor.reserves = 10;
+ for (const player of g.players) {
+ g.deck.push(...player.hand);
+ player.hand = [];
+ player.traitors = [];
+ }
+ const firstWorthless = takeCard('worthless');
+ const secondWorthless = takeCard('worthless', [firstWorthless.id]);
+ placeFixtureHand(g, 1, [firstWorthless]);
+ placeFixtureHand(g, 0, [secondWorthless]);
+
+ g = applyAction(g, 'h', {
+ type: 'chooseBattle',
+ territory: 'red_chasm',
+ target: 'e',
+ });
+ g = clearBattlePreparation(g);
+ g = applyAction(g, 'h', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'emperor-1',
+ weapon: firstWorthless.id,
+ });
+ g = applyAction(g, 'e', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'emperor-2',
+ weapon: secondWorthless.id,
+ });
+ g = resolveBattle(g);
+
+ assert.ok(
+ g.log.some((entry) =>
+ entry.text.includes('Harkonnen won in Red Chasm (8–3)'),
+ ),
+ );
+ const skillLogs = g.log.filter(
+ (entry) => entry.automatic?.name === 'Leader Skill',
+ );
+ assert.ok(
+ skillLogs.some(
+ (entry) =>
+ entry.text.includes('Warmaster') &&
+ entry.text.includes('gained 3 battle strength'),
+ ),
+ );
+ assert.ok(
+ skillLogs.every(
+ (entry) =>
+ !entry.text.includes('Warmaster') ||
+ !entry.text.includes('gained 1 battle strength'),
+ ),
+ );
+ assert.equal(
+ g.players
+ .find((player) => player.id === 'e')!
+ .leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
+ undefined,
+ );
+ for (const [viewer] of SEATS)
+ assert.deepEqual(
+ viewGame(g, viewer).leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === 'e',
+ ),
+ {
+ owner: 'e',
+ leader: 'emperor-1',
+ skill: 'warmaster',
+ controller: 'e',
+ captured: false,
+ faceUp: true,
+ },
+ );
+ assertSkillCustody(g);
+});
+
+void test('executing the known skilled captive returns exactly that card and keeps the inferred identity public', () => {
+ let g = acceptCapture(captureOffer());
+ g = applyAction(g, 'h', { type: 'decision', mode: 'execute' });
+ assert.equal(
+ g.leaderSkills!.assignments.some(
+ (assignment) => assignment.leader === 'emperor-1',
+ ),
+ false,
+ );
+ assert.equal(
+ g.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length,
+ 1,
+ );
+ for (const [viewer] of SEATS) {
+ const leader = viewGame(g, viewer)
+ .players.find((player) => player.id === 'e')!
+ .leaders.find((candidate) => candidate.id === 'emperor-1')!;
+ assert.equal(leader.dead, true);
+ assert.equal(leader.deaths, 2);
+ }
+ assert.ok(
+ g.log.some(
+ (entry) =>
+ entry.automatic?.name === 'Leader Skill lost' &&
+ entry.text.includes('Warmaster'),
+ ),
+ );
+ assertSkillCustody(g);
+});
+
+void test('capturing an unskilled leader preserves the existing private identity boundary', () => {
+ const offered = captureOffer('emperor-2');
+ const outsiderBefore = viewGame(offered, 'g').players.find(
+ (player) => player.id === 'e',
+ )!.leaders;
+ const g = acceptCapture(offered);
+ assert.equal(g.players[0].leaders[1].capturedBy, 'h');
+ assert.deepEqual(
+ viewGame(g, 'g').players.find((player) => player.id === 'e')!.leaders,
+ outsiderBefore,
+ );
+ const outsiderDecision = viewGame(g, 'g').decision;
+ const captorDecision = viewGame(g, 'h').decision;
+ assert.equal(
+ outsiderDecision?.kind === 'capturedLeader' && outsiderDecision.leader,
+ '',
+ );
+ assert.equal(
+ captorDecision?.kind === 'capturedLeader' && captorDecision.leader,
+ 'emperor-1',
+ );
+ assert.deepEqual(
+ viewGame(g, 'g').leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === 'e',
+ ),
+ {
+ owner: 'e',
+ leader: 'emperor-2',
+ skill: 'warmaster',
+ controller: 'e',
+ captured: false,
+ faceUp: true,
+ },
+ );
+ assert.ok(g.log.every((entry) => !entry.text.includes('Captain Aramsham')));
+ assertSkillCustody(g);
+});
diff --git a/tests/leader-skills-controls.test.ts b/tests/leader-skills-controls.test.ts
new file mode 100644
index 00000000..6898dfee
--- /dev/null
+++ b/tests/leader-skills-controls.test.ts
@@ -0,0 +1,213 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { LeaderSkillsPanel } from '../components/leader-skills';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import type { LeaderSkillsView } from '../game/leader-skills';
+
+const leaders = [
+ { id: 'duke-leto', name: 'Duke Leto Atreides' },
+ { id: 'lady-jessica', name: 'Lady Jessica' },
+ { id: 'thufir-hawat', name: 'Thufir Hawat' },
+] as const;
+const players = [
+ { id: 'a', name: 'Atreides' },
+ { id: 'e', name: 'Emperor' },
+] as const;
+
+function view(overrides: Partial = {}): LeaderSkillsView {
+ return {
+ assignments: [],
+ offer: null,
+ eligibleLeaders: [],
+ battleChoice: null,
+ ...overrides,
+ };
+}
+
+function markup(skills: LeaderSkillsView | null, busy = false): string {
+ return renderToStaticMarkup(
+ createElement(LeaderSkillsPanel, {
+ skills,
+ leaders,
+ players,
+ busy,
+ act() {},
+ }),
+ );
+}
+
+function disabledControls(html: string): number {
+ return (html.match(/<(?:button|input|select)\b[^>]*\sdisabled=""/g) ?? [])
+ .length;
+}
+
+void test('renders nothing when the projected module is absent and never invents a private offer', () => {
+ assert.equal(markup(null), '');
+
+ const publicOnly = markup(
+ view({
+ assignments: [
+ {
+ owner: 'a',
+ controller: 'a',
+ captured: false,
+ leader: 'duke-leto',
+ skill: 'warmaster',
+ faceUp: true,
+ },
+ ],
+ }),
+ );
+ assert.match(publicOnly, /Public Leader Skill assignments/);
+ assert.match(publicOnly, /Atreides · Duke Leto Atreides · Face up/);
+ assert.doesNotMatch(
+ publicOnly,
+ /Your private offer|Choose one private skill/,
+ );
+ assert.doesNotMatch(publicOnly, /Bureaucrat|Spice Banker|Diplomat/);
+});
+
+void test('every public assignment card exposes its complete canonical normal and battle text', () => {
+ for (const card of LEADER_SKILL_CARDS) {
+ const html = markup(
+ view({
+ assignments: [
+ {
+ owner: 'a',
+ controller: 'a',
+ captured: false,
+ leader: 'duke-leto',
+ skill: card.id,
+ faceUp: false,
+ },
+ ],
+ }),
+ );
+ assert.match(
+ html,
+ new RegExp(`aria-label="${card.name} Leader Skill card"`),
+ );
+ assert.match(html, new RegExp(`Inspect ${card.name}`));
+ assert.match(html, /Atreides · Duke Leto Atreides · Behind shield/);
+ assert.ok(html.includes(`aria-label="${card.name} normal effect"`));
+ assert.ok(html.includes(`aria-label="${card.name} battle effect"`));
+ for (const line of [...card.normal, ...card.battle])
+ assert.ok(html.includes(line), `${card.name} omitted: ${line}`);
+ }
+});
+
+void test('a private setup offer renders only its two physical cards and eligible leader selection', () => {
+ const skills = view({
+ offer: {
+ event: 'setup-event',
+ cards: ['bureaucrat', 'diplomat'],
+ leader: null,
+ },
+ eligibleLeaders: [leaders[0], leaders[1]],
+ });
+ const html = markup(skills);
+
+ assert.match(html, /aria-label="Choose a Leader Skill"/);
+ assert.match(html, /Keep Bureaucrat/);
+ assert.match(html, /Keep Diplomat/);
+ assert.match(html, /Bureaucrat Leader Skill card/);
+ assert.match(html, /Diplomat Leader Skill card/);
+ assert.doesNotMatch(
+ html,
+ /Mentat Leader Skill card|Spice Banker Leader Skill card/,
+ );
+ assert.match(html, /aria-label="Eligible Leader Skill leader"/);
+ assert.match(html, /Duke Leto Atreides/);
+ assert.match(html, /Lady Jessica/);
+ assert.match(html, /Assign selected skill/);
+ assert.equal((html.match(/type="radio"/g) ?? []).length, 2);
+ assert.equal((html.match(/checked=""/g) ?? []).length, 1);
+
+ const disabled = markup(skills, true);
+ assert.equal(disabledControls(disabled), 4);
+});
+
+void test('revival asks before preview and does not offer a decline after cards are drawn', () => {
+ const undrawn = markup(
+ view({
+ offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
+ eligibleLeaders: [leaders[2]],
+ }),
+ );
+ assert.match(undrawn, /Skill for Thufir Hawat/);
+ assert.match(undrawn, /before seeing either private card/);
+ assert.match(undrawn, /Draw two skills/);
+ assert.match(undrawn, /Continue without a skill/);
+ assert.doesNotMatch(undrawn, /Bureaucrat|Diplomat|Your private offer/);
+ assert.equal(
+ disabledControls(
+ markup(
+ view({
+ offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
+ eligibleLeaders: [leaders[2]],
+ }),
+ true,
+ ),
+ ),
+ 2,
+ );
+
+ const drawn = markup(
+ view({
+ offer: {
+ event: 'revival-event',
+ cards: ['bureaucrat', 'diplomat'],
+ leader: 'thufir-hawat',
+ },
+ eligibleLeaders: [leaders[2]],
+ }),
+ );
+ assert.match(drawn, /Assigned leader: Thufir Hawat<\/strong>/);
+ assert.match(drawn, /choosing one is mandatory/);
+ assert.match(drawn, /Assign selected skill/);
+ assert.doesNotMatch(drawn, /Continue without a skill|Draw two skills/);
+ assert.doesNotMatch(drawn, /aria-label="Eligible Leader Skill leader"/);
+});
+
+void test('battle visibility choice names the skilled leader and disables both actions while busy', () => {
+ const skills = view({
+ assignments: [
+ {
+ owner: 'a',
+ controller: 'a',
+ captured: false,
+ leader: 'duke-leto',
+ skill: 'master-of-assassins',
+ faceUp: true,
+ },
+ ],
+ battleChoice: {
+ event: 'battle-event',
+ leader: 'duke-leto',
+ skill: 'master-of-assassins',
+ },
+ });
+ const html = markup(skills);
+ assert.match(html, /aria-label="Leader Skill battle visibility"/);
+ assert.match(html, /Duke Leto Atreides in this battle/);
+ assert.match(html, /Keep leader face up/);
+ assert.match(html, /Move leader behind shield/);
+ assert.equal((html.match(/disabled=""/g) ?? []).length, 0);
+ assert.equal(disabledControls(markup(skills, true)), 2);
+});
+
+void test('the panel states the exact five connected role modifiers and unfinished boundary', () => {
+ const html = markup(view());
+ for (const name of [
+ 'Warmaster',
+ 'Master of Assassins',
+ 'Swordmaster of Ginaz',
+ 'Killer Medic',
+ 'Prana-Bindu Adept',
+ ])
+ assert.match(html, new RegExp(name));
+ assert.match(html, /development-only module/);
+ assert.match(html, /Other Leader Skill effects are unfinished/);
+});
diff --git a/tests/leader-skills-engine.test.ts b/tests/leader-skills-engine.test.ts
new file mode 100644
index 00000000..3f6935bd
--- /dev/null
+++ b/tests/leader-skills-engine.test.ts
@@ -0,0 +1,558 @@
+import assert from 'node:assert/strict';
+import { mock } from 'node:test';
+import test from 'node:test';
+import type { Card } from '../game/cards';
+import {
+ applyAction,
+ createGame,
+ initializeLeaderSkillsGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import {
+ LEADER_SKILL_CARDS,
+ type LeaderSkillId,
+} from '../game/leader-skill-cards';
+import { newRevivalRules } from '../game/revival';
+
+const DIRECT_SKILLS = [
+ 'warmaster',
+ 'master-of-assassins',
+ 'swordmaster-of-ginaz',
+ 'killer-medic',
+ 'prana-bindu-adept',
+] as const;
+
+function rejectUnchanged(g: Game, id: string, action: Action, message: RegExp) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, id, action), message);
+ assert.deepEqual(g, before);
+}
+
+/** Put one requested physical skill first without bypassing the real setup deal. */
+function initialized(target: LeaderSkillId = 'warmaster'): Game {
+ let g = createGame('SKILLENG', newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+
+ const targetIndex = LEADER_SKILL_CARDS.findIndex(
+ (card) => card.id === target,
+ );
+ let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
+ mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
+ array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
+ return array;
+ });
+ try {
+ g = initializeLeaderSkillsGameForAudit(g);
+ } finally {
+ mock.restoreAll();
+ }
+ assert.equal(g.leaderSkills!.offers.a.cards[0], target);
+ return g;
+}
+
+function initializedVoiceGame(): Game {
+ let g = createGame('SKILLVOICE', newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
+ joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+
+ const targetIndex = LEADER_SKILL_CARDS.findIndex(
+ (card) => card.id === 'warmaster',
+ );
+ let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
+ mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
+ array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
+ return array;
+ });
+ try {
+ g = initializeLeaderSkillsGameForAudit(g);
+ } finally {
+ mock.restoreAll();
+ }
+ g = applyAction(g, 'b', {
+ type: 'predict',
+ faction: 'atreides',
+ turn: 3,
+ });
+ for (const id of ['a', 'b', 'e']) {
+ const skills = viewGame(g, id).leaderSkills!;
+ g = applyAction(g, id, {
+ type: 'leaderSkill',
+ event: skills.offer!.event,
+ skill: skills.offer!.cards[0],
+ leader: skills.eligibleLeaders[0].id,
+ });
+ }
+ return finishSetup(g);
+}
+
+function assignSetup(state: Game, skill: LeaderSkillId): Game {
+ let g = state;
+ const atreides = g.leaderSkills!.offers.a;
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event: atreides.event,
+ skill,
+ leader: 'atreides-0',
+ });
+ const emperor = g.leaderSkills!.offers.e;
+ g = applyAction(g, 'e', {
+ type: 'leaderSkill',
+ event: emperor.event,
+ skill: emperor.cards[0],
+ leader: 'emperor-0',
+ });
+ return g;
+}
+
+function finishSetup(state: Game): Game {
+ let g = state;
+ for (let step = 0; step < 8 && g.status === 'setup'; step += 1) {
+ const owner = g.players.find((player) => player.traitorChoices.length > 0);
+ assert.ok(owner, `setup stalled at ${g.setupStage}`);
+ g = applyAction(g, owner.id, {
+ type: 'traitor',
+ leader: owner.traitorChoices[0],
+ });
+ }
+ assert.equal(g.status, 'playing');
+ return g;
+}
+
+function takeCard(g: Game, owner: string, predicate: (card: Card) => boolean) {
+ const index = g.deck.findIndex(predicate);
+ assert.ok(index >= 0, 'the requested physical battle card must exist');
+ const [card] = g.deck.splice(index, 1);
+ g.players.find((player) => player.id === owner)!.hand.push(card);
+ return card;
+}
+
+function battleFor(
+ skill: (typeof DIRECT_SKILLS)[number],
+ hide: boolean,
+ onlySkilledLeader = false,
+) {
+ const g = finishSetup(assignSetup(initialized(skill), skill));
+ const attacker = g.players.find((player) => player.id === 'a')!;
+ const defender = g.players.find((player) => player.id === 'e')!;
+ attacker.forces = { 'arrakeen:10': 5 };
+ attacker.reserves = 15;
+ defender.forces = { 'arrakeen:10': 5 };
+ defender.reserves = 15;
+ Object.assign(g, {
+ phase: 6,
+ storm: 18,
+ order: ['a', 'e'],
+ active: 'a',
+ ready: [],
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ for (const player of g.players) {
+ g.deck.push(...player.hand);
+ player.hand = [];
+ }
+ if (onlySkilledLeader)
+ for (const leader of attacker.leaders.filter(
+ (leader) => leader.id !== 'atreides-0',
+ )) {
+ leader.dead = true;
+ leader.deaths = 1;
+ }
+ const role =
+ skill === 'warmaster'
+ ? takeCard(g, 'a', (card) => card.kind === 'worthless')
+ : skill === 'master-of-assassins'
+ ? takeCard(g, 'a', (card) => card.kind === 'poison')
+ : skill === 'swordmaster-of-ginaz'
+ ? takeCard(g, 'a', (card) => card.kind === 'projectile')
+ : skill === 'killer-medic'
+ ? takeCard(g, 'a', (card) => card.kind === 'snooper')
+ : takeCard(g, 'a', (card) => card.kind === 'shield');
+ let next = applyAction(g, 'a', {
+ type: 'chooseBattle',
+ territory: 'arrakeen',
+ target: 'e',
+ });
+ while (next.decision?.kind === 'leaderSkillVisibility') {
+ const decision = next.decision;
+ next = applyAction(next, decision.player, {
+ type: 'leaderSkillVisibility',
+ event: decision.event,
+ hide: decision.player === 'a' ? hide : false,
+ });
+ }
+ while (next.battle?.preparation)
+ next = applyAction(next, next.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ return { g: next, role };
+}
+
+function resolvePlans(state: Game, attackerLeader: string, role: Card): Game {
+ const slot =
+ role.kind === 'snooper' || role.kind === 'shield' ? 'defense' : 'weapon';
+ let g = applyAction(state, 'a', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: attackerLeader,
+ [slot]: role.id,
+ });
+ g = applyAction(g, 'e', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'emperor-1',
+ });
+ g = applyAction(g, 'a', { type: 'traitorCall', call: false });
+ return applyAction(g, 'e', { type: 'traitorCall', call: false });
+}
+
+function killedSkillBattle(): Game {
+ const { g: battle, role } = battleFor('master-of-assassins', true);
+ const poison = takeCard(
+ battle,
+ 'e',
+ (card) => card.kind === 'poison' && card.id !== role.id,
+ );
+ let g = applyAction(battle, 'a', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'atreides-0',
+ weapon: role.id,
+ });
+ g = applyAction(g, 'e', {
+ type: 'battlePlan',
+ dial: 1,
+ leader: 'emperor-1',
+ weapon: poison.id,
+ });
+ g = applyAction(g, 'a', { type: 'traitorCall', call: false });
+ return applyAction(g, 'e', { type: 'traitorCall', call: false });
+}
+
+void test('real setup deals private choices before traitors, then publishes only completed assignments across JSON views', () => {
+ let g = initialized();
+ assert.equal(g.setupStage, 'leaderSkills');
+ assert.ok(g.players.every((player) => player.hand.length === 1));
+ assert.ok(g.players.every((player) => player.traitorChoices.length === 0));
+ const aView = viewGame(g, 'a');
+ const eView = viewGame(g, 'e');
+ assert.equal(aView.leaderSkills!.offer!.cards.length, 2);
+ assert.equal(eView.leaderSkills!.offer!.cards.length, 2);
+ assert.notDeepEqual(
+ aView.leaderSkills!.offer!.cards,
+ eView.leaderSkills!.offer!.cards,
+ );
+ assert.equal('deck' in aView, false);
+ assert.equal('traitorReserve' in aView, false);
+
+ const event = g.leaderSkills!.offers.a.event;
+ rejectUnchanged(
+ g,
+ 'e',
+ {
+ type: 'leaderSkill',
+ event,
+ skill: 'warmaster',
+ leader: 'atreides-0',
+ },
+ /current setup step|current Leader Skill cards/,
+ );
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event,
+ skill: 'warmaster',
+ leader: 'atreides-0',
+ });
+ for (const viewer of ['a', 'e']) {
+ const view = viewGame(JSON.parse(JSON.stringify(g)), viewer);
+ assert.deepEqual(view.leaderSkills!.assignments, [
+ {
+ owner: 'a',
+ leader: 'atreides-0',
+ skill: 'warmaster',
+ controller: 'a',
+ captured: false,
+ faceUp: true,
+ },
+ ]);
+ assert.equal(
+ view.leaderSkills!.offer?.event ?? null,
+ g.leaderSkills!.offers[viewer]?.event ?? null,
+ );
+ }
+ g = assignSetup(initialized(), 'warmaster');
+ assert.equal(g.setupStage, 'traitors');
+});
+
+void test('each connected physical card role grants +3 only after its skilled leader moves behind the shield', () => {
+ for (const skill of DIRECT_SKILLS) {
+ const { g, role } = battleFor(skill, true);
+ assert.equal(
+ viewGame(g, 'a').leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === 'a',
+ )!.faceUp,
+ false,
+ );
+ const resolved = resolvePlans(g, 'atreides-0', role);
+ const defenderScore =
+ skill === 'master-of-assassins' || skill === 'swordmaster-of-ginaz'
+ ? 0
+ : 5;
+ assert.ok(
+ resolved.log.some((entry) =>
+ entry.text.includes(`Atreides won in Arrakeen (8–${defenderScore})`),
+ ),
+ `${skill} did not add three through the real engine battle`,
+ );
+ assert.ok(
+ resolved.log.some(
+ (entry) =>
+ entry.automatic?.name === 'Leader Skill' &&
+ entry.text.includes('gained 3 battle strength'),
+ ),
+ );
+ }
+});
+
+void test('a face-up skilled leader is inadmissible while another leader and Cheap Hero receive the normal +1', () => {
+ const { g, role } = battleFor('warmaster', false);
+ rejectUnchanged(
+ g,
+ 'a',
+ {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'atreides-0',
+ weapon: role.id,
+ },
+ /behind the shield/,
+ );
+ const resolved = resolvePlans(g, 'atreides-1', role);
+ assert.ok(
+ resolved.log.some((entry) =>
+ entry.text.includes('Atreides won in Arrakeen (6–5)'),
+ ),
+ );
+
+ const heroBattle = battleFor('warmaster', false);
+ const hero = takeCard(heroBattle.g, 'a', (card) => card.kind === 'hero');
+ const heroResolved = resolvePlans(heroBattle.g, hero.id, heroBattle.role);
+ assert.ok(
+ heroResolved.log.some((entry) =>
+ entry.text.includes('Emperor won in Arrakeen (1–5)'),
+ ),
+ );
+
+ const mandatory = battleFor('warmaster', true, true);
+ assert.equal(
+ viewGame(mandatory.g, 'a').leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === 'a',
+ )!.faceUp,
+ false,
+ );
+ assert.doesNotThrow(() =>
+ resolvePlans(mandatory.g, 'atreides-0', mandatory.role),
+ );
+});
+
+void test('a Voice that forbids the last alternate Cheap Hero automatically conceals the only skilled leader without replaying powers', () => {
+ let g = initializedVoiceGame();
+ for (const player of g.players) {
+ g.deck.push(...player.hand);
+ player.hand = [];
+ player.forces = {};
+ player.reserves = 20;
+ }
+ const heroIndex = g.deck.findIndex((card) => card.kind === 'hero');
+ assert.ok(heroIndex >= 0);
+ const [hero] = g.deck.splice(heroIndex, 1);
+ g.players.find((player) => player.id === 'a')!.hand.push(hero);
+ const atreides = g.players.find((player) => player.id === 'a')!;
+ atreides.forces = { 'arrakeen:10': 5 };
+ atreides.reserves = 15;
+ g.players.find((player) => player.id === 'b')!.forces = {
+ 'arrakeen:10': 5,
+ };
+ g.players.find((player) => player.id === 'b')!.reserves = 15;
+ for (const leader of atreides.leaders.filter(
+ (leader) => leader.id !== 'atreides-0',
+ )) {
+ leader.dead = true;
+ leader.deaths = 1;
+ }
+ Object.assign(g, {
+ phase: 6,
+ storm: 18,
+ order: ['a', 'b', 'e'],
+ active: 'a',
+ ready: [],
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ g = applyAction(g, 'a', {
+ type: 'chooseBattle',
+ territory: 'arrakeen',
+ target: 'b',
+ });
+ while (g.decision?.kind === 'leaderSkillVisibility')
+ g = applyAction(g, g.decision.player, {
+ type: 'leaderSkillVisibility',
+ event: g.decision.event,
+ hide: false,
+ });
+ assert.equal(g.battle!.preparation?.kind, 'voice');
+ g = applyAction(g, 'b', { type: 'voice', kind: 'hero', must: false });
+ assert.equal(g.battle!.leaderSkillHidden?.a, true);
+ assert.equal(
+ g.log.filter(
+ (entry) =>
+ entry.automatic?.name === 'Leader Skill' &&
+ entry.text.includes('behind the shield'),
+ ).length,
+ 1,
+ );
+ while (g.battle?.preparation)
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ g = applyAction(g, 'a', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: 'atreides-0',
+ });
+ assert.equal(g.battle!.plans.a.leader, 'atreides-0');
+ assert.equal(g.battle!.voice?.kind, 'hero');
+});
+
+void test('a killed skilled leader loses its card before scoring and a real revival privately offers a new draw', () => {
+ let g = killedSkillBattle();
+ assert.ok(
+ g.log.some((entry) => entry.text.includes('Emperor won in Arrakeen (0–1)')),
+ );
+ assert.equal(
+ g.leaderSkills!.assignments.some((assignment) => assignment.owner === 'a'),
+ false,
+ );
+ assert.ok(g.leaderSkills!.deck.includes('master-of-assassins'));
+
+ const atreides = g.players.find((player) => player.id === 'a')!;
+ for (const leader of atreides.leaders) {
+ leader.dead = true;
+ leader.deaths = Math.max(1, leader.deaths);
+ }
+ atreides.spice = 20;
+ atreides.leaderRevived = false;
+ g.phase = 4;
+ g.revivalRules = newRevivalRules();
+ g.decision = null;
+ g.response = null;
+ g = applyAction(g, 'a', {
+ type: 'reviveLeader',
+ leader: 'atreides-0',
+ });
+ assert.equal(g.decision?.kind, 'leaderSkillRevival');
+ assert.deepEqual(viewGame(g, 'a').leaderSkills!.offer!.cards, []);
+ assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
+
+ const event = g.decision!.event;
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event,
+ mode: 'draw',
+ });
+ const privateOffer = viewGame(JSON.parse(JSON.stringify(g)), 'a')
+ .leaderSkills!.offer!;
+ assert.equal(privateOffer.cards.length, 2);
+ assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event,
+ skill: privateOffer.cards[0],
+ leader: 'atreides-0',
+ });
+ assert.equal(g.leaderSkills!.offers.a, undefined);
+ assert.equal(
+ g.leaderSkills!.assignments.find((assignment) => assignment.owner === 'a')
+ ?.leader,
+ 'atreides-0',
+ );
+ assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(g)), 'e'));
+});
+
+void test('Ghola revival during an unfinished battle preserves its power stage through the private draw and new visibility choice', () => {
+ let g = killedSkillBattle();
+ const atreides = g.players.find((player) => player.id === 'a')!;
+ const emperor = g.players.find((player) => player.id === 'e')!;
+ atreides.forces['carthag:11'] = 1;
+ atreides.reserves -= 1;
+ emperor.forces['carthag:11'] = 1;
+ emperor.reserves -= 1;
+ const ghola = takeCard(g, 'a', (card) => card.effect === 'ghola');
+ Object.assign(g, {
+ phase: 6,
+ storm: 18,
+ order: ['a', 'e'],
+ active: 'a',
+ ready: [],
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ g = applyAction(g, 'a', {
+ type: 'chooseBattle',
+ territory: 'carthag',
+ target: 'e',
+ });
+ while (g.decision?.kind === 'leaderSkillVisibility')
+ g = applyAction(g, g.decision.player, {
+ type: 'leaderSkillVisibility',
+ event: g.decision.event,
+ hide: false,
+ });
+ assert.ok(g.battle!.preparation);
+ const preparation = structuredClone(g.battle!.preparation);
+
+ g = applyAction(g, 'a', {
+ type: 'card',
+ card: ghola.id,
+ leader: 'atreides-0',
+ });
+ assert.equal(g.decision?.kind, 'leaderSkillRevival');
+ assert.deepEqual(g.battle!.preparation, preparation);
+ const event = g.decision!.event;
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event,
+ mode: 'draw',
+ });
+ const offer = viewGame(g, 'a').leaderSkills!.offer!;
+ g = applyAction(g, 'a', {
+ type: 'leaderSkill',
+ event,
+ skill: offer.cards[0],
+ leader: 'atreides-0',
+ });
+ assert.equal(g.decision?.kind, 'leaderSkillVisibility');
+ assert.equal(g.decision!.resumePowers, false);
+ assert.deepEqual(g.battle!.preparation, preparation);
+ g = applyAction(g, 'a', {
+ type: 'leaderSkillVisibility',
+ event: g.decision!.event,
+ hide: true,
+ });
+ assert.equal(g.decision, null);
+ assert.deepEqual(g.battle!.preparation, preparation);
+ g = applyAction(g, preparation!.owner, { type: 'declineBattlePower' });
+ assert.notDeepEqual(g.battle?.preparation, preparation);
+ assert.equal(g.discard.filter((card) => card.id === ghola.id).length, 1);
+});
diff --git a/tests/leader-skills-recovery.test.ts b/tests/leader-skills-recovery.test.ts
new file mode 100644
index 00000000..8e5aea52
--- /dev/null
+++ b/tests/leader-skills-recovery.test.ts
@@ -0,0 +1,507 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import type { DatabaseSync } from 'node:sqlite';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import {
+ initializeLeaderSkillsGameForAudit,
+ type Action,
+ type Game,
+} from '../game/engine';
+import type { RoomsClock } from '../db/rooms';
+import { unitStore } from './fixture-nexus-room-store';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+
+function assertSkillCustody(game: Game): void {
+ assert.ok(game.leaderSkills);
+ const physical = [
+ ...game.leaderSkills.deck,
+ ...game.leaderSkills.assignments.map((assignment) => assignment.skill),
+ ...Object.values(game.leaderSkills.offers).flatMap((offer) => offer.cards),
+ ];
+ const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
+ assert.equal(physical.length, 14);
+ assert.equal(new Set(physical).size, 14);
+ assert.deepEqual([...physical].sort(), [...canonical].sort());
+}
+
+function concurrentBarrier() {
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ return {
+ wait: async () => {
+ arrivals += 1;
+ if (arrivals === 2) release();
+ await gate;
+ },
+ count: () => arrivals,
+ };
+}
+
+async function fixture() {
+ const store = unitStore();
+ const sqlite: DatabaseSync = store.sqlite;
+ const created = await store.rooms.createRoom(
+ 'Leader Skills host',
+ 'beneGesserit',
+ false,
+ [],
+ );
+ const code = created.view.code;
+ const tokens = [created.token!];
+ for (const [name, faction] of [
+ ['Fremen seat', 'fremen'],
+ ['Atreides seat', 'atreides'],
+ ] as const)
+ tokens.push((await store.rooms.joinRoom(code, name, faction)).token!);
+ const auths = await Promise.all(
+ tokens.map((token) => store.restart().authenticate(code, token)),
+ );
+
+ for (const auth of auths) {
+ const room = await store.restart().readRoom(code);
+ await store
+ .restart()
+ .act(code, auth, room.version, { type: 'ready' }, clock);
+ }
+ const ready = await store.restart().readRoom(code);
+ const initialized = initializeLeaderSkillsGameForAudit(ready);
+ sqlite
+ .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(initialized), initialized.version, code);
+ return { ...store, sqlite, code, tokens, auths };
+}
+
+async function act(
+ f: Awaited>,
+ player: number,
+ action: Action,
+) {
+ const before = await f.restart().readRoom(f.code);
+ await f.restart().act(f.code, f.auths[player], before.version, action, clock);
+ return f.restart().readRoom(f.code);
+}
+
+void test('SQLite restart keeps each private two-card offer and one concurrent selection consumes one card once', async () => {
+ const f = await fixture();
+ try {
+ let game = await f.restart().readRoom(f.code);
+ assert.equal(game.status, 'setup');
+ assert.equal(game.setupStage, 'prediction');
+ game = await act(f, 0, {
+ type: 'predict',
+ faction: 'atreides',
+ turn: 4,
+ });
+ assert.equal(game.setupStage, 'leaderSkills');
+ assertSkillCustody(game);
+
+ const initialOffers = structuredClone(game.leaderSkills!.offers);
+ assert.equal(Object.keys(initialOffers).length, 3);
+ for (const [index, auth] of f.auths.entries()) {
+ const seat = await f.restart().readSeatView(f.code, auth);
+ const owner = auth.playerId;
+ assert.deepEqual(seat.leaderSkills?.offer, initialOffers[owner]);
+ assert.equal(seat.leaderSkills?.assignments.length, 0);
+ assert.equal(seat.leaderSkills?.eligibleLeaders.length, 5);
+ for (const other of f.auths.filter((candidate) => candidate !== auth))
+ assert.notDeepEqual(
+ seat.leaderSkills?.offer?.cards,
+ initialOffers[other.playerId].cards,
+ );
+ assert.equal('deck' in seat.leaderSkills!, false);
+ assert.equal(seat.players[index].hand?.length, 1);
+ }
+
+ const owner = f.auths[0];
+ const ownerView = await f.restart().readSeatView(f.code, owner);
+ const offer = ownerView.leaderSkills!.offer!;
+ const chosenSkill = offer.cards[0];
+ const returnedSkill = offer.cards[1];
+ const chosenLeader = ownerView.leaderSkills!.eligibleLeaders[0].id;
+ const choice: Action = {
+ type: 'leaderSkill',
+ event: offer.event,
+ skill: chosenSkill,
+ leader: chosenLeader,
+ };
+ const beforeVersion = game.version;
+ const barrier = concurrentBarrier();
+ f.writes.length = 0;
+ f.hooks.beforeWrite = barrier.wait;
+ const results = await Promise.allSettled([
+ f.rooms.act(f.code, owner, beforeVersion, choice, clock),
+ f.restart().act(f.code, owner, beforeVersion, choice, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+
+ assert.equal(barrier.count(), 2);
+ assert.equal(
+ results.filter((result) => result.status === 'fulfilled').length,
+ 1,
+ );
+ assert.equal(
+ results.filter((result) => result.status === 'rejected').length,
+ 1,
+ );
+ assert.deepEqual(
+ f.writes.map((write) => write.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+
+ const selected = await f.restart().readRoom(f.code);
+ assert.equal(selected.version, beforeVersion + 1);
+ assert.equal(selected.leaderSkills!.offers[owner.playerId], undefined);
+ assert.deepEqual(
+ selected.leaderSkills!.assignments.filter(
+ (assignment) => assignment.owner === owner.playerId,
+ ),
+ [
+ {
+ owner: owner.playerId,
+ leader: chosenLeader,
+ skill: chosenSkill,
+ },
+ ],
+ );
+ assert.equal(
+ selected.leaderSkills!.deck.filter((skill) => skill === returnedSkill)
+ .length,
+ 1,
+ );
+ assertSkillCustody(selected);
+
+ const ownerAfter = await f.restart().readSeatView(f.code, owner);
+ const nextAfter = await f.restart().readSeatView(f.code, f.auths[1]);
+ assert.equal(ownerAfter.leaderSkills?.offer, null);
+ assert.deepEqual(
+ nextAfter.leaderSkills?.offer,
+ initialOffers[f.auths[1].playerId],
+ );
+ assert.deepEqual(
+ ownerAfter.leaderSkills?.assignments,
+ nextAfter.leaderSkills?.assignments,
+ );
+ const snapshot = structuredClone(selected);
+ await assert.rejects(
+ f.restart().act(f.code, owner, beforeVersion, choice, clock),
+ );
+ assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('real setup completion preserves prediction, starting hands, assignments and traitor choices across JSON restart', async () => {
+ const f = await fixture();
+ try {
+ let game = await act(f, 0, {
+ type: 'predict',
+ faction: 'atreides',
+ turn: 4,
+ });
+ assert.equal(game.setupStage, 'leaderSkills');
+ const prediction = structuredClone(game.players[0].prediction);
+ const startingHands = game.players.map((player) =>
+ player.hand.map((card) => card.id),
+ );
+
+ for (const [index, auth] of f.auths.entries()) {
+ const seat = await f.restart().readSeatView(f.code, auth);
+ const offer = seat.leaderSkills!.offer!;
+ game = await act(f, index, {
+ type: 'leaderSkill',
+ event: offer.event,
+ skill: offer.cards[0],
+ leader: seat.leaderSkills!.eligibleLeaders[0].id,
+ });
+ assertSkillCustody(game);
+ }
+ assert.equal(game.setupStage, 'traitors');
+ const assignments = structuredClone(game.leaderSkills!.assignments);
+
+ for (const [index] of f.auths.entries()) {
+ game = await f.restart().readRoom(f.code);
+ const choice = game.players[index].traitorChoices[0];
+ assert.ok(choice);
+ game = await act(f, index, { type: 'traitor', leader: choice });
+ }
+ assert.equal(game.setupStage, 'forces');
+ const traitors = game.players.map((player) => [...player.traitors]);
+ game = await act(f, 1, {
+ type: 'fremenSetup',
+ placements: { sietch_tabr: 10 },
+ });
+ assert.equal(game.status, 'playing');
+ assert.equal(game.setupStage, undefined);
+ assert.deepEqual(game.players[0].prediction, prediction);
+ assert.deepEqual(
+ game.players.map((player) => player.hand.map((card) => card.id)),
+ startingHands,
+ );
+ assert.deepEqual(
+ game.players.map((player) => player.traitors),
+ traitors,
+ );
+ assert.deepEqual(game.leaderSkills!.assignments, assignments);
+ assert.deepEqual(game.leaderSkills!.offers, {});
+ assertSkillCustody(game);
+
+ const persisted = structuredClone(game);
+ assert.deepEqual(await f.restart().readRoom(f.code), persisted);
+ for (const [index, auth] of f.auths.entries()) {
+ const seat = await f.restart().readSeatView(f.code, auth);
+ assert.deepEqual(
+ seat.leaderSkills?.assignments,
+ assignments.map((assignment) => ({
+ ...assignment,
+ controller: assignment.owner,
+ captured: false,
+ faceUp: true,
+ })),
+ );
+ assert.equal(seat.leaderSkills?.offer, null);
+ assert.deepEqual(
+ seat.players[index].hand?.map((card) => card.id),
+ startingHands[index],
+ );
+ for (const [otherIndex, player] of seat.players.entries())
+ if (otherIndex !== index) assert.equal(player.hand, undefined);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
+ const f = await fixture();
+ try {
+ let game = await act(f, 0, {
+ type: 'predict',
+ faction: 'atreides',
+ turn: 4,
+ });
+ for (const [index, auth] of f.auths.entries()) {
+ const seat = await f.restart().readSeatView(f.code, auth);
+ const offer = seat.leaderSkills!.offer!;
+ game = await act(f, index, {
+ type: 'leaderSkill',
+ event: offer.event,
+ skill: offer.cards[0],
+ leader: seat.leaderSkills!.eligibleLeaders[0].id,
+ });
+ }
+ for (const [index] of f.auths.entries()) {
+ game = await f.restart().readRoom(f.code);
+ game = await act(f, index, {
+ type: 'traitor',
+ leader: game.players[index].traitorChoices[0],
+ });
+ }
+ game = await act(f, 1, {
+ type: 'fremenSetup',
+ placements: { sietch_tabr: 10 },
+ });
+ assert.equal(game.status, 'playing');
+
+ const atreidesAuth = f.auths[2];
+ const bgAuth = f.auths[0];
+ const atreides = game.players.find(
+ (player) => player.id === atreidesAuth.playerId,
+ )!;
+ const bg = game.players.find((player) => player.id === bgAuth.playerId)!;
+ const oldAssignment = game.leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === atreides.id,
+ )!;
+ for (const player of game.players) {
+ game.deck.push(...player.hand);
+ player.hand = [];
+ player.reserves += Object.values(player.forces).reduce(
+ (sum, amount) => sum + amount,
+ 0,
+ );
+ player.forces = {};
+ }
+ const gholaIndex = game.deck.findIndex((card) => card.effect === 'ghola');
+ const poisonIndex = game.deck.findIndex((card) => card.kind === 'poison');
+ assert.ok(gholaIndex >= 0 && poisonIndex >= 0);
+ const [ghola] = game.deck.splice(gholaIndex, 1);
+ const adjustedPoisonIndex = game.deck.findIndex(
+ (card) => card.kind === 'poison',
+ );
+ const [poison] = game.deck.splice(adjustedPoisonIndex, 1);
+ atreides.hand.push(ghola);
+ bg.hand.push(poison);
+ atreides.reserves -= 5;
+ atreides.forces['arrakeen:10'] = 5;
+ bg.reserves -= 5;
+ bg.forces['arrakeen:10'] = 5;
+ Object.assign(game, {
+ phase: 6,
+ storm: 18,
+ order: [atreides.id, bg.id, f.auths[1].playerId],
+ active: atreides.id,
+ ready: [],
+ decision: null,
+ response: null,
+ phaseOpening: null,
+ });
+ f.sqlite
+ .prepare('UPDATE rooms SET state = ? WHERE code = ?')
+ .run(JSON.stringify(game), f.code);
+
+ game = await act(f, 2, {
+ type: 'chooseBattle',
+ territory: 'arrakeen',
+ target: bg.id,
+ });
+ while (game.decision?.kind === 'leaderSkillVisibility') {
+ const owner = f.auths.findIndex(
+ (auth) => auth.playerId === game.decision!.player,
+ );
+ assert.ok(owner >= 0);
+ game = await act(f, owner, {
+ type: 'leaderSkillVisibility',
+ event: game.decision.event,
+ hide: true,
+ });
+ }
+ while (game.battle?.preparation) {
+ const owner = f.auths.findIndex(
+ (auth) => auth.playerId === game.battle!.preparation!.owner,
+ );
+ assert.ok(owner >= 0);
+ game = await act(f, owner, { type: 'declineBattlePower' });
+ }
+ game = await act(f, 2, {
+ type: 'battlePlan',
+ dial: 0,
+ leader: oldAssignment.leader,
+ });
+ const bgLeader = game.leaderSkills!.assignments.find(
+ (assignment) => assignment.owner === bg.id,
+ )!.leader;
+ game = await act(f, 0, {
+ type: 'battlePlan',
+ dial: 1,
+ leader: bgLeader,
+ weapon: poison.id,
+ });
+ game = await act(f, 2, { type: 'traitorCall', call: false });
+ game = await act(f, 0, { type: 'traitorCall', call: false });
+
+ const dead = game.players
+ .find((player) => player.id === atreides.id)!
+ .leaders.find((leader) => leader.id === oldAssignment.leader)!;
+ assert.equal(dead.dead, true);
+ assert.equal(
+ game.leaderSkills!.assignments.some(
+ (assignment) => assignment.owner === atreides.id,
+ ),
+ false,
+ );
+ assert.equal(
+ game.leaderSkills!.deck.filter((skill) => skill === oldAssignment.skill)
+ .length,
+ 1,
+ );
+ assertSkillCustody(game);
+ if (game.decision?.kind === 'battleCards')
+ game = await act(
+ f,
+ f.auths.findIndex((auth) => auth.playerId === game.decision!.player),
+ { type: 'decision', discard: [] },
+ );
+
+ game = await act(f, 2, {
+ type: 'card',
+ card: ghola.id,
+ leader: oldAssignment.leader,
+ });
+ assert.equal(game.decision?.kind, 'leaderSkillRevival');
+ assert.equal(game.discard.filter((card) => card.id === ghola.id).length, 1);
+ const undrawn = await f.restart().readSeatView(f.code, atreidesAuth);
+ assert.deepEqual(undrawn.leaderSkills!.offer!.cards, []);
+ assert.equal(
+ (await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
+ null,
+ );
+
+ const event = undrawn.leaderSkills!.offer!.event;
+ game = await act(f, 2, {
+ type: 'leaderSkill',
+ event,
+ mode: 'draw',
+ });
+ const drawn = await f.restart().readSeatView(f.code, atreidesAuth);
+ const cards = [...drawn.leaderSkills!.offer!.cards];
+ assert.equal(cards.length, 2);
+ assert.equal(
+ (await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
+ null,
+ );
+ const choice: Action = {
+ type: 'leaderSkill',
+ event,
+ skill: cards[0],
+ leader: oldAssignment.leader,
+ };
+ const beforeVersion = game.version;
+ const barrier = concurrentBarrier();
+ f.writes.length = 0;
+ f.hooks.beforeWrite = barrier.wait;
+ const results = await Promise.allSettled([
+ f.rooms.act(f.code, atreidesAuth, beforeVersion, choice, clock),
+ f.restart().act(f.code, atreidesAuth, beforeVersion, choice, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(barrier.count(), 2);
+ assert.equal(
+ results.filter((result) => result.status === 'fulfilled').length,
+ 1,
+ );
+ assert.equal(
+ results.filter((result) => result.status === 'rejected').length,
+ 1,
+ );
+ assert.deepEqual(
+ f.writes.map((write) => write.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+
+ const selected = await f.restart().readRoom(f.code);
+ assert.equal(selected.version, beforeVersion + 1);
+ assert.deepEqual(
+ selected.leaderSkills!.assignments.filter(
+ (assignment) => assignment.owner === atreides.id,
+ ),
+ [
+ {
+ owner: atreides.id,
+ leader: oldAssignment.leader,
+ skill: cards[0],
+ },
+ ],
+ );
+ assert.equal(
+ selected.leaderSkills!.deck.filter((skill) => skill === cards[0]).length,
+ 0,
+ );
+ assert.equal(
+ selected.leaderSkills!.deck.filter((skill) => skill === cards[1]).length,
+ 1,
+ );
+ assertSkillCustody(selected);
+
+ const snapshot = structuredClone(selected);
+ await assert.rejects(
+ f.restart().act(f.code, atreidesAuth, selected.version, choice, clock),
+ );
+ assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/leader-skills.test.ts b/tests/leader-skills.test.ts
new file mode 100644
index 00000000..c87653b8
--- /dev/null
+++ b/tests/leader-skills.test.ts
@@ -0,0 +1,414 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ LEADER_SKILL_CARDS,
+ type LeaderSkillId,
+} from '../game/leader-skill-cards';
+import {
+ LeaderSkillError,
+ chooseLeaderSkill,
+ createLeaderSkills,
+ dealLeaderSkills,
+ declineRevivedLeaderSkill,
+ drawRevivedLeaderSkills,
+ offerRevivedLeaderSkill,
+ returnDeadLeaderSkills,
+ validateLeaderSkills,
+ type LeaderSkillsState,
+ type SkillRoster,
+} from '../game/leader-skills';
+
+const KEEP_ORDER = () => 0.999999;
+
+function playerIds(count: number): string[] {
+ return Array.from({ length: count }, (_, index) => `player-${index + 1}`);
+}
+
+function rosterFor(players: readonly string[]): SkillRoster {
+ return players.map((id) => ({
+ id,
+ leaders: [
+ { id: `${id}-leader-1`, dead: false },
+ { id: `${id}-leader-2`, dead: false },
+ ],
+ }));
+}
+
+function dealtState(players = ['a', 'b'] as const): LeaderSkillsState {
+ return dealLeaderSkills(createLeaderSkills(KEEP_ORDER), players, 'setup-1');
+}
+
+function expectImmutableRejection(
+ state: LeaderSkillsState,
+ action: () => unknown,
+ message: RegExp,
+): void {
+ const snapshot = structuredClone(state);
+ assert.throws(action, (error: unknown) => {
+ assert.ok(error instanceof LeaderSkillError);
+ assert.match(error.message, message);
+ return true;
+ });
+ assert.deepEqual(state, snapshot);
+}
+
+void test('deals every two-card setup hand before any card can return for two through six players', () => {
+ const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
+
+ for (let count = 2; count <= 6; count += 1) {
+ const players = playerIds(count);
+ const pristine = createLeaderSkills(KEEP_ORDER);
+ const snapshot = structuredClone(pristine);
+ const dealt = dealLeaderSkills(pristine, players, `setup-${count}`);
+
+ assert.deepEqual(pristine, snapshot);
+ assert.equal(Object.keys(dealt.offers).length, count);
+ assert.equal(dealt.deck.length, 14 - 2 * count);
+ assert.deepEqual(dealt.assignments, []);
+ assert.deepEqual(
+ players.flatMap((player) => dealt.offers[player].cards),
+ canonical.slice(0, 2 * count),
+ );
+ for (const player of players) {
+ assert.deepEqual(dealt.offers[player], {
+ event: `setup-${count}`,
+ cards: canonical.slice(
+ players.indexOf(player) * 2,
+ players.indexOf(player) * 2 + 2,
+ ),
+ leader: null,
+ });
+ }
+ validateLeaderSkills(dealt, rosterFor(players));
+ }
+});
+
+void test('chooses one physical card once, returns the rejected card, and preserves all fourteen cards', () => {
+ const before = dealtState();
+ const snapshot = structuredClone(before);
+ const [kept, returned] = before.offers.a.cards;
+ const chosen = chooseLeaderSkill(
+ before,
+ 'a',
+ 'setup-1',
+ kept,
+ 'a-leader-1',
+ ['a-leader-1', 'a-leader-2'],
+ KEEP_ORDER,
+ );
+
+ assert.deepEqual(before, snapshot);
+ assert.deepEqual(chosen.assignments, [
+ { owner: 'a', leader: 'a-leader-1', skill: kept },
+ ]);
+ assert.equal(chosen.offers.a, undefined);
+ assert.ok(chosen.deck.includes(returned));
+ assert.equal(chosen.deck.includes(kept), false);
+ validateLeaderSkills(chosen, rosterFor(['a', 'b']));
+
+ expectImmutableRejection(
+ chosen,
+ () =>
+ chooseLeaderSkill(
+ chosen,
+ 'a',
+ 'setup-1',
+ kept,
+ 'a-leader-2',
+ ['a-leader-2'],
+ KEEP_ORDER,
+ ),
+ /current Leader Skill cards|already has an assigned Leader Skill/,
+ );
+});
+
+void test('saved custody rejects an offer for an owner who already has an assigned skill', () => {
+ const offered = dealtState();
+ const assigned = chooseLeaderSkill(
+ offered,
+ 'a',
+ 'setup-1',
+ offered.offers.a.cards[0],
+ 'a-leader-1',
+ ['a-leader-1', 'a-leader-2'],
+ KEEP_ORDER,
+ );
+ const invalid = structuredClone(assigned);
+ invalid.offers.a = {
+ event: 'impossible-second-offer',
+ cards: invalid.deck.splice(0, 2),
+ leader: null,
+ };
+ assert.throws(
+ () => validateLeaderSkills(invalid, rosterFor(['a', 'b'])),
+ /assignment|offer|original skill/i,
+ );
+});
+
+void test('rejects the wrong owner, event, physical card, or leader without changing custody', () => {
+ const state = dealtState();
+ const card = state.offers.a.cards[0];
+ const unofferedCard = state.deck[0];
+
+ const attempts: [() => unknown, RegExp][] = [
+ [
+ () =>
+ chooseLeaderSkill(
+ state,
+ 'intruder',
+ 'setup-1',
+ card,
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ ),
+ /current Leader Skill cards/,
+ ],
+ [
+ () =>
+ chooseLeaderSkill(
+ state,
+ 'a',
+ 'stale-event',
+ card,
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ ),
+ /current Leader Skill cards/,
+ ],
+ [
+ () =>
+ chooseLeaderSkill(
+ state,
+ 'a',
+ 'setup-1',
+ unofferedCard,
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ ),
+ /current Leader Skill cards/,
+ ],
+ [
+ () =>
+ chooseLeaderSkill(
+ state,
+ 'a',
+ 'setup-1',
+ card,
+ 'a-captured-or-dead-leader',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ ),
+ /eligible living leader/,
+ ],
+ ];
+
+ for (const [action, message] of attempts)
+ expectImmutableRejection(state, action, message);
+
+ expectImmutableRejection(
+ state,
+ () =>
+ chooseLeaderSkill(
+ state,
+ 'a',
+ 'setup-1',
+ card,
+ 'a-leader-1',
+ ['a-leader-1'],
+ () => Number.NaN,
+ ),
+ /Invalid skill shuffle/,
+ );
+});
+
+void test('setup input rejection leaves the pristine physical inventory untouched', () => {
+ const state = createLeaderSkills(KEEP_ORDER);
+ const attempts: [readonly string[], string][] = [
+ [['a'], 'setup'],
+ [['a', 'b', 'c', 'd', 'e', 'f', 'g'], 'setup'],
+ [['a', 'a'], 'setup'],
+ [['a', 'b'], ''],
+ ];
+
+ for (const [players, event] of attempts)
+ expectImmutableRejection(
+ state,
+ () => dealLeaderSkills(state, players, event),
+ /two through six distinct skill recipients/,
+ );
+});
+
+void test('death returns a skill exactly once while a living captured leader keeps it attached', () => {
+ const offered = dealtState();
+ const skill = offered.offers.a.cards[0];
+ const assigned = chooseLeaderSkill(
+ offered,
+ 'a',
+ 'setup-1',
+ skill,
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ );
+ const capturedRoster: SkillRoster = [
+ {
+ id: 'a',
+ leaders: [
+ { id: 'a-leader-1', dead: false, capturedBy: 'b' },
+ { id: 'a-leader-2', dead: false },
+ ],
+ },
+ ...rosterFor(['b']),
+ ];
+
+ const captured = returnDeadLeaderSkills(assigned, capturedRoster, KEEP_ORDER);
+ assert.equal(captured.state, assigned);
+ assert.deepEqual(captured.returned, []);
+ assert.deepEqual(captured.state.assignments, assigned.assignments);
+ assert.equal(
+ offerRevivedLeaderSkill(assigned, 'a', 'a-leader-2', 'revival-blocked'),
+ assigned,
+ );
+
+ const deathRoster: SkillRoster = [
+ {
+ id: 'a',
+ leaders: [
+ { id: 'a-leader-1', dead: true, capturedBy: 'b' },
+ { id: 'a-leader-2', dead: false },
+ ],
+ },
+ ...rosterFor(['b']),
+ ];
+ const assignedSnapshot = structuredClone(assigned);
+ const death = returnDeadLeaderSkills(assigned, deathRoster, KEEP_ORDER);
+ assert.deepEqual(assigned, assignedSnapshot);
+ assert.deepEqual(death.returned, [
+ { owner: 'a', leader: 'a-leader-1', skill },
+ ]);
+ assert.deepEqual(death.state.assignments, []);
+ assert.ok(death.state.deck.includes(skill));
+
+ let secondShuffleCalls = 0;
+ const repeated = returnDeadLeaderSkills(death.state, deathRoster, () => {
+ secondShuffleCalls += 1;
+ return 0;
+ });
+ assert.equal(repeated.state, death.state);
+ assert.deepEqual(repeated.returned, []);
+ assert.equal(secondShuffleCalls, 0);
+ validateLeaderSkills(death.state, capturedRoster);
+});
+
+void test('revival asks whether to draw before the private preview and requires a keep after drawing', () => {
+ const initial = createLeaderSkills(KEEP_ORDER);
+ const originalDeck = [...initial.deck];
+ const offered = offerRevivedLeaderSkill(
+ initial,
+ 'a',
+ 'a-leader-1',
+ 'revival-1',
+ );
+
+ assert.deepEqual(initial.deck, originalDeck);
+ assert.deepEqual(offered.offers.a, {
+ event: 'revival-1',
+ cards: [],
+ leader: 'a-leader-1',
+ });
+ assert.deepEqual(offered.deck, originalDeck);
+
+ const declined = declineRevivedLeaderSkill(offered, 'a', 'revival-1');
+ assert.equal(declined.offers.a, undefined);
+ assert.deepEqual(declined.deck, originalDeck);
+
+ const redrawnOffer = offerRevivedLeaderSkill(
+ declined,
+ 'a',
+ 'a-leader-1',
+ 'revival-2',
+ );
+ const preview = drawRevivedLeaderSkills(redrawnOffer, 'a', 'revival-2');
+ assert.deepEqual(preview.offers.a.cards, originalDeck.slice(0, 2));
+ assert.deepEqual(preview.deck, originalDeck.slice(2));
+
+ expectImmutableRejection(
+ preview,
+ () => declineRevivedLeaderSkill(preview, 'a', 'revival-2'),
+ /undrawn revival skill offer/,
+ );
+ expectImmutableRejection(
+ preview,
+ () =>
+ chooseLeaderSkill(
+ preview,
+ 'a',
+ 'revival-2',
+ preview.offers.a.cards[0],
+ 'a-leader-2',
+ ['a-leader-1', 'a-leader-2'],
+ KEEP_ORDER,
+ ),
+ /eligible living leader/,
+ );
+
+ const kept = chooseLeaderSkill(
+ preview,
+ 'a',
+ 'revival-2',
+ preview.offers.a.cards[0],
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ );
+ assert.equal(kept.offers.a, undefined);
+ assert.deepEqual(kept.assignments, [
+ { owner: 'a', leader: 'a-leader-1', skill: originalDeck[0] },
+ ]);
+ validateLeaderSkills(kept, rosterFor(['a']));
+});
+
+void test('saved-state validation rejects duplicate inventory and wrong attachment without mutation', () => {
+ const roster = rosterFor(['a', 'b']);
+ const state = dealtState();
+
+ const duplicate = structuredClone(state);
+ duplicate.deck[duplicate.deck.length - 1] = duplicate.deck[0];
+ const duplicateSnapshot = structuredClone(duplicate);
+ assert.throws(
+ () => validateLeaderSkills(duplicate, roster),
+ /preserve all fourteen physical cards exactly once/,
+ );
+ assert.deepEqual(duplicate, duplicateSnapshot);
+
+ const card = state.offers.a.cards[0];
+ const assigned = chooseLeaderSkill(
+ state,
+ 'a',
+ 'setup-1',
+ card,
+ 'a-leader-1',
+ ['a-leader-1'],
+ KEEP_ORDER,
+ );
+ const wrongOwner = structuredClone(assigned);
+ wrongOwner.assignments[0].owner = 'b';
+ const wrongOwnerSnapshot = structuredClone(wrongOwner);
+ assert.throws(
+ () => validateLeaderSkills(wrongOwner, roster),
+ /original leader/,
+ );
+ assert.deepEqual(wrongOwner, wrongOwnerSnapshot);
+});
+
+void test('canonical physical inventory is unchanged when initial shuffling rejects a bad roll', () => {
+ const canonical: LeaderSkillId[] = LEADER_SKILL_CARDS.map((card) => card.id);
+ assert.throws(() => createLeaderSkills(() => 1), /Invalid skill shuffle/);
+ assert.deepEqual(
+ LEADER_SKILL_CARDS.map((card) => card.id),
+ canonical,
+ );
+});
diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts
index 911b6c35..b8548fdc 100644
--- a/tests/prototype-room.test.ts
+++ b/tests/prototype-room.test.ts
@@ -2,7 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { createGame, joinGame, newPlayer } from '../game/engine';
-import { startIxPrototypeRoom } from '../tools/prototype-room';
+import {
+ startIxPrototypeRoom,
+ startPrototypeRoom,
+} from '../tools/prototype-room';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -96,3 +99,50 @@ void test('unready, wrong identity and incompatible lobbies fail without writing
);
}
});
+
+void test('Leader Skills prototype saves the real private setup once without changing other rooms or seats', (t) => {
+ const { db } = fixture(t);
+ const game = createGame(
+ 'PROTOTYP',
+ newPlayer('i', 'Atreides', 'atreides'),
+ true,
+ );
+ joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
+ game.players.forEach((p) => {
+ p.ready = true;
+ });
+ db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
+ JSON.stringify(game),
+ game.code,
+ );
+ const seats = db.prepare('SELECT * FROM seats').all();
+ const other = db
+ .prepare('SELECT * FROM rooms WHERE code = ?')
+ .get('KEEPME00');
+ const result = startPrototypeRoom(db, game.code, 7, 'leader-skills');
+ assert.equal(result.version, 8);
+ const saved = JSON.parse(
+ db.prepare('SELECT state FROM rooms WHERE code = ?').get(game.code)!
+ .state as string,
+ );
+ assert.equal(saved.version, 8);
+ assert.equal(saved.setupStage, 'leaderSkills');
+ assert.equal(saved.leaderSkills.offers.i.cards.length, 2);
+ assert.equal(saved.leaderSkills.offers.h.cards.length, 2);
+ assert.equal(saved.players[0].hand.length, 1);
+ assert.equal(saved.players[1].hand.length, 2);
+ const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
+ assert.throws(
+ () => startPrototypeRoom(db, game.code, 8, 'leader-skills'),
+ /redeal/,
+ );
+ assert.deepEqual(
+ db.prepare('SELECT * FROM rooms ORDER BY code').all(),
+ rooms,
+ );
+ assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
+ assert.deepEqual(
+ db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
+ other,
+ );
+});
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index 853424e6..4986577f 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -2,6 +2,7 @@ import type { DatabaseSync } from 'node:sqlite';
import {
initializeIxGameForAudit,
initializeDiscoveryGameForAudit,
+ initializeLeaderSkillsGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -19,7 +20,7 @@ export function startPrototypeRoom(
db: DatabaseSync,
code: string,
expectedVersion: number,
- profile: 'ix' | 'discovery',
+ profile: 'ix' | 'discovery' | 'leader-skills',
) {
if (
!/^[A-Z0-9]{8}$/.test(code) ||
@@ -37,12 +38,17 @@ export function startPrototypeRoom(
const initial = JSON.parse(row.state) as Game;
if (initial.code !== code)
throw new Error('The room identity is inconsistent.');
- if (!['ix', 'discovery'].includes(profile))
+ if (!['ix', 'discovery', 'leader-skills'].includes(profile))
throw new Error('Unknown prototype profile.');
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
- : initializeDiscoveryGameForAudit({ ...initial, discoveryEnabled: true });
+ : profile === 'leader-skills'
+ ? initializeLeaderSkillsGameForAudit(initial)
+ : initializeDiscoveryGameForAudit({
+ ...initial,
+ discoveryEnabled: true,
+ });
// Exercise the same player projection before accepting the new saved state.
for (const player of game.players) viewGame(game, player.id);
const version = expectedVersion + 1;
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index a19d9a65..b3b021ce 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -20,12 +20,12 @@ async function main() {
});
if (values.help) {
console.log(
- 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready Ix expansion or base-faction Discovery lobby for local prototyping. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
+ 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}
if (
- !['ix', 'discovery'].includes(values.profile) ||
+ !['ix', 'discovery', 'leader-skills'].includes(values.profile) ||
!values.db ||
!values.room ||
!values.out ||
@@ -50,7 +50,7 @@ async function main() {
db,
values.room,
version,
- values.profile as 'ix' | 'discovery',
+ values.profile as 'ix' | 'discovery' | 'leader-skills',
);
writeFileSync(
resolve(values.out, 'prototype.json'),