From 20cc6057e912de79ccd7c0e3d5fa6f42c54fc76a Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sun, 20 Sep 2026 14:54:52 +1000
Subject: [PATCH] Connect unmodified Smuggler battle collection
New Leader Skills battles bind territory spice to the public plan reveal and automatically pay the controlling player once the selected Smuggler survives, including a loser or captive with no surviving forces. Cap collection at the original single pile and unmodified disc strength. Pending spice cannot fund a plan. Settlement before Sandmaster's after-win addition remains an explicitly documented timing inference; modified strength, multiple positive piles and other module combinations remain gated pending the existing rulings and integration.
Share pre-seal admission across server, controls and all four bot profiles. Expose the public Ecaz card-variant flag so its guard also works in private views. Normalize the selected Kwisatz value once for UI validation and submission. Fix AI selection of unavailable native face-up skilled discs. Show reveal-time pending guidance and an automatic collection notice/chronicle without a new confirmation. Keep unversioned saved battles on their existing behavior.
Bind saved receipts to the original battle, controller, leader, plans, posture and pile; project no internal binding frame. Void collection on leader death, opposing/mutual Traitors and explosion without restoring destroyed spice. Apply captured-leader settlement before normal custody cleanup. Existing room versioning prevents concurrent final votes from paying twice.
Twelve new engine/SQLite tests cover Basic/Advanced winners and losers, caps, empty piles, zero remaining forces, weapon/Traitor/explosion outcomes, captive survival/death custody, Sandmaster ordering, corrupted receipts, immutable pre-seal guards, legacy continuation, legal paths for four profiles and authenticated concurrent/restarted settlement. Independent review prompted the Ecaz gate and stale-Kwisatz corrections and found no further material defect in the bounded delta. A focused regression also identified the missing public Ecaz configuration field, now projected.
Types, lint, 4884 offline tests, production build and 43 HTTP tests passed on the unchanged source fingerprint. Two genuine four-profile Leader Skills samples completed in 265 Basic and 211 Advanced actions with no rejected candidates and 14 JSON/private-view restorations; neither sampled game naturally exercised Smuggler collection, so focused and browser evidence establish that effect.
In isolated browser room YWV8AGKS, the Emperor selected Smuggler, sealed a zero dial, saw six pending spice without early credit, refreshed the pending decision and submitted no Traitor. After the opponent's production-engine final action, the browser showed the 6-8 loss, automatic six-spice collection, explanatory chronicle and restored balance 26 after another refresh. Opponent/setup staging was private and confined to the new QA room. Screenshot acceptance remains open. An initial private verification script compared SQLite null-prototype rows with parsed JSON too strictly; the transaction rolled back and the corrected content comparison passed.
After the reported outage the server was healthy and the previous private QA seat restored. All 1106 baseline rooms, 2314 seats and every original recovery, entry and handover row remain byte-for-byte unchanged; the HTTP/browser checks added 34 rooms and 71 seats. SQLite integrity and foreign keys pass. No restart, migration, database reset or automation was needed. No mode, module or publication gate is opened.
Verified source tree: dc55a36f7bfe108d6529b2e42add019d212648e6c17772b36e06fe73f9443265
---
components/game-table.tsx | 24 ++-
components/leader-skill-battle-guide.tsx | 6 +-
docs/CURRENT_STATUS.md | 24 +--
docs/IMPLEMENTATION_STATUS.md | 16 ++
docs/LEADER_SKILLS_RULES.md | 5 +-
docs/LEADER_SKILLS_RUNTIME.md | 7 +-
docs/RULE_DECISIONS.md | 4 +-
docs/SMUGGLER_BATTLE.md | 66 +++++++++
game/bots.ts | 8 +-
game/engine.ts | 74 +++++++++-
game/reference.ts | 13 +-
game/smuggler-battle.ts | 79 ++++++++++
tests/fixture-smuggler-battle.ts | 34 +++++
tests/smuggler-battle-recovery.test.ts | 62 ++++++++
tests/smuggler-battle.test.ts | 179 +++++++++++++++++++++++
15 files changed, 564 insertions(+), 37 deletions(-)
create mode 100644 docs/SMUGGLER_BATTLE.md
create mode 100644 game/smuggler-battle.ts
create mode 100644 tests/fixture-smuggler-battle.ts
create mode 100644 tests/smuggler-battle-recovery.test.ts
create mode 100644 tests/smuggler-battle.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 4ca147eb..560ef210 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -51,6 +51,7 @@ import { Distrans } from './distrans';
import { JuiceOfSapho } from './juice-of-sapho';
import { LeaderSkillsPanel } from './leader-skills';
import { LeaderSkillBattleGuide } from './leader-skill-battle-guide';
+import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from '@/game/smuggler-battle';
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
import { MentatQuestion, MentatHistory } from './mentat-question';
import { BureaucratPayment } from './bureaucrat-payment';
@@ -469,6 +470,14 @@ export function GameTable({
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
+ const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active &&
+ !me.kwisatz.dead && !g.battle?.kwisatzBlocked &&
+ (!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory);
+ const smugglerPlanReason = g.battle?.smugglerCollectionEnabled ? smugglerBattlePlanBlock({
+ assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [],
+ leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon,
+ defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz,
+ }, smugglerBattleModeSupported(g), { territory: g.battle.territory, spice: g.spice }) : null;
const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense,
!!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon));
const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw,
@@ -4415,6 +4424,10 @@ export function GameTable({
) : [g.battle.attacker, g.battle.defender].includes(me.id) ? (
g.battle.revealed ? (
<>
+ {g.battle.smugglerCollection &&
+ {g.players.find(p => p.id === g.battle!.smugglerCollection!.player)?.name}’s Smuggler has {g.battle.smugglerCollection.amount} spice pending from the reveal-time pile.
+ Collection is automatic if the leader survives, even after a defeat. That spice is not yet spendable.
+
}
{!g.battle.traitorSubmitted.includes(me.id) && (
<>
{actionButton(
@@ -4438,6 +4451,7 @@ export function GameTable({
) : (
<>
+ {smugglerPlanReason && {smugglerPlanReason}
}
{g.battle.insight && (
{committed[g.battle.insight.field]
@@ -4712,19 +4726,13 @@ export function GameTable({
!g.battle.fremenSupportBlocked
? 0
: battleSupport,
- kwisatz:
- kwisatz &&
- !!me.kwisatz?.active &&
- !me.kwisatz.dead &&
- !g.battle.kwisatzBlocked &&
- (!me.kwisatz.usedAt ||
- me.kwisatz.usedAt === g.battle.territory),
+ kwisatz: selectedBattleKwisatz,
dial: battleDial,
leader,
weapon,
defense,
}),
- !!stonePlanReason || !!harassControl.blocked || !battlePairValid,
+ !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !battlePairValid,
)}
>
) : (
diff --git a/components/leader-skill-battle-guide.tsx b/components/leader-skill-battle-guide.tsx
index 87c565d1..35cfd4bc 100644
--- a/components/leader-skill-battle-guide.tsx
+++ b/components/leader-skill-battle-guide.tsx
@@ -12,12 +12,16 @@ export function LeaderSkillBattleGuide({ game, leader }: { game: GameView; leade
const bureaucrat = usesSurvivingSkilledLeader(assignments, 'bureaucrat', leader, true);
const sandmaster = usesSurvivingSkilledLeader(assignments, 'sandmaster', leader, true);
const rihani = usesSurvivingSkilledLeader(assignments, 'rihani-decipherer', leader, true);
- if (!mentat && !bureaucrat && !sandmaster && !rihani) return null;
+ const smuggler = usesSurvivingSkilledLeader(assignments, 'smuggler', leader, true);
+ if (!mentat && !bureaucrat && !sandmaster && !rihani && !smuggler) return null;
return
{mentat &&
Mentat adds 2 to this leader’s battle strength if the leader survives.
}
{bureaucrat &&
Bureaucrat subtracts {leaderSkillStrongholdCount(game, opponent)} from your opponent’s battle total if your leader survives. Count each occupied stronghold once, including contested strongholds. Advisors do not count.
}
{sandmaster &&
If this Sandmaster survives and wins, add 3 spice to the territory’s existing spice pile automatically. No spice is added when the territory has no spice.
}
{rihani &&
If this Rihani Decipherer survives and wins, you may draw two Traitors and exchange an unused old card for one of them. A native Rihani also privately inspects two deck cards first.
}
+ {smuggler &&
{game.battle!.smugglerCollectionEnabled
+ ? 'Smuggler records spice in this territory when both plans reveal, capped by this leader’s unmodified strength. If the leader survives, collect it automatically even if you lose or have no forces left. Pending spice cannot fund this plan. Strength modifiers and combined modules remain under review.'
+ : 'This saved battle predates automatic Smuggler collection. Its original battle behavior is preserved; new battles use the current collection flow.'}
}
{(mentat || bureaucrat) &&
These score effects resolve automatically. They do not change the leader’s printed value or spice bounty.
}
;
}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 00f86577..f4be3955 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,19 +6,19 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Latest delivered checkpoint: `9b67a78`, [early Sapho aggressor](SAPHO_AGGRESSOR.md).
-Types, lint, **4,866 offline tests**, production build and **43 HTTP tests** passed.
-Two genuine CHOAM/Richese samples completed; focused and browser evidence exercised
-the new priority effect. After the next outage all 1,106 rooms were present, the
-original baseline rows and latest QA save were unchanged, and a private seat
-restored after browser refresh. The healthy server was reused without a reset.
+Latest delivered checkpoint: `9be7dfb`, the [interactive introduction](INTERACTIVE_INTRODUCTION.md).
+Types, lint, **4,872 offline tests** and production build passed. Five lessons
+connect shared-rule shipping, sealed battle and collection exercises, card
+inspection and browser-local continuation. Broader teaching remains unfinished.
+After the reported outage all 1,106 games matched the prior backup and a private
+QA seat restored after refresh. The healthy server was reused without a reset.
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
-Current work: the [interactive introduction](INTERACTIVE_INTRODUCTION.md) adds
-five lessons with shared-rule shipping, sealed battle and collection exercises,
-card inspection and browser-local continuation. Broader faction/expansion
-teaching remains unfinished. Final verification and delivery belong to the
-source-bound report and Git message.
+Current work: [Smuggler battle collection](SMUGGLER_BATTLE.md) connects the
+unmodified lower band, automatic survival settlement, losing/captured leaders,
+Sandmaster order, shared controls and bots, and saved continuation. Modified
+strength and combined modules remain guarded. Final verification and delivery
+belong to the source-bound private report and Git message.
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
battle-card slot, private category inspections, exact undialed normal/elite
@@ -75,7 +75,7 @@ These links define bounded working behavior, not complete module certification.
| --- | --- |
| Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
-| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
+| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 5ddc8bba..d8f6fcd8 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,21 @@
# Dune implementation status
+## 20 September 2026 — Smuggler battle collection prototype
+
+[Unmodified Smuggler collection](SMUGGLER_BATTLE.md) binds the reveal-time pile
+and pays once after leader survival, including losing or captured leaders. Shared
+server/control/bot admission preserves the pending modifier and combined-module
+boundaries; deferred settlement before Sandmaster remains an explicit timing
+inference. Native face-up skilled discs are excluded from AI selection. New
+receipts preserve private saved continuation and old unversioned battles retain
+their prior behavior. Independent review and focused engine/SQLite evidence are
+paired with source-bound final checks, browser/samples, preservation and verified
+Git delivery in the private checkpoint and commit message. No gate is opened.
+
+After the reported outage the server responded and all 1,106 baseline games
+matched their saved rows. The prior private QA seat restored after refresh.
+No restart, reset or maintenance automation was needed.
+
## 20 September 2026 — Interactive introduction prototype
[Five guided lessons](INTERACTIVE_INTRODUCTION.md) connect shipping budgets,
diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md
index 3602c0b2..b9e8241f 100644
--- a/docs/LEADER_SKILLS_RULES.md
+++ b/docs/LEADER_SKILLS_RULES.md
@@ -204,8 +204,9 @@ Resolving this reveal-time collection logically before Sandmaster's after-win
addition is an explicit timing inference. A drained pile leaves no existing spice
for Sandmaster to augment. Multiple-pile allocation remains unresolved. Pending
spice is not spendable personal custody; this provisional timing must remain
-explicit when the collection function is connected. Runtime collection is still
-missing; the independent introduction proceeded while the modifier answer waits.
+explicit in the connected [unmodified collection prototype](SMUGGLER_BATTLE.md).
+Modified plans, other modules and multiple positive piles remain gated while
+their answers and integration wait.
## Implementation readiness contract
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 04056963..cbfc8404 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -64,8 +64,9 @@ now connects its lower band with separate sealed funds and survivor strength. [S
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
[Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical
Worthless card copying a canonical base Shield or Snooper; the lower retreat
-and modified-defense inheritance remain guarded. Smuggler battle collection
-also remains missing.
+and modified-defense inheritance remain guarded. [Smuggler battle collection](SMUGGLER_BATTLE.md)
+now connects reveal-bound, unmodified collection after survival, including
+losers and captives. Modified strength and combined modules remain guarded.
Planetologist and Suk Graduate have the connected
prototypes described below.
@@ -78,7 +79,7 @@ Before enabling them, add response-driven automatic revival continuation
coverage; the current before/after revival observer does not certify deferred
Tleilaxu response chains. The other source-contract timing questions also remain
open. Planetologist now has bounded movement and battle controls as described below.
-Remaining normal-band payments, ordinary Mentat activation, Smuggler battle collection,
+Remaining normal-band payments, ordinary Mentat activation, modified Smuggler collection,
Diplomat retreat and the other guarded skill combinations still need connected
effects.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index e63cfb25..0ca7ad4d 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -56,8 +56,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered. Every genuine Richese setup includes those cards and Stone Burner; an ordinary-copy-only admission gate cannot depend on concealed custody or alter the printed deck. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
-| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand fallback (private historical observations persist after death), Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
-| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; lower collection remains unimplemented. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
+| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler modifier/allocation boundaries, Mentat empty-hand fallback (private historical observations persist after death), Diplomat lower retreat ordering, modified-defense inheritance and remaining simultaneous optional timing need resolution before those cases are enabled. These are recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources), [Diplomat boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
+| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
diff --git a/docs/SMUGGLER_BATTLE.md b/docs/SMUGGLER_BATTLE.md
new file mode 100644
index 00000000..4c9323ba
--- /dev/null
+++ b/docs/SMUGGLER_BATTLE.md
@@ -0,0 +1,66 @@
+# Smuggler battle collection
+
+20 September 2026. **Prototyped, with Partial rules coverage.** New battles in
+the base-faction Leader Skills preview connect the lower Smuggler effect in Basic
+and Advanced games. Public module, mode and publication gates remain closed.
+
+## Rule and timing contract
+
+The [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up)
+combines the printed reveal-time collection with the common requirement that the
+selected skilled leader survive. The card does not require winning or retaining
+forces. A captured skilled leader grants its lower effect to its controller.
+
+At public plan reveal the server records the battle, controller, selected leader,
+plans, skill posture and territory's existing spice. The amount is the smaller of
+that single pile and the unmodified disc strength. This pending amount is not
+personal spice and cannot fund a sealed plan or another payment. After survival
+is known, the server transfers it once, including for a surviving loser, before
+Sandmaster adds any after-victory spice. A pile drained to zero cannot receive
+Sandmaster's addition. This deferred settlement and ordering remain explicit
+timing inferences, not a newly discovered publisher ruling.
+
+A weapon death, opposing or mutual Traitor, or Lasgun–Shield explosion voids the
+collection. A successful sole own Traitor call can leave the Smuggler alive and
+eligible. A void receipt changes no pile; it cannot restore spice destroyed by an
+explosion. Capture return/death cleanup follows settlement without paying the
+original owner. This board transfer is neither bank income nor another player's
+payment, so it does not invoke Banker or Bureaucrat payment handling.
+
+## Connected behavior
+
+- A shared admission quote serves the server, player controls and four AI
+ profiles. Public mode flags and the player's own chosen plan determine the
+ guard; an opponent's concealed cards do not control eligibility.
+- Guidance explains the pending amount, survival and spending restriction.
+ Collection is automatic, with the existing faction-colored action notice and
+ an explanatory chronicle entry; there is no confirmation step.
+- AI candidates omit optional Kwisatz when selecting Smuggler and reject other
+ unsupported modified plans. Native face-up skilled discs remain unavailable
+ to battle selection; captives and existing mandatory concealment retain their
+ legal paths. UI availability and the submitted Kwisatz value share one check.
+- Versioned, signed reveal receipts survive private JSON/SQLite continuation.
+ Reads/actions reject changed plans, receipts, pile or identity. Public views
+ expose the selected leader, controller and pending amount only after reveal;
+ the internal binding frame is never projected. Concurrent final votes use the
+ existing versioned room write to prevent duplicate collection.
+- Battles already saved without the new version keep their original behavior.
+ Subsequent battles use the new flow. There is no database migration or reset.
+
+## Boundaries and evidence
+
+Kwisatz, separate trainer bonuses and Stunner still await the existing modifier
+question; no user answer is assumed. Expansion rosters/cards, the independent
+Ecaz card variant, other optional modules and multiple positive piles remain
+guarded before the Smuggler plan seals. These development restrictions are not
+rules that forbid those combinations in the physical game. Stone Burner and
+other expansion combinations therefore have no connected acceptance claim here.
+
+Focused engine tests cover winners/losers in both modes, empty/short piles,
+zero surviving forces, death and Traitor outcomes, explosion, capture custody,
+Sandmaster order, corruption, immutable rejection, legacy continuation and all
+four bot profiles. SQLite tests cover authenticated restart, private projection,
+concurrent final votes and replay rejection. Browser and full-game sample scope,
+required check/build/HTTP results, preservation and verified Git delivery are
+recorded in the private source-bound checkpoint and commit message. This does
+not certify every Leader Skill or combined-module interaction.
diff --git a/game/bots.ts b/game/bots.ts
index 7cf68581..bd1812e8 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,7 +1,8 @@
import { discoveryBotActions } from './discovery-options';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
-import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
+import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
+import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw';
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
@@ -708,6 +709,7 @@ function plans(g: GameView): Action[] {
(l) =>
!l.dead &&
controlsLeader(me, l) &&
+ !battleSkills.some(a => a.leader === l.id && a.faceUp && !a.captured) &&
(!l.usedAt || l.usedAt === b.territory),
);
const heroes = (me.hand ?? []).filter((c) => c.kind === 'hero');
@@ -748,6 +750,7 @@ function plans(g: GameView): Action[] {
for (const defense of defenses) {
const kwisatz =
!!leader &&
+ !(b.smugglerCollectionEnabled && usesSurvivingSkilledLeader(battleSkills, 'smuggler', leader, true)) &&
!!me.kwisatz?.active &&
!me.kwisatz.dead &&
!b.kwisatzBlocked &&
@@ -778,6 +781,9 @@ function plans(g: GameView): Action[] {
) + (kwisatz ? 2 : 0);
const w = me.hand?.find((c) => c.id === weapon);
const d = me.hand?.find((c) => c.id === defense);
+ if (b.smugglerCollectionEnabled && smugglerBattlePlanBlock({ assignments: battleSkills,
+ leader: myLeader, weapon: w, defense: d, kwisatz }, smugglerBattleModeSupported(g),
+ { territory: b.territory, spice: g.spice })) continue;
if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue;
const stoneBattle =
isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon));
diff --git a/game/engine.ts b/game/engine.ts
index 35fed59f..98d220e3 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -12,6 +12,7 @@ import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type Rihan
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
+import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
@@ -612,6 +613,9 @@ export type HarassAllocationReceipt = {
stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string;
};
export type Battle = {
+ /** New battles bind automatic collection to the original public plan reveal. */
+ smugglerCollectionVersion?: 1;
+ smugglerCollection?: SmugglerBattleReceipt | null;
/** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */
harassAllocationVersion?: 1;
harassAllocationEvent?: string;
@@ -1592,6 +1596,7 @@ export type Game = {
sukRescue?: { signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean };
sandmaster?: { leader: string; key: string; before: number; after: number };
+ smugglerCollection?: SmugglerBattleReceipt;
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
moritaniAssassinate?: {event:string;signature:string;continuation?:string};
@@ -5267,6 +5272,7 @@ function leaderSkillsIntegrity(g: Game) {
sandmasterIntegrity(g);
sukRescueIntegrity(g);
rihaniIntegrity(g);
+ smugglerBattleIntegrity(g);
for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) {
if (plan.bankerSpice === undefined) continue;
const p = getPlayer(g, owner);
@@ -14031,6 +14037,13 @@ function validatePlan(
);
const w = cardOf(p, plan.weapon),
d = cardOf(p, plan.defense);
+ if (b.smugglerCollectionVersion === 1) {
+ const smugglerPlan = { assignments: battleLeaderSkills(g, p), leader: l, weapon: w, defense: d, kwisatz: plan.kwisatz };
+ const block = smugglerBattlePlanBlock(smugglerPlan, smugglerBattleModeSupported(g), { territory: b.territory, spice: g.spice });
+ requireRule(!block, block ?? 'Smuggler collection is unavailable for this plan.');
+ if (smugglerPlan.assignments.some(a => a.skill === 'smuggler' && a.leader === l?.id))
+ nexusRule(() => smugglerBattlePile(b.territory, g.spice));
+ }
const planetologistWeapon = canUsePlanetologistWeapon(
g,
p,
@@ -15448,6 +15461,36 @@ function traitorVoters(g: Game, b: Battle) {
function traitorBeneficiary(g: Game, b: Battle, id: string) {
return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
}
+function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
+ const b = g.battle!;
+ const frame = JSON.stringify({ plans: b.plans, hidden: b.leaderSkillHidden,
+ skills: [b.attacker, b.defender].map(id => battleLeaderSkills(g, getPlayer(g, id))) });
+ const receipts = [b.attacker, b.defender].flatMap(id => {
+ const p = getPlayer(g, id), plan = b.plans[id];
+ const receipt = nexusRule(() => createSmugglerBattle({ event: b.event!, turn: g.turn,
+ territory: b.territory, player: id, frame, spice: g.spice, supported: smugglerBattleModeSupported(g),
+ plan: { assignments: battleLeaderSkills(g, p),
+ leader: controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead),
+ weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), kwisatz: plan.kwisatz } }));
+ return receipt ? [receipt] : [];
+ });
+ requireRule(receipts.length <= 1, 'Smuggler needs its unique physical skill card.');
+ return receipts[0] ?? null;
+}
+function smugglerBattleIntegrity(g: Game) {
+ const b = g.battle;
+ if (b?.smugglerCollectionVersion !== undefined || b?.smugglerCollection !== undefined) {
+ requireRule(b && b.smugglerCollectionVersion === 1, 'The saved Smuggler collection lost its battle version.');
+ if (b.revealed) requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(pendingSmugglerBattle(g)),
+ 'The saved Smuggler collection changed its original plans, leader or reveal-time spice.');
+ else requireRule(b.smugglerCollection === undefined, 'Smuggler collection cannot precede public Battle Plans.');
+ }
+ const context = g.lastBattleContext, receipt = context?.smugglerCollection;
+ if (receipt) requireRule(receipt.event === context!.event && receipt.turn === context!.turn &&
+ receipt.territory === context!.territory && context!.combatants.includes(receipt.player) &&
+ ['collected', 'void'].includes(receipt.stage) && receipt.signature === smugglerBattleSignature(receipt),
+ 'The completed Smuggler collection changed its original battle receipt.');
+}
/** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */
function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
const b = g.battle!;
@@ -15521,7 +15564,11 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
!g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.expansions.length &&
g.players.every((p) => faction(p.faction).expansion === 'base'),
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
- if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
+ const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
+ !(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
+ if (smuggler) requireRule(Number.isSafeInteger(getPlayer(g, smuggler.receipt.player).spice + smuggler.amount),
+ 'Smuggler collection would overflow the spice balance.');
+ if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, smuggler?.spice ?? g.spice));
if (quote.rihani) {
const winner = getPlayer(g, quote.winner!);
nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
@@ -15542,7 +15589,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
);
if (quote.homeworldExplosion)
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
- return quote;
+ return { ...quote, smuggler };
} catch (error) {
if (
error instanceof BattleResolutionQuoteError ||
@@ -15841,6 +15888,17 @@ function resolveBattle(g: Game) {
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
};
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
+ if (quote.smuggler) {
+ const { receipt, amount } = quote.smuggler;
+ const collector = getPlayer(g, receipt.player);
+ if (amount && receipt.key) g.spice[receipt.key] = receipt.before - amount;
+ collector.spice += amount;
+ g.lastBattleContext.smugglerCollection = receipt;
+ log(g, receipt.stage === 'void'
+ ? `${collector.name}'s Smuggler did not survive. Its pending collection is void; no spice was taken from ${combatLocationName(g, b.territory)}.`
+ : `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time pile held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`,
+ { faction: collector.faction, name: 'Smuggler collection' });
+ }
if (winner && quote.sandmaster) {
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
if (placement) {
@@ -23829,6 +23887,7 @@ function applyActionInner(
...choice,
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
event: battleEvent,
+ ...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}),
@@ -24009,6 +24068,11 @@ function applyActionInner(
if (l?.capturedBy) delete l.concealed;
}
log(g, 'Both battle plans are revealed.');
+ if (b.smugglerCollectionVersion === 1) {
+ b.smugglerCollection = pendingSmugglerBattle(g);
+ if (b.smugglerCollection) log(g,
+ `${getPlayer(g, b.smugglerCollection.player).name}'s Smuggler may collect ${b.smugglerCollection.amount} spice from the reveal-time pile if the leader survives. That spice is not yet spendable.`);
+ }
nextRevealedDecision(g);
}
return g;
@@ -24559,6 +24623,7 @@ export function viewGame(state: Game, id: string) {
advisorAccompanyMaximum: spiritualAdvisorMaximum(g, me.id, 'arrakeen'),
},
expansions: g.expansions,
+ ecazTreachery: !!g.ecazTreachery,
turn: g.turn,
phase: g.phase,
phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null,
@@ -25136,6 +25201,11 @@ export function viewGame(state: Game, id: string) {
}
: null,
lateDefense: b.revealed ? (b.lateDefense ?? {}) : {},
+ smugglerCollectionEnabled: b.smugglerCollectionVersion === 1,
+ smugglerCollection: b.revealed && b.smugglerCollection
+ ? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader,
+ amount: b.smugglerCollection.amount, before: b.smugglerCollection.before }
+ : null,
diplomatDefense: b.revealed && b.diplomatDefense
? { player: b.diplomatDefense.player, event: b.diplomatDefense.event,
leader: b.diplomatDefense.leader, cards: [...b.diplomatDefense.cards],
diff --git a/game/reference.ts b/game/reference.ts
index 28030629..475016ed 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3699,7 +3699,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping, Sandmaster route collection sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
+ summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying 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.',
@@ -3717,16 +3717,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. Lower retreat and modified defenses remain unfinished.',
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.',
- 'Special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
+ 'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
+ 'Special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['setup','choam-modules','implementation-checklist'],
checklist: [
- { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
- { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
- { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
+ { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
+ { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
+ { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
- { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
+ { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler shipment and battle collection, losing/captured survival, Sandmaster order, explosion, legacy and concurrent recovery, and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-battle.test.ts','tests/smuggler-battle-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
],
},
{
diff --git a/game/smuggler-battle.ts b/game/smuggler-battle.ts
new file mode 100644
index 00000000..30b0527b
--- /dev/null
+++ b/game/smuggler-battle.ts
@@ -0,0 +1,79 @@
+import type { Card, Leader } from './cards';
+import { faction } from './catalog';
+import { splitLocation, validGameLocation } from './board';
+import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
+
+export type SmugglerBattleReceipt = {
+ event: string; turn: number; territory: string; player: string; leader: string;
+ frame: string; strength: number; key: string | null; before: number; amount: number;
+ stage: 'pending' | 'collected' | 'void'; signature: string;
+};
+export function smugglerBattleModeSupported(g: {
+ expansions: readonly string[]; players: readonly { faction: string }[];
+ homeworlds?: unknown; nexusCards?: unknown; discoveries?: unknown;
+ discoveryEnabled?: boolean; strongholdCards?: unknown; techTokens?: unknown; ecazTreachery?: unknown;
+}) {
+ return !g.expansions.length && g.players.every(p => faction(p.faction)?.expansion === 'base') &&
+ !g.homeworlds && !g.nexusCards && !g.discoveries && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens && !g.ecazTreachery;
+}
+export type SmugglerBattlePlan = {
+ assignments: readonly BattleLeaderSkill[];
+ leader?: Pick;
+ weapon?: Card; defense?: Card; kwisatz?: boolean;
+};
+export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boolean,
+ pile?: { territory: string; spice: Readonly> }): string | null {
+ if (!usesSurvivingSkilledLeader(plan.assignments, 'smuggler', plan.leader?.id, true)) return null;
+ if (!supported) return 'Smuggler battle collection with expansion factions or other optional modules is still being integrated.';
+ if (plan.kwisatz || leaderSkillBattleBonus({ assignments: plan.assignments,
+ selectedLeader: { id: plan.leader!.id, kind: 'disc' }, weapon: plan.weapon,
+ defense: plan.defense, skilledLeaderSurvives: true }).bonus)
+ return 'Smuggler collection with modified leader strength awaits its ruling. Choose an unmodified plan or another leader.';
+ if (pile) {
+ try { smugglerBattlePile(pile.territory, pile.spice); }
+ catch (error) { return error instanceof Error ? error.message : 'Smuggler spice is unavailable.'; }
+ }
+ return null;
+}
+export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string {
+ return JSON.stringify({ ...receipt, signature: undefined });
+}
+export function smugglerBattlePile(territory: string, spice: Readonly>) {
+ const piles = Object.entries(spice).filter(([key]) => splitLocation(key).territory === territory);
+ for (const [key, amount] of piles) {
+ const at = splitLocation(key);
+ if (!validGameLocation({}, at.territory, at.sector) || key !== `${at.territory}:${at.sector}` ||
+ !Number.isSafeInteger(amount) || amount < 0) throw new Error('Smuggler needs a valid spice pile.');
+ }
+ const positive = piles.filter(([, amount]) => amount > 0);
+ if (positive.length > 1) throw new Error('Smuggler collection among multiple spice piles awaits its allocation ruling.');
+ return positive.length ? { key: positive[0][0], before: positive[0][1] } : { key: null, before: 0 };
+}
+export function createSmugglerBattle(input: {
+ event: string; turn: number; territory: string; player: string; frame: string;
+ plan: SmugglerBattlePlan; supported: boolean; spice: Readonly>;
+}): SmugglerBattleReceipt | null {
+ if (!usesSurvivingSkilledLeader(input.plan.assignments, 'smuggler', input.plan.leader?.id, true)) return null;
+ const blocked = smugglerBattlePlanBlock(input.plan, input.supported);
+ if (blocked) throw new Error(blocked);
+ const strength = input.plan.leader!.strength;
+ if (!input.event || !input.player || !Number.isSafeInteger(input.turn) || input.turn < 1 ||
+ !Number.isSafeInteger(strength) || strength < 0) throw new Error('Smuggler needs the current battle and an unmodified leader.');
+ const pile = smugglerBattlePile(input.territory, input.spice);
+ const receipt: SmugglerBattleReceipt = { event: input.event, turn: input.turn,
+ territory: input.territory, player: input.player, leader: input.plan.leader!.id,
+ frame: input.frame, strength, ...pile, amount: Math.min(strength, pile.before), stage: 'pending', signature: '' };
+ receipt.signature = smugglerBattleSignature(receipt);
+ return receipt;
+}
+/** Survival controls both winning and losing leaders; this is not bank/player income. */
+export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, survives: boolean, spice: Readonly>) {
+ if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
+ receipt.amount !== Math.min(receipt.strength, receipt.before) ||
+ JSON.stringify(smugglerBattlePile(receipt.territory, spice)) !== JSON.stringify({ key: receipt.key, before: receipt.before }))
+ throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
+ const amount = survives ? receipt.amount : 0;
+ const settled: SmugglerBattleReceipt = { ...receipt, stage: survives ? 'collected' : 'void', signature: '' };
+ settled.signature = smugglerBattleSignature(settled);
+ return { receipt: settled, amount, spice: receipt.key ? { ...spice, [receipt.key]: receipt.before - amount } : { ...spice } };
+}
diff --git a/tests/fixture-smuggler-battle.ts b/tests/fixture-smuggler-battle.ts
new file mode 100644
index 00000000..07da21b6
--- /dev/null
+++ b/tests/fixture-smuggler-battle.ts
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict';
+import { applyAction, type Game } from '../game/engine';
+import { leaderSkillBattle } from './leader-skill-battle-fixture';
+import { validateLeaderSkills } from '../game/leader-skills';
+
+export function smugglerBattle({ advanced = false, captured = false, amount = 8, hide = true, atreides = false } = {}) {
+ const game = leaderSkillBattle({ skill: 'smuggler', territory: 'wind_pass', sector: 14,
+ unsealed: true, advanced, captured, hide, atreides,
+ ...(captured ? { capturedNativeSkill: 'sandmaster' as const } : {}) });
+ game.spice = { 'wind_pass:14': amount };
+ // Keep a separate unresolved battle so collection cannot advance the phase
+ // before assertions about this battle's own receipt and income.
+ for (const player of game.players) { player.forces['carthag:11'] = 1; player.reserves--; }
+ return game;
+}
+export function setOtherSkill(game: Game, skill: 'sandmaster' | 'warmaster', owner = 'd') {
+ const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
+ const index = game.leaderSkills!.deck.indexOf(skill);
+ assert.ok(index >= 0);
+ [assignment.skill, game.leaderSkills!.deck[index]] = [skill, assignment.skill];
+ validateLeaderSkills(game.leaderSkills!, game.players);
+}
+export function revealSmuggler(game: Game, { dial = 4, enemyDial = 0, weapon = null as string | null,
+ defense = null as string | null, enemyWeapon = null as string | null, enemyLeader = 'guild-1', kwisatz = false } = {}) {
+ const own = game.leaderSkills!.assignments.find(a => a.skill === 'smuggler')!;
+ game = applyAction(game, 'a', { type: 'battlePlan', dial, support: game.advanced ? dial : 0,
+ leader: own.leader, weapon, defense, kwisatz });
+ return applyAction(game, 'd', { type: 'battlePlan', dial: enemyDial, support: game.advanced ? enemyDial : 0,
+ leader: enemyLeader, weapon: enemyWeapon });
+}
+export function finishSmuggler(game: Game, ownCall = false, otherCall = false) {
+ game = applyAction(game, 'a', { type: 'traitorCall', call: ownCall });
+ return applyAction(game, 'd', { type: 'traitorCall', call: otherCall });
+}
diff --git a/tests/smuggler-battle-recovery.test.ts b/tests/smuggler-battle-recovery.test.ts
new file mode 100644
index 00000000..6a9bc9d7
--- /dev/null
+++ b/tests/smuggler-battle-recovery.test.ts
@@ -0,0 +1,62 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync } from 'node:sqlite';
+import type { RoomsClock } from '../db/rooms';
+import { unitStore } from './fixture-nexus-room-store';
+import { applyAction } from '../game/engine';
+import { smugglerBattle, revealSmuggler } from './fixture-smuggler-battle';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+void test('authenticated SQLite restart and concurrent final votes collect Smuggler spice once with seat privacy', async () => {
+ const f = unitStore(), sqlite: DatabaseSync = f.sqlite;
+ try {
+ const created = await f.rooms.createRoom('Smuggler', 'emperor', false, []);
+ const code = created.view.code;
+ const joined = await f.rooms.joinRoom(code, 'Opponent', 'guild');
+ const tokens = [created.token!, joined.token!];
+ for (const [i, token] of tokens.entries()) {
+ const auth = await f.rooms.authenticate(code, token);
+ sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?').run(i ? 'd' : 'a', code, auth.playerId);
+ }
+ const auth = await Promise.all(tokens.map(token => f.restart().authenticate(code, token)));
+ const state = applyAction(revealSmuggler(smugglerBattle()), 'a', { type: 'traitorCall', call: false });
+ state.code = code; state.version = (await f.rooms.readRoom(code)).version;
+ sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(state), state.version, code);
+ const beforeSeats = sqlite.prepare('SELECT * FROM seats WHERE room_code=? ORDER BY player_id').all(code);
+ for (let i = 0; i < auth.length; i++) {
+ const view = await f.restart().readSeatView(code, auth[i]);
+ assert.equal(view.battle!.smugglerCollection!.amount, 6);
+ assert.equal(view.players.find(p => p.id === view.me)!.spice, 20);
+ assert.equal('spice' in view.players.find(p => p.id !== view.me)!, false);
+ assert.equal('hand' in view.players.find(p => p.id !== view.me)!, false);
+ assert.equal('frame' in view.battle!.smugglerCollection!, false);
+ }
+ let arrivals = 0;
+ let release!: () => void;
+ const barrier = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
+ const action = { type: 'traitorCall' as const, call: false };
+ const race = await Promise.allSettled([
+ f.rooms.act(code, auth[1], state.version, action, clock),
+ f.restart().act(code, auth[1], state.version, action, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(arrivals, 2);
+ assert.equal(race.filter(r => r.status === 'fulfilled').length, 1);
+ assert.equal(race.filter(r => r.status === 'rejected').length, 1);
+ const done = await f.restart().readRoom(code);
+ assert.equal(done.version, state.version + 1);
+ assert.equal(done.players[0].spice, 26);
+ assert.equal(done.spice['wind_pass:14'], 2);
+ assert.equal(done.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ assert.equal(done.log.filter(l => l.text.includes('surviving Smuggler collected')).length, 1);
+ await assert.rejects(f.restart().act(code, auth[1], state.version, action, clock));
+ await assert.rejects(f.restart().act(code, auth[1], done.version, action, clock));
+ assert.deepEqual(await f.restart().readRoom(code), done);
+ assert.deepEqual(sqlite.prepare('SELECT * FROM seats WHERE room_code=? ORDER BY player_id').all(code), beforeSeats);
+ for (const seat of auth) {
+ const first = await f.rooms.readSeatView(code, seat);
+ assert.deepEqual(await f.restart().readSeatView(code, seat), first);
+ }
+ } finally { sqlite.close(); }
+});
diff --git a/tests/smuggler-battle.test.ts b/tests/smuggler-battle.test.ts
new file mode 100644
index 00000000..9c989643
--- /dev/null
+++ b/tests/smuggler-battle.test.ts
@@ -0,0 +1,179 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { smugglerBattle, revealSmuggler, finishSmuggler, setOtherSkill } from './fixture-smuggler-battle';
+
+function reject(game: Game, action: Action, id = 'a') {
+ const before = JSON.stringify(game);
+ assert.throws(() => applyAction(game, id, action));
+ assert.equal(JSON.stringify(game), before);
+}
+function take(game: Game, id: string, kind: string) {
+ const index = game.deck.findIndex(card => card.kind === kind);
+ assert.ok(index >= 0);
+ const [card] = game.deck.splice(index, 1);
+ game.players.find(p => p.id === id)!.hand.push(card);
+ return card.id;
+}
+function traitor(game: Game, owner: string, leader: string) {
+ const holder = game.players.find(p => p.traitors.includes(leader));
+ const target = game.players.find(p => p.id === owner)!;
+ if (holder === target) return;
+ const previous = target.traitors[0];
+ target.traitors[0] = leader;
+ if (holder) holder.traitors[holder.traitors.indexOf(leader)] = previous;
+ else {
+ const index = game.traitorReserve!.indexOf(leader); assert.ok(index >= 0);
+ game.traitorReserve![index] = previous;
+ }
+}
+void test('Smuggler freezes the revealed pile without credit then pays a surviving winner or loser in Basic and Advanced', () => {
+ for (const advanced of [false, true]) for (const loses of [false, true]) {
+ const start = smugglerBattle({ advanced });
+ const pending = revealSmuggler(start, { dial: loses ? 0 : 4, enemyDial: loses ? 5 : 0 });
+ assert.equal(pending.players[0].spice, 20);
+ assert.equal(pending.spice['wind_pass:14'], 8);
+ assert.equal(pending.battle!.smugglerCollection!.amount, 6);
+ for (const p of pending.players) assert.deepEqual(viewGame(pending, p.id), viewGame(JSON.parse(JSON.stringify(pending)), p.id));
+ const result = finishSmuggler(JSON.parse(JSON.stringify(pending)));
+ assert.equal(result.lastBattleContext!.winner, loses ? 'd' : 'a');
+ assert.equal(result.players[0].spice, 26 - (advanced && !loses ? 4 : 0));
+ assert.equal(result.spice['wind_pass:14'], 2);
+ assert.equal(result.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ if (loses) assert.equal(result.players[0].forces['wind_pass:14'] ?? 0, 0);
+ assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(result))), result);
+ reject(result, { type: 'traitorCall', call: false }, 'd');
+ }
+});
+void test('Smuggler collection is capped by a short or empty pile and does not require remaining forces', () => {
+ for (const amount of [0, 2, 6]) {
+ const result = finishSmuggler(revealSmuggler(smugglerBattle({ amount }), { dial: 5 }));
+ assert.equal(result.players[0].spice, 20 + amount);
+ assert.equal(result.spice['wind_pass:14'], 0);
+ assert.equal(result.players[0].forces['wind_pass:14'] ?? 0, 0);
+ }
+});
+void test('weapon deaths, opposing/mutual Traitors and explosion void collection; own Traitor victory still collects', () => {
+ for (const outcome of ['weapon', 'explosion', 'own', 'opposing', 'mutual'] as const) {
+ const game = smugglerBattle();
+ let defense: string | null = null, enemyWeapon: string | null = null;
+ if (outcome === 'weapon') enemyWeapon = take(game, 'd', 'projectile');
+ if (outcome === 'explosion') { defense = take(game, 'a', 'shield'); enemyWeapon = take(game, 'd', 'lasgun'); }
+ if (outcome === 'own' || outcome === 'mutual') traitor(game, 'a', 'guild-1');
+ if (outcome === 'opposing' || outcome === 'mutual') traitor(game, 'd', 'emperor-0');
+ const done = finishSmuggler(revealSmuggler(game, { defense, enemyWeapon }),
+ outcome === 'own' || outcome === 'mutual', outcome === 'opposing' || outcome === 'mutual');
+ assert.equal(done.lastBattleContext!.smugglerCollection!.stage, outcome === 'own' ? 'collected' : 'void', outcome);
+ assert.equal(done.spice['wind_pass:14'], outcome === 'explosion' ? undefined : outcome === 'own' ? 2 : 8, outcome);
+ assert.equal(done.players[0].spice, outcome === 'weapon' ? 26 : outcome === 'own' ? 29 : 20, outcome);
+ }
+});
+void test('capture pays the controlling Harkonnen before the living skilled disc returns to its owner', () => {
+ const game = smugglerBattle({ captured: true, hide: false });
+ const revealed = revealSmuggler(game);
+ assert.equal(revealed.battle!.smugglerCollection!.player, 'a');
+ assert.equal(revealed.battle!.smugglerCollection!.leader, 'guild-0');
+ const done = finishSmuggler(revealed);
+ assert.equal(done.players[0].spice, 25);
+ assert.equal(done.players[1].spice, 20);
+ assert.equal(done.players[1].leaders[0].capturedBy, undefined);
+ assert.equal(done.leaderSkills!.assignments.find(a => a.skill === 'smuggler')!.owner, 'd');
+});
+void test('a killed captured Smuggler earns nothing and returns its dead disc and skill to their proper custody', () => {
+ const game = smugglerBattle({ captured: true, hide: false });
+ const enemyWeapon = take(game, 'd', 'projectile');
+ const done = finishSmuggler(revealSmuggler(game, { dial: 0, enemyDial: 5, enemyWeapon }));
+ assert.equal(done.players[0].spice, 20);
+ assert.equal(done.spice['wind_pass:14'], 8);
+ assert.equal(done.lastBattleContext!.smugglerCollection!.stage, 'void');
+ assert.equal(done.players[1].leaders[0].capturedBy, undefined);
+ assert.equal(done.players[1].leaders[0].dead, true);
+ assert.ok(done.leaderSkills!.deck.includes('smuggler'));
+ assert.equal(done.leaderSkills!.assignments.some(a => a.skill === 'smuggler'), false);
+ assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done);
+});
+void test('an existing unversioned battle retains its original no-collection continuation', () => {
+ const game = smugglerBattle();
+ delete game.battle!.smugglerCollectionVersion;
+ const pending = revealSmuggler(game);
+ assert.equal(pending.battle!.smugglerCollection, undefined);
+ const done = finishSmuggler(JSON.parse(JSON.stringify(pending)));
+ assert.equal(done.players[0].spice, 20);
+ assert.equal(done.spice['wind_pass:14'], 8);
+ assert.equal(done.lastBattleContext!.smugglerCollection, undefined);
+ assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done);
+});
+void test('Smuggler collection precedes a surviving Sandmaster winner and may remove the last existing pile', () => {
+ for (const amount of [4, 8]) {
+ const game = smugglerBattle({ amount }); setOtherSkill(game, 'sandmaster');
+ game.battle!.leaderSkillHidden!.d = true;
+ const done = finishSmuggler(revealSmuggler(game, { dial: 0, enemyDial: 5, enemyLeader: 'guild-0' }));
+ assert.equal(done.lastBattleContext!.winner, 'd');
+ assert.equal(done.spice['wind_pass:14'], amount === 4 ? 0 : 5);
+ assert.equal(!!done.lastBattleContext!.sandmaster, amount === 8);
+ }
+});
+void test('changed reveal receipts, spice, plans or skill identity are rejected by reads, actions and restoration', () => {
+ const pending = revealSmuggler(smugglerBattle());
+ for (const corrupt of [
+ (g: Game) => { delete g.battle!.smugglerCollection; },
+ (g: Game) => { g.battle!.smugglerCollection!.amount++; },
+ (g: Game) => { g.battle!.smugglerCollection!.event = 'another-battle'; },
+ (g: Game) => { g.spice['wind_pass:14']++; },
+ (g: Game) => { g.battle!.plans.d.dial++; },
+ (g: Game) => { delete g.battle!.smugglerCollectionVersion; },
+ ]) {
+ const bad = structuredClone(pending); corrupt(bad);
+ assert.throws(() => viewGame(bad, 'a'));
+ assert.throws(() => normalizeAutomaticGame(bad));
+ reject(bad, { type: 'traitorCall', call: false });
+ }
+ assert.equal(viewGame(smugglerBattle(), 'd').battle!.smugglerCollection, null);
+ assert.equal('frame' in viewGame(pending, 'd').battle!.smugglerCollection!, false);
+});
+void test('own modified plans and multiple piles fail before sealing without changing any resources', () => {
+ const game = smugglerBattle({ advanced: true, atreides: true });
+ game.players[0].battleLosses = 7;
+ reject(game, { type: 'battlePlan', leader: 'atreides-0', dial: 0, support: 0, kwisatz: true });
+ const multiple = smugglerBattle(); multiple.spice['wind_pass:15'] = 2;
+ reject(multiple, { type: 'battlePlan', leader: 'emperor-0', dial: 0 });
+ const trainer = smugglerBattle({ captured: true, hide: false });
+ setOtherSkill(trainer, 'warmaster', 'a');
+ reject(trainer, { type: 'battlePlan', leader: 'guild-0', dial: 0, weapon: trainer.players[0].hand[0].id });
+});
+void test('the independent Ecaz card variant is gated before a Smuggler plan is sealed', () => {
+ const game = smugglerBattle();
+ game.ecazTreachery = true;
+ game.deck.push(...ecazTreacheryCards());
+ // The variant inventory is valid: admission is rejected for its public scope,
+ // irrespective of which player secretly holds an expansion card.
+ const own = viewGame(game, 'a');
+ assert.equal(own.ecazTreachery, true);
+ const action = { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 };
+ assert.throws(() => applyAction(game, 'a', action), /other optional modules/);
+ reject(game, action);
+});
+void test('all four actual bot profiles retain a legal unmodified path and finish pending collection', () => {
+ for (const difficulty of DIFFICULTIES) {
+ let game = smugglerBattle({ advanced: true, atreides: true });
+ game.players[0].battleLosses = 7;
+ // Only the skilled leader is available here; other living discs fought elsewhere.
+ for (const leader of game.players[0].leaders.slice(1)) leader.usedAt = 'arrakeen';
+ for (let step = 0; game.battle && step < 20; step++) {
+ const candidates = game.players.flatMap(p => {
+ const view = viewGame(game, p.id); view.players.find(v => v.id === p.id)!.bot = difficulty;
+ return botActions(view).map(action => ({ id: p.id, action }));
+ });
+ assert.ok(candidates.length, difficulty);
+ const { id, action } = candidates[0];
+ if (id === 'a' && action.type === 'battlePlan') assert.ok(!action.kwisatz);
+ game = applyAction(game, id, action);
+ game = JSON.parse(JSON.stringify(game));
+ }
+ assert.equal(game.battle, null, difficulty);
+ assert.ok(game.lastBattleContext!.smugglerCollection, difficulty);
+ }
+});