From 63298be1e147db378c772356c87b2ace19d74ec7 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sat, 17 Oct 2026 11:33:07 +1100
Subject: [PATCH] Prototype modifier-aware Smuggler battle collection
---
components/game-table.tsx | 6 +-
components/leader-skill-battle-guide.tsx | 2 +-
components/leader-skills.tsx | 2 +-
docs/CHOAM_LEADER_SKILLS.md | 8 +-
docs/CURRENT_STATUS.md | 20 ++
docs/IMPLEMENTATION_STATUS.md | 49 ++++
docs/IX_LEADER_SKILLS.md | 6 +-
docs/LEADER_SKILLS_RULES.md | 19 ++
docs/LEADER_SKILLS_RUNTIME.md | 5 +-
docs/RULE_DECISIONS.md | 6 +-
docs/SMUGGLER_BATTLE.md | 89 +++++--
docs/SMUGGLER_NO_FIELD.md | 5 +-
docs/TLEILAXU_LEADER_SKILLS.md | 17 +-
game/bots.ts | 3 +-
game/engine.ts | 8 +-
game/reference.ts | 12 +-
game/smuggler-battle.ts | 32 +--
tests/fixture-smuggler-artillery-journey.ts | 180 ++++++++++++++
tests/fixture-smuggler-kwisatz-journey.ts | 252 ++++++++++++++++++++
tests/smuggler-artillery-journey.test.ts | 70 ++++++
tests/smuggler-battle.test.ts | 38 ++-
tests/smuggler-kwisatz-journey.test.ts | 91 +++++++
22 files changed, 836 insertions(+), 84 deletions(-)
create mode 100644 tests/fixture-smuggler-artillery-journey.ts
create mode 100644 tests/fixture-smuggler-kwisatz-journey.ts
create mode 100644 tests/smuggler-artillery-journey.test.ts
create mode 100644 tests/smuggler-kwisatz-journey.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index e6950eb9..79a186c3 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -78,7 +78,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 { smugglerBattlePlanBlock } from '@/game/smuggler-battle';
import { SmugglerBattleCollection } from './smuggler-battle-collection';
import { SandmasterVictorySpice } from './sandmaster-victory-spice';
import { RihaniChoice, RihaniHistory } from './rihani-decipherer';
@@ -574,7 +574,7 @@ export function GameTable({
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;
+ }, g.battle.smugglerCollectionSupported, { territory: g.battle.territory, spice: g.spice }) : null;
const occupyControl = ecazOccupyPlanControl(g,
committed.dial ? Number(committed.dial.value) : battleDial,
battleFreeSupport ? 0 : battleSupport);
@@ -5156,7 +5156,7 @@ export function GameTable({
<>
{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 piles.
- If the leader survives, collection resolves before battle settlement, even after defeat. A partial collection across several piles requires its owner’s sector choice. That spice is not yet spendable.
+ A surviving leader collects unless an actual zero-strength weapon effect resolves. Collection settles before other battle cleanup, even after defeat; a partial collection across several piles requires its owner’s sector choice. This reveal-time cap is not yet spendable.
}
{!g.battle.traitorVoters.includes(me.id) && (
diff --git a/components/leader-skill-battle-guide.tsx b/components/leader-skill-battle-guide.tsx
index 8140977a..aeaf60cd 100644
--- a/components/leader-skill-battle-guide.tsx
+++ b/components/leader-skill-battle-guide.tsx
@@ -20,7 +20,7 @@ export function LeaderSkillBattleGuide({ game, leader }: { game: GameView; leade
{sandmaster &&
If this Sandmaster survives and wins, add 3 spice to an existing pile in the territory. One pile resolves automatically; with several, provisionally choose its sector. No existing spice means no addition.
}
{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 the territory’s positive piles when both plans reveal, capped in total by this leader’s unmodified strength. A surviving leader collects even if you lose or have no forces left. A partial collection across several piles provisionally asks you for sector amounts; single-pile and all/none collections stay automatic. Pending spice cannot fund this plan. Strength modifiers and other combined modules remain under review.'
+ ? 'Smuggler records the territory’s positive piles when both plans reveal. Provisional strength policy: the cap includes the copied or printed disc value, declared leader bonuses and Kwisatz. A surviving leader collects even if you lose or have no forces left, unless an actual zero-strength weapon effect resolves. A partial collection across several piles asks you for sector amounts; single-pile and all/none collections stay automatic. Pending spice cannot fund this plan. This modifier policy is not a publisher ruling; other 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/components/leader-skills.tsx b/components/leader-skills.tsx
index d54b2bcb..d39610d4 100644
--- a/components/leader-skills.tsx
+++ b/components/leader-skills.tsx
@@ -115,7 +115,7 @@ export function LeaderSkillsPanel({
Special-card battle use, Suk Graduate force rescue, Mentat’s private
weapon question and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and
victory spice, Rihani inspections and exchanges, optional Smuggler
- reserve shipping, Spice Banker battle spending, and Diplomat copying
+ reserve shipping and provisional modifier-inclusive battle collection, Spice Banker battle spending, and Diplomat copying
an opposing canonical printed defensive role with a committed Worthless card
after plans are revealed. Source-specific timing and combinations remain
guarded; complete mode acceptance is unfinished.
diff --git a/docs/CHOAM_LEADER_SKILLS.md b/docs/CHOAM_LEADER_SKILLS.md
index 912928d4..a5a89636 100644
--- a/docs/CHOAM_LEADER_SKILLS.md
+++ b/docs/CHOAM_LEADER_SKILLS.md
@@ -97,9 +97,11 @@ remains open.
## Remaining scope
-Normal Banker income, ordinary Mentat questioning, modified Smuggler collection,
-captured replacement entitlement and other recorded skill questions remain
-unfinished. The [bounded Diplomat retreat](LEADER_SKILLS_RUNTIME.md) is connected
+Normal Banker income, ordinary Mentat questioning, captured replacement
+entitlement and other recorded skill questions retain their stated boundaries.
+Modified Smuggler collection follows the later
+[provisional policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026).
+The [bounded Diplomat retreat](LEADER_SKILLS_RUNTIME.md) is connected
for supported battles, not a ruling for modified or simultaneous effects. Mixed
Richese/other faction or optional-module combinations remain separate work. See the
[runtime boundaries](LEADER_SKILLS_RUNTIME.md) and [decision register](RULE_DECISIONS.md).
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c60ed14a..a24bbd82 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Smuggler modifier band — 17 October.** Under the standing recommended-choice
+instruction, provisionally cap by copied/printed disc plus declared skill bonuses
+and KH2. Actual Artillery zeroing voids collection; ordinary Stunner stays a
+projectile. Sole own Traitor bypasses weapons and retains collection. This is
+not a publisher FAQ or a numerical answer to the old question. Original piles,
+survival, spending, bounties and other mode guards stay.
+Both prior raw KH/trainer plans rejected unchanged; they now seal. Genuine
+Atreides training and seven supported casualties unlock KH before the second
+paid conflict: cap7 collects6/7 from actual6/8 piles, never before outcome.
+Shielded Artillery leaves the leader alive but transfers0; own Traitor bypass
+and shielded Stunner transfer6. Winner cleanup precedes ordinary Collection.
+Four390px journeys/32 actions retain those controls, zero overflow/app errors;
+eight four-policy programmes/64 actions use actually offered modified KH plans.
+Conserved unopened decks/cards/identities are explicit; no live army, wallet,
+phase, order, loss count, KH or receipt is assigned. Table admission now uses
+the same original server projection as bots. Parent removed forwarding/default
+pins, the obsolete modifier guard and its unused import. Focused23 cases and
+frozen9-file union86/86, types/lint and build pass; existing build warnings remain.
+Full modes, other exceptional combinations, calibration and deployment stay open.
+
**Native World phase modules — 17 October.** Selected E1/E2 native/base Worlds
now retain original Tech3+ Basic/Advanced and/or Advanced Strongholds2+ through
the independent-card entry. Original38/50 decks, native fees/custody, current
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 12aea6e3..4a235151 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,54 @@
# Dune implementation status
+## 17 October 2026 — Provisional modified Smuggler collection
+
+The original raw KH/trainer plans reject unchanged at the modifier guard.
+Under the later standing choose-recommended instruction, use a visibly
+provisional reveal cap: copied/printed disc plus existing declared skill bonus
+and KH2. No publisher modifier ruling or numerical user answer is claimed.
+General skill survival/card-role rules are in supplied Advanced physical p21;
+p40 permits KH immediately after its seventh actual loss, not this cap formula.
+The old question named Stunner, but the base Stunner is a projectile. Reuse
+actual shared Artillery zeroing: it voids collection even for a shielded survivor,
+without a pile choice; a sole own Traitor bypasses weapons and retains collection.
+
+Keep signed reveal-time piles/cap, pending spending prohibition, controller
+custody, delayed survival settlement, native bounties and existing profile gates.
+No saved field/schema/migration/reset is added. Bots no longer suppress legal KH
+for this skill; table uses the original projected module admission, as bots do.
+Guide, pending notice, reference/checklist and source contracts name the policy.
+
+Two disjoint contributors supplied real native programmes. Actual Atreides
+training, three paid shipments and seven supported first-battle casualties
+produce legally active KH. The second paid conflict uses original6/8-spice
+Wind Pass North/Broken Land blows: cap7 collects6/7, wallet3 stays3 at reveal,
+then becomes9/10 before original winner cleanup. Ordinary Collection remains
+separate. A captured Smuggler's normal trainer bonus is a controlled unit case,
+not claimed natural capture history.
+
+Actual first-turn Emperor/Guild shipments to South Mesa and movement into Red
+Chasm precede shielded Artillery, sole own-Traitor and ordinary Stunner battles.
+At payout Artillery leaves a living Smuggler, wallet5 after support and pile8;
+own Traitor gives wallet13 including original one-spice bounty/pile2; ordinary
+shielded Stunner gives wallet11/pile2. Each retains original winner cleanup,
+card retention and native Collection. No force/wallet/phase/order/loss/KH/
+receipt is assigned. Unopened skill/Spice order and conserved physical card/
+traitor swaps are explicitly disclosed rather than claimed purchases.
+
+Four actual390px journeys/32 actions verify original controls, pending7 without
+credit, zeroing, traitor precedence, Stunner and separate Collection. Final roots
+are390px with no app alert/page error. Eight original four-policy programmes/
+64 actions use offered trained KH plans and finish Collection. Parent removed
+contributed setup/forwarding pins and the obsolete guard expectation; an unused
+bot import found by lint is removed. Owned host/tab/scaffolds are removed;
+human server, real rooms/DBs and deployment were untouched.
+
+Focused23 and final related9-file union86/86, types/lint and build pass. Existing
+deprecation/chunk-size/route-classification warnings remain. Proof artifacts:
+`/tmp/dune-smuggler-modifier-*-proof.json`. No full mode, strategic calibration,
+HTTP/database or comprehensive assurance claim; remaining exceptional bands
+and final deployed acceptance stay open.
+
## 17 October 2026 — Native World independent cards with phase modules
Original independent-card variant authority permits composition; fresh selected
diff --git a/docs/IX_LEADER_SKILLS.md b/docs/IX_LEADER_SKILLS.md
index cc303377..adebede9 100644
--- a/docs/IX_LEADER_SKILLS.md
+++ b/docs/IX_LEADER_SKILLS.md
@@ -86,6 +86,8 @@ These are playability checks, not AI strength studies.
Additional faction/module combinations, unresolved Sandmaster eligibility during
native HMS relocation and other missing Leader Skill effects remain
-separate work. Existing Banker, Mentat, Diplomat and modified-Smuggler questions
-remain in the [decision register](RULE_DECISIONS.md). Full AI implementation and
+separate work. Existing Banker, Mentat and Diplomat questions remain in the
+[decision register](RULE_DECISIONS.md); Smuggler uses the later
+[provisional modifier policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026).
+Full AI implementation and
difficulty tuning wait until all non-AI game features are complete.
diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md
index d0caa449..1eebad2d 100644
--- a/docs/LEADER_SKILLS_RULES.md
+++ b/docs/LEADER_SKILLS_RULES.md
@@ -267,3 +267,22 @@ at plan reveal, transfer Spice Banker escrow on capture, or issue a replacement
skill while the original card is captured. This
source contract supports the bounded [runtime prototype](LEADER_SKILLS_RUNTIME.md);
it is not evidence that the optional module can be enabled publicly.
+
+## Smuggler modifier policy — 17 October 2026
+
+The older numerical question remains unanswered. Under the later standing
+choose-recommended instruction, prototype the reveal-time cap as copied/printed
+disc value plus existing declared Leader Skill bonuses and Kwisatz +2. Only the
+surviving controller collects, and an actual resolved zero-strength weapon
+effect voids collection. This is a provisional interpretation, not a new FAQ.
+The supplied Advanced physical p21 states skill survival and played-card-role
+rules; physical p40 permits KH after the seventh actual loss. Neither passage
+supplies a specific Smuggler modifier calculation.
+
+The old question named Stunner. The canonical base Stunner is a projectile
+weapon; the existing zero-strength effect is Artillery Strike. Use the actual
+shared weapon resolution, not either card's name: a sole own Traitor bypasses
+Artillery, while a surviving shielded leader is still zeroed in an ordinary
+Artillery result. No base weapon or bounty rule changes.
+See [collection timing and policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026).
+
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 96f56dd0..bf84d210 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -891,8 +891,9 @@ Worthless card copying a canonical base Shield or Snooper. Its bounded lower
selected undialed normal/elite counters to an empty adjacent non-stronghold
after a surviving trained leader loses. Modified-defense inheritance remains
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.
+now connects reveal-bound collection after survival, including losers/captives.
+The [17 October modifier policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026)
+includes declared bonuses/KH and actual Artillery zeroing; other combinations stay guarded.
Planetologist and Suk Graduate have the connected
prototypes described below.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 81012e80..f4a64bb4 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -298,7 +298,7 @@ They supersede earlier software-absence claims only within their named scope.
| Already-active Karama / allied No-Field | Honor the already-played rounded one-force Guild rate and bank routing; no implicit additional card or shipment. | [Allied No-Field payment](RICHESE_ALLIED_SHIPMENT_AUDIT.md) |
| Full-hand Nullentropy Box | Basic and Advanced permit an atomic retrieval/Box-disposal exchange with final hand within its actual limit; Box remains last/on top. Intermediate capacity is not adjudicated. | [Box runtime](NULLENTROPY_BOX_ENGINE_AUDIT.md) |
| Kulon with Richese Ornithopter | Both modes coexist in Basic/Advanced. The fixed one-group card maximum remains three; Kulon extends each normal-range group. Explicitly provisional, not a publisher ruling. | [Movement policy](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#provisional-kulon-coexistence--9-october-2026) |
-| Multi-pile battle skills | In both modes, surviving Smuggler allocates the fixed unmodified-strength-capped collection among reveal-time positive piles; Sandmaster winner then selects one remaining pile for +3. Single-pile/all/none/void effects stay automatic. Owner sector selection is provisional, not a publisher allocation ruling. | [Smuggler](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026), [Sandmaster](LEADER_BATTLE_EFFECTS.md#source-contract) |
+| Multi-pile battle skills | In both modes, eligible Smuggler allocates its fixed reveal-time strength-capped amount among original positive piles; the later provisional modifier policy includes declared leader bonuses and KH. Sandmaster winner then selects one remaining pile for +3. Single-pile/all/none/void effects stay automatic. Owner sector selection is provisional, not a publisher allocation ruling. | [Smuggler allocation](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026), [modifier policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026), [Sandmaster](LEADER_BATTLE_EFFECTS.md#source-contract) |
| Multi-pile Sandmaster movement | Ground movement and native HMS relocation choose one existing positive debit sector per entered territory; worm and physical Fremen Ambassador relocation choose one at their destination, or decline collection. HMS selection precedes unchanged native sector collection and survives cancellation/special preparation. Duplicate territory and undeclared ambiguous collection reject. Provisional owner allocation, not a publisher ruling. | [Ground](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026), [HMS](SANDMASTER_MOVEMENT.md#owned-native-hms-pile-allocation--9-october-2026), [Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026), [worm](SANDMASTER_WORM.md#connected-behavior) |
| Basic occupied Homeworld lifecycle | Current sole foreign garrison controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding; the entitlement ends immediately on departure, contest or native repopulation. A recorded source observation is still required. Basic Tupile slot leasing keeps the conservative original-epoch gate. Visibly provisional, not a publisher or numerical user ruling. | [Occupation](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
@@ -713,7 +713,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
| Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) |
-| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
+| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value as the Smuggler base; the controller's own modifiers follow the later provisional policy. Advanced foreign gholas and existing skill questions retain separate contracts. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md), [Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
| Native Ecaz / Leader Skills | Basic/Advanced Ecaz plus classic opponents retains the exact ecaz deck, five native assignment discs, public skills before Loyalty/Traitors and original placement. Ordinary rescue/death/own revival and Ambassador handlers compose. A separately acquired Duke6 may receive the normal Warmaster1, never trained-disc3; surviving release or printed6 death leaves the native trainer intact. Existing-skilled Ecaz paid5 Duke revival preserves the earlier living set-aside result, without authorizing Duke assignment. Advanced Harkonnen and combined Occupy skills remain bounded. | [Native scope and source contracts](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026), [original Duke lifecycle](DUKE_VIDAL_RULES.md) |
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
| Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. Existing leader-skills/banker-income entry preserves selected Tech; native/other overlays stay separate. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. Mentat/Bureaucrat original bounds remain. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) |
@@ -776,7 +776,7 @@ 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) |
| Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Other discard producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](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 and owned multi-pile allocation are now [prototyped](SMUGGLER_BATTLE.md). Modified strength and remaining exceptional compositions stay guarded. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
+| Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
| 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) |
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) |
| 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
index d4dee75e..55b2c8bc 100644
--- a/docs/SMUGGLER_BATTLE.md
+++ b/docs/SMUGGLER_BATTLE.md
@@ -1,8 +1,8 @@
# 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.
+Updated 17 October 2026. **Prototyped, with Partial rules coverage.** Supported
+Basic/Advanced Leader Skills tables include the provisional modifier policy below.
+Public module, mode and publication gates remain closed.
## Native Discovery legal participation — 6 October 2026
@@ -26,13 +26,31 @@ 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 positive spice piles. The amount
-is the smaller of their aggregate total and the unmodified disc strength. This
-pending amount is not personal spice and cannot fund a sealed plan or another
+is the smaller of their aggregate total and the reveal-time leader-strength cap.
+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.
+### Provisional modifier policy — 17 October 2026
+
+Under the standing choose-recommended instruction, include the copied/printed
+disc value, existing declared Leader Skill bonuses and declared Kwisatz +2 in
+the cap. This is an explicit first-version interpretation, not a publisher FAQ
+or an answer to the older numerical question. The authorized Advanced source's
+physical p21 supplies general skill survival/card-role rules, not this cap ruling.
+Printed disc value and bounty do not change. Later actions cannot enlarge the
+recorded piles or spend the pending collection.
+
+An actual resolved zero-strength weapon effect voids collection even when the
+leader survives; it opens no sector-allocation choice and transfers no spice.
+The existing resolver assigns that effect to **Artillery Strike**, not the base
+projectile **Stunner**. A sole own Traitor bypasses weapon effects and therefore
+does not zero collection; opposing/mutual Traitors retain their survival rules.
+The receipt retains its original reveal-time cap and records `void` when zeroed.
+
+
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
@@ -49,10 +67,11 @@ payment, so it does not invoke Banker or Bureaucrat payment handling.
Single-pile collection remains automatic with no acknowledgement. Partial
multi-pile collection asks only the surviving controller to choose concrete
sectors; collecting all or none remains automatic. The choice has no decline.
-- 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.
+- AI candidates and player controls admit legal declared Kwisatz and trainer
+ bonuses through the shared plan guard. The table and bots use the server's
+ projected module admission rather than reconstructing private configuration.
+ Native face-up skilled discs still cannot be selected without concealment;
+ captives and mandatory preparation retain their original paths.
- Existing single-pile signed receipts have JSON/SQLite continuation evidence.
Multi-pile choices retain the same battle binding and have changed-path JSON
and real-engine control smoke; comprehensive persistence assurance remains
@@ -66,8 +85,8 @@ payment, so it does not invoke Banker or Bureaucrat payment handling.
## Multi-pile allocation policy and pure API — 9 October 2026
-The printed collection amount applies to the territory's aggregate existing
-spice, capped by the unmodified leader strength. The available source does not
+The collection amount applies to aggregate existing spice, capped by the
+recorded strength policy above. The available source does not
specify which sectors supply a partial multi-pile collection. **Owner-selectable
sectors are a provisional prototype allocation policy, not a publisher ruling.**
The owner controls only distribution, never the mandatory amount. Controls appear
@@ -83,18 +102,18 @@ fill and require the exact total within each revealed pile's capacity.
aggregate total. Signed owner choices add
`allocations?: Record`. Single-pile and empty receipts retain
their original shape.
-- `smugglerBattleAllocationRequired(receipt, survives): boolean` reports a
- missing choice only for a surviving pending receipt with at least two positive
- piles and `0 < amount < before`. All/none collection and death need no choice.
+- `smugglerBattleAllocationRequired(receipt, eligible): boolean` reports a
+ missing choice only for an eligible pending receipt with at least two positive
+ piles and `0 < amount < before`. All/none, death and actual zeroing need no choice.
- `chooseSmugglerBattleAllocation(receipt, allocations): SmugglerBattleReceipt`
verifies the pending signature, accepts only reveal-time positive keys and
nonnegative safe integers within their recorded capacities, requires a total
exactly equal to `amount`, omits zero entries, and returns a new signed receipt
without mutating its input.
-- `settleSmugglerBattle(receipt, survives, spice)` verifies the signed receipt
- and unchanged reveal-time positive piles, requires the surviving ambiguous
+- `settleSmugglerBattle(receipt, eligible, spice)` verifies the signed receipt
+ and unchanged reveal-time positive piles, requires any eligible ambiguous
choice, debits those concrete sectors, and returns `{ receipt, amount, spice }`.
- Death returns a signed `void` receipt with zero transfer; forced all/none
+ Death or actual zeroing returns a signed `void` with zero transfer; forced all/none
collection is automatic. A settled receipt cannot be settled twice.
- `SmugglerBattleCollectionOffer` is the owner-only public shape
`{ event: string, player: string, territory: string, amount: number,
@@ -111,12 +130,12 @@ printed rulings.
## 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 and other optional modules 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.
+The old modifier question received no numerical answer; the visibly provisional
+17 October policy above supplies usable behavior under the standing instruction.
+It does not settle every exceptional skill interaction. The independent Ecaz
+card variant and unrelated optional-module envelopes retain their admission
+guards. These restrictions are not printed prohibitions. Existing revealed
+weapon/Stone/Mirror profiles and original settlement order remain separate.
Focused engine tests cover winners/losers in both modes, empty/short piles,
zero surviving forces, death and Traitor outcomes, explosion, capture custody,
@@ -126,3 +145,27 @@ 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.
+
+## Modified-strength runtime proof — 17 October 2026
+
+Actual old raw KH/trainer plans reject unchanged before the cutover. The new
+focused23 cases and related9-file union86/86 pass, as do types/lint and build
+with existing warnings. New source-bound cases:
+`tests/smuggler-kwisatz-journey.test.ts`,
+`tests/fixture-smuggler-kwisatz-journey.ts`,
+`tests/smuggler-artillery-journey.test.ts` and
+`tests/fixture-smuggler-artillery-journey.ts`.
+
+Seven actual supported casualties unlock KH; real next-turn6/8-spice blows
+yield cap7 collections6/7, with no reveal-time credit. Actual paid shipments
+and movement precede shielded Artillery0, sole own-Traitor bypass6 and ordinary
+shielded Stunner6. Original winner cleanup exposes payout before separate
+Collection. Unopened deck/card/identity staging is explicit; no live force,
+wallet, phase, order, loss count, KH or receipt is assigned.
+
+Four390px control journeys/32 actions and eight four-policy programmes/64
+actions reach original Collection. The table and bots use the original server
+admission projection. Other exceptional skill/mode compositions, full strategy,
+comprehensive assurance and deployed acceptance remain open. No real room,
+database, human server or deployment was changed.
+
diff --git a/docs/SMUGGLER_NO_FIELD.md b/docs/SMUGGLER_NO_FIELD.md
index 227309dd..54b1ef01 100644
--- a/docs/SMUGGLER_NO_FIELD.md
+++ b/docs/SMUGGLER_NO_FIELD.md
@@ -83,6 +83,7 @@ traitor remained unchanged through the final refresh.
Allied free-companion composition, Homeworlds, Nexus, Discoveries, Stronghold
Cards, Tech Tokens and other combined modes remain outside this prototype.
-Unmodified Smuggler battle collection now has its separate native reveal/
-survival contract; the existing [Leader Skills material questions](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources)
+Smuggler battle collection has its separate native reveal/survival contract,
+now including the explicit [modifier policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026).
+Other [Leader Skills material questions](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources)
remain pending.
diff --git a/docs/TLEILAXU_LEADER_SKILLS.md b/docs/TLEILAXU_LEADER_SKILLS.md
index b0cd4732..d9cc1194 100644
--- a/docs/TLEILAXU_LEADER_SKILLS.md
+++ b/docs/TLEILAXU_LEADER_SKILLS.md
@@ -90,11 +90,11 @@ kills a living winning leader without a bounty and returns its skill once.
Older receipts without the new boundary retain their original signature shape.
Shared profile checks extend the existing ordinary movement, shipment, payment
-and battle effects. Zoal's unmodified Smuggler collection uses the opposing
-disc's printed value from the two revealed plans, including zero against Cheap
-Hero; it does not use Zoal's revival value or copy the opponent's skill bonus.
-This follows the existing [Zoal and skill contract](LEADER_SKILLS_RULES.md) and
-[Smuggler boundary](SMUGGLER_BATTLE.md), without settling modified-strength cases.
+and battle effects. Zoal's Smuggler base uses the opposing disc's printed value
+from the two revealed plans, including zero against Cheap Hero; it does not use
+Zoal's revival value or copy the opponent's skill bonus. The controller's own
+declared modifiers follow the later [provisional Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026).
+This preserves the existing [Zoal and skill contract](LEADER_SKILLS_RULES.md).
## Entry and checks
@@ -124,9 +124,10 @@ Private source-bound reports record final checks, game samples and browser work.
## Remaining work
-Normal Banker income, ordinary Mentat questioning, modified Smuggler collection,
-captured replacement entitlement and other recorded skill questions remain
-incomplete. The [bounded Diplomat retreat](LEADER_SKILLS_RUNTIME.md) is connected
+Normal Banker income, ordinary Mentat questioning, captured replacement
+entitlement and other recorded skill questions retain their stated boundaries.
+Modified Smuggler collection follows the linked provisional policy above.
+The [bounded Diplomat retreat](LEADER_SKILLS_RUNTIME.md) is connected
for supported battles, not modified or simultaneous effects. See the
[runtime boundaries](LEADER_SKILLS_RUNTIME.md) and
[rule decisions](RULE_DECISIONS.md). Full AI implementation and tuning wait
diff --git a/game/bots.ts b/game/bots.ts
index 44915cea..ae738ea1 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3,7 +3,7 @@ import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions }
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
-import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
+import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus } from './leader-skill-combat';
import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
@@ -791,7 +791,6 @@ 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 &&
diff --git a/game/engine.ts b/game/engine.ts
index 0fab411a..7976b900 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -23048,16 +23048,16 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
(nativeExpansionLeaderSkillsProfile(g) || 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.');
const collection = b.smugglerCollection;
- const survives = !!collection &&
+ const collects = !!collection && !quote.effects?.stunned &&
!(collection.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender);
const smugglerAllocation: Extract | null =
- collection && smugglerBattleAllocationRequired(collection, survives)
+ collection && smugglerBattleAllocationRequired(collection, collects)
? { kind: 'smugglerCollection', event: b.event!, player: collection.player,
territory: b.territory, amount: collection.amount, piles: collection.piles! }
: null;
const smuggler = collection && !smugglerAllocation
- ? nexusRule(() => settleSmugglerBattle(collection, survives, g.spice)) : null;
- if (collection && survives)
+ ? nexusRule(() => settleSmugglerBattle(collection, collects, g.spice)) : null;
+ if (collection && collects)
requireRule(Number.isSafeInteger(getPlayer(g, collection.player).spice + collection.amount),
'Smuggler collection would overflow the spice balance.');
const afterCollection = smuggler?.spice ?? g.spice;
diff --git a/game/reference.ts b/game/reference.ts
index 69c9474b..cf9a6910 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4801,7 +4801,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Basic Tleilaxu with base opponents retains its existing full skill deck. Fresh Advanced native Tleilaxu, optionally Ixians and/or CHOAM, plus classic tables use distinct required Ix/CHOAM decks and all fourteen skills. Starting choices, actual traitors and three native Face Dancers remain. Unrelated overlays and public starts stay gated.',
'Rihani draws become unrevealed Face Dancers. Keep one new card by revealing and returning one unrevealed old card; the other new card returns privately. Only the owner sees the inspection history and new identities.',
- 'A successful own-leader revival preserves the optional skill draw after revival responses. Winner skill and card cleanup precede Face Dance; a killed skilled leader returns its card once. Zoal copies the opposing disc value for unmodified Smuggler collection.',
+ 'A successful own-leader revival preserves the optional skill draw after revival responses. Winner skill and card cleanup precede Face Dance; a killed skilled leader returns its card once. Zoal copies the opposing disc value before the shared provisional Smuggler modifier cap.',
'Actual foreign revival pays its native half price and fills the controlled living pool only up to five, but never draws or assigns a skill. The original faction keeps the sole physical disc; Tleilaxu controls it. A native trainer can supply its normal role bonus to that different selected disc, not its trained lower bonus. Own revival still offers its eligible optional draw. Auditor and the Ecaz-only Duke/Ghola exclusions remain.',
'Foreign death and negotiated original-owner buyback use original handlers. Harkonnen capture temporarily overrides Tleilaxu control without moving the disc into another physical roster. Its actual plan reveal removes the concealed snapshot; surviving use returns the ghola to Tleilaxu. Execution preserves the original dead-ghola return path. No skill attaches to a foreign disc.',
'Other unfinished skill effects retain their stated limits. This development combination does not certify the full expansion or every optional-module interaction.',
@@ -4925,7 +4925,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 and unmodified battle collection, Sandmaster route and worm-ride 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 provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'Fresh all14 Leader Skills preserves original classic, supported E1/E2 or standalone Ecaz OR Moritani setup with Homeworlds/Discovery/Tech3+/Advanced Strongholds under each envelope. Explicit --nexus-cards additionally admits single/paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47 and standalone OR paired Ecaz/Moritani/ecaz33 with classics. Basic/Advanced2–6, original Ix pre-training offer, ordinary native discs and actual hands remain; Auditor/shared Duke never train. Winning normal/trained Suk uses source-labelled native, visitor or nested fixed-loss pools; native saved counters stay at that home and external Emperor returns allocate original homes. Original phase-end industry, held benefits, cleanup/Tech before Face Dance and native assassination remain; unreconciled bands, mixed E3/three-family, public starts and played-save conversion stay separate.',
'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.',
@@ -4955,18 +4955,18 @@ export const RULE_TOPICS: RuleTopic[] = [
'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.',
- 'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.',
+ 'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.',
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
- { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler shipping, No-Field accompaniment and unmodified battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income remains a separate profile; other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',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/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.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 shipping, No-Field accompaniment and provisional modifier-inclusive battle collection, plus Sandmaster ground, worm, physical Ambassador and native HMS allocation are connected. Normal Mentat private questions and historical observations use the user-approved uniform step in new supported battles without an extra opt-in. Spice Banker lower spending and native Diplomat printed-defense copying are connected; trained retreat transfers undialed forces before losing casualties. Normal Banker income remains a separate profile; other combined interactions stay open. Bureaucrat redirects supported third-party payments with saved once-per-phase use, while user-classified gifts stay outside payment handling.',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/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.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 surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. 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/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.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, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. 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:'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 modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. 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. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; 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/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
- { area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Earlier native/optional-module and recovery evidence remains dated in linked contracts. Modified Smuggler adds actual seven-loss KH activation, short/ample reveal caps, shielded Artillery zeroing, sole-own-Traitor precedence and ordinary Stunner collection. Focused23 cases pass. Four390px journeys/32 actions retain winner cleanup and separate Collection; eight four-policy programmes/64 actions use offered modified KH plans. Provisional policy is not a publisher ruling or full-mode/deployed acceptance.', evidence: ['tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
],
},
{
diff --git a/game/smuggler-battle.ts b/game/smuggler-battle.ts
index acb9d4b6..55de7016 100644
--- a/game/smuggler-battle.ts
+++ b/game/smuggler-battle.ts
@@ -39,10 +39,6 @@ export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boo
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.'; }
@@ -74,9 +70,15 @@ export function createSmugglerBattle(input: {
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;
+ // Provisional reveal-time cap: copied disc value plus the existing leader
+ // bonuses and Kwisatz. Actual weapon zeroing is applied with survival.
+ const strength = input.plan.leader!.strength + leaderSkillBattleBonus({
+ assignments: input.plan.assignments,
+ selectedLeader: { id: input.plan.leader!.id, kind: 'disc' },
+ weapon: input.plan.weapon, defense: input.plan.defense, skilledLeaderSurvives: true,
+ }).bonus + (input.plan.kwisatz ? 2 : 0);
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.');
+ !Number.isSafeInteger(strength) || strength < 0) throw new Error('Smuggler needs the current battle and a valid leader strength.');
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,
@@ -116,9 +118,9 @@ function normalizeSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
return Object.fromEntries(Object.entries(allocations).filter(([, amount]) => amount > 0)
.sort(([a], [b]) => a.localeCompare(b)));
}
-/** Only a surviving leader's partial collection from multiple piles needs an owner choice. */
-export function smugglerBattleAllocationRequired(receipt: SmugglerBattleReceipt, survives: boolean): boolean {
- return survives && receipt.stage === 'pending' && receipt.allocations === undefined &&
+/** A partial multi-pile collection needs a choice only when collection is eligible. */
+export function smugglerBattleAllocationRequired(receipt: SmugglerBattleReceipt, eligible: boolean): boolean {
+ return eligible && receipt.stage === 'pending' && receipt.allocations === undefined &&
Object.keys(recordedPiles(receipt)).length > 1 && receipt.amount > 0 && receipt.amount < receipt.before;
}
export function chooseSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
@@ -130,21 +132,21 @@ export function chooseSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
chosen.signature = smugglerBattleSignature(chosen);
return chosen;
}
-/** Survival controls both winning and losing leaders; this is not bank/player income. */
-export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, survives: boolean, spice: Readonly>) {
+/** Eligibility requires survival without a resolved zero-strength weapon effect. */
+export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, eligible: boolean, spice: Readonly>) {
validateSmugglerBattleReceipt(receipt);
const snapshot = smugglerBattlePile(receipt.territory, spice);
const expected = smugglerBattlePile(receipt.territory, recordedPiles(receipt));
if (JSON.stringify(snapshot) !== JSON.stringify(expected))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
- if (smugglerBattleAllocationRequired(receipt, survives))
+ if (smugglerBattleAllocationRequired(receipt, eligible))
throw new Error('Choose the Smuggler collection sectors before settling the surviving leader.');
- const amount = survives ? receipt.amount : 0;
- const allocations = !survives || !amount ? {} : receipt.allocations ??
+ const amount = eligible ? receipt.amount : 0;
+ const allocations = !eligible || !amount ? {} : receipt.allocations ??
(receipt.key ? { [receipt.key]: amount } : recordedPiles(receipt));
const remaining = { ...spice };
for (const [key, collected] of Object.entries(allocations)) remaining[key] -= collected;
- const settled: SmugglerBattleReceipt = { ...receipt, stage: survives ? 'collected' : 'void', signature: '' };
+ const settled: SmugglerBattleReceipt = { ...receipt, stage: eligible ? 'collected' : 'void', signature: '' };
settled.signature = smugglerBattleSignature(settled);
return { receipt: settled, amount, spice: remaining };
}
diff --git a/tests/fixture-smuggler-artillery-journey.ts b/tests/fixture-smuggler-artillery-journey.ts
new file mode 100644
index 00000000..0c34d90f
--- /dev/null
+++ b/tests/fixture-smuggler-artillery-journey.ts
@@ -0,0 +1,180 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import {
+ advancedNativePlayer, completeAdvancedNativeSkillsSetup, initializeAdvancedNativeSkillsSetup,
+} from './fixture-advanced-native-skills';
+import { takeHarassCard } from './fixture-harass-withdraw';
+import { nextSkillsTechBattleStep, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
+import { quoteSpiceBankerIncomeBattleEconomics } from './fixture-spice-banker-income';
+
+export type SmugglerArtilleryJourneyOptions = { weapon?: 'artillery' | 'stunner'; ownTraitor?: boolean };
+export type SmugglerArtilleryJourney = {
+ initial: Game; afterSetup: Game; beforeBattle: Game; game: Game;
+ owner: string; opponent: string; observer: string; leader: string; opposingLeader: string;
+ territory: string; key: string; printedAmount: number; cap: number;
+ weapon: string; shields: [string, string]; ownTraitor: boolean;
+ plans: [SkillsTechBattleStep, SkillsTechBattleStep];
+ shipments: { before: Game; action: Action; actor: string; after: Game; price: number }[];
+ movements: { before: Game; action: Action; actor: string; after: Game }[];
+ steps: SkillsTechBattleStep[]; staging: string[];
+};
+
+export const smugglerArtilleryPlayer = (game: Game, id: string) => {
+ const player = game.players.find(p => p.id === id); assert.ok(player); return player;
+};
+const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
+const act = (game: Game, actor: string, action: Action, steps: SkillsTechBattleStep[]) => {
+ steps.push({ actor, action: structuredClone(action) }); return applyAction(game, actor, action);
+};
+function next(game: Game): SkillsTechBattleStep {
+ if (game.decision?.kind === 'leaderSkillVisibility')
+ return { actor: game.decision.player, action: { type: 'leaderSkillVisibility',
+ event: game.decision.event, hide: game.decision.player === 'emperor' } };
+ return nextSkillsTechBattleStep(game);
+}
+function advance(state: Game, until: (game: Game) => boolean, steps: SkillsTechBattleStep[]): Game {
+ let game = state;
+ for (let n = 0; !until(game) && n < 1000; n++) {
+ assert.equal(game.turn, 1, 'Continue only this original first-turn programme');
+ assert.equal(game.status, 'playing');
+ assert.notEqual(game.decision?.kind, 'smugglerCollection', 'This single printed pile requires no allocation choice');
+ const step = next(game); game = act(game, step.actor, step.action, steps);
+ }
+ assert.ok(until(game), 'Reach the exact original native action boundary'); return game;
+}
+
+/** Fresh native Ix original47 + all14 setup, Emperor's actual Smuggler offer and
+ * trainer choice, zero first-Storm dials, real eight-spice Red Chasm blow. Both
+ * combatants pay to ship two normal counters to South Mesa, then really move
+ * them one territory into Red Chasm. No live army, wallet, phase, order, owner,
+ * skill assignment or receipt is assigned. Physical staging is listed below.
+ */
+export function createSmugglerArtilleryJourney(
+ options: SmugglerArtilleryJourneyOptions = {},
+): SmugglerArtilleryJourney {
+ const owner = 'emperor', opponent = 'guild', observer = 'ixians';
+ const initial = createGame('SMUGGLERARTILLERY', newPlayer(owner, owner, 'emperor'), true, ['ix']);
+ joinGame(initial, newPlayer(observer, observer, 'ixians'));
+ joinGame(initial, newPlayer(opponent, opponent, 'guild'));
+ const setupOptions = { family: 'ixians' as const, requestedSkill: 'smuggler' as const,
+ skillOwner: 'emperor' as const, opponents: ['guild'] as const, initial };
+ let game = completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(setupOptions), setupOptions);
+ const afterSetup = structuredClone(game), steps: SkillsTechBattleStep[] = [];
+ const staging = [
+ 'Control only the first original all14 shuffle so Emperor is offered Smuggler; choose its original eligible Hasimir Fenring disc through the setup action.',
+ 'Position original unopened Red Chasm (8 spice, sector 7) and Broken Land non-worm Spice Cards before the first native Spice Blow; no pile is assigned.',
+ ];
+ for (const [position, territory] of ['red_chasm', 'broken_land'].entries()) {
+ const index = game.spiceDeck.findIndex(card => 'territory' in card && card.territory === territory);
+ assert.ok(index >= 0); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+ }
+ const territory = 'red_chasm', key = 'red_chasm:7', printedAmount = 8;
+ const shipments: SmugglerArtilleryJourney['shipments'] = [];
+ const movements: SmugglerArtilleryJourney['movements'] = [];
+ game = advance(game, g => g.phase === 5 && clean(g), steps);
+ assert.equal(game.spice[key], printedAmount);
+ while (game.phase === 5) {
+ if (!clean(game)) { const step = next(game); game = act(game, step.actor, step.action, steps); continue; }
+ const actor = game.active!;
+ if ([owner, opponent].includes(actor)) {
+ const before = structuredClone(game);
+ const action: Action = { type: 'ship', territory: 'south_mesa', sector: 5, amount: 2, elite: 0, allyPayment: 0 };
+ game = act(game, actor, action, steps);
+ game = advance(game, clean, steps);
+ const price = actor === owner ? 4 : 2;
+ assert.equal(smugglerArtilleryPlayer(before, actor).spice - smugglerArtilleryPlayer(game, actor).spice, price);
+ shipments.push({ before, action, actor, after: structuredClone(game), price });
+ const beforeMove = structuredClone(game);
+ const movement: Action = { type: 'move', from: 'south_mesa:5', territory, sector: 7, amount: 2, elite: 0 };
+ game = act(game, actor, movement, steps);
+ game = advance(game, clean, steps);
+ movements.push({ before: beforeMove, action: movement, actor, after: structuredClone(game) });
+ assert.equal(smugglerArtilleryPlayer(game, actor).forces[key], 2);
+ }
+ game = act(game, actor, { type: 'endMovement' }, steps);
+ }
+ game = advance(game, g => g.phase === 6 && clean(g), steps);
+ // Return starting hands, including the native Ix setup stock, before taking
+ // canonical physical cards. These are conserved transfers, not purchases.
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ const physicalWeapon = treacheryDeck(['ix']).find(card => options.weapon === 'stunner'
+ ? card.name === 'Stunner' : card.kind === 'artillery');
+ assert.ok(physicalWeapon);
+ const weapon = takeHarassCard(game, owner, physicalWeapon.id).id;
+ const shieldFaces = game.deck.filter(card => card.kind === 'shield').slice(0, 2);
+ assert.equal(shieldFaces.length, 2);
+ const shields: [string, string] = [takeHarassCard(game, owner, shieldFaces[0].id).id,
+ takeHarassCard(game, opponent, shieldFaces[1].id).id];
+ staging.push(`Conserve all original starting hands back to undealt stock, then transfer original ${physicalWeapon.name} (${weapon}) and Shield ${shields[0]} to Emperor, Shield ${shields[1]} to Guild. No card is created or purchased.`);
+ const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'smuggler');
+ assert.ok(assignment);
+ const leader = assignment.leader;
+ const cap = advancedNativePlayer(game, 'emperor').leaders.find(l => l.id === leader)!.strength;
+ assert.equal(cap, 6);
+ const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
+ const opposing = smugglerArtilleryPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
+ .sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(opposing);
+ if (options.ownTraitor && !smugglerArtilleryPlayer(game, owner).traitors.includes(opposing.id)) {
+ const own = smugglerArtilleryPlayer(game, owner), returned = own.traitors[0];
+ const index = game.traitorReserve!.indexOf(opposing.id);
+ const donor = game.players.find(p => p.id !== owner && p.traitors.includes(opposing.id));
+ assert.ok(returned && (index >= 0 || donor));
+ if (index >= 0) game.traitorReserve![index] = returned;
+ else donor!.traitors[donor!.traitors.indexOf(opposing.id)] = returned;
+ own.traitors[0] = opposing.id;
+ staging.push(`Conserve original Traitor ${opposing.id}: exchange Emperor's ${returned} with ${donor?.id ?? 'original reserve'}; no reveal history is assigned.`);
+ }
+ const beforeBattle = structuredClone(game);
+ const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === territory);
+ assert.ok(choice);
+ game = act(game, choice.chooser, { type: 'chooseBattle', territory,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, steps);
+ game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, steps);
+ const plans: SmugglerArtilleryJourney['plans'] = [
+ { actor: owner, action: { type: 'battlePlan', leader, dial: 1, support: 1, weapon, defense: shields[0] } },
+ { actor: opponent, action: { type: 'battlePlan', leader: opposing.id, dial: 0, support: 0, defense: shields[1] } },
+ ];
+ assert.deepEqual(game.battle!.plans, {});
+ assert.equal(game.spice[key], printedAmount);
+ return { initial, afterSetup, beforeBattle, game, owner, opponent, observer, leader, opposingLeader: opposing.id,
+ territory, key, printedAmount, cap, weapon, shields, ownTraitor: options.ownTraitor ?? false,
+ plans, shipments, movements, steps, staging };
+}
+
+export function revealSmugglerArtilleryJourney(f: SmugglerArtilleryJourney, steps: SkillsTechBattleStep[] = []): Game {
+ let game = structuredClone(f.game);
+ for (const plan of f.plans) {
+ game = act(game, plan.actor, plan.action, steps);
+ game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, steps);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+
+/** Original own call first; the other seat explicitly declines. Stop at the
+ * winner's real optional card cleanup, after payout but before Collection.
+ * The independent native quote uses the actual own call and the pending other
+ * seat's no-call, never a rewritten live battle or a synthetic weapon effect.
+ */
+export function settleSmugglerArtilleryJourney(state: Game, f: SmugglerArtilleryJourney,
+ steps: SkillsTechBattleStep[] = []) {
+ let game = advance(state, g => !!g.battle?.revealed && clean(g), steps);
+ game = act(game, f.owner, { type: 'traitorCall', call: f.ownTraitor }, steps);
+ game = advance(game, g => !!g.battle?.revealed && clean(g), steps);
+ const beforeResolution = structuredClone(game);
+ const resolution = quoteSpiceBankerIncomeBattleEconomics(beforeResolution, beforeResolution).resolution;
+ game = act(game, f.opponent, { type: 'traitorCall', call: false }, steps);
+ game = advance(game, g => g.decision?.kind === 'battleCards' && !g.response && !g.phaseOpening, steps);
+ assert.equal(game.decision?.player, f.owner);
+ assert.equal(game.phase, 6);
+ assert.ok(game.lastBattleContext?.smugglerCollection);
+ return { game, beforeResolution, resolution };
+}
+
+/** Keep the winner's optional cards and resume the real next Collection. */
+export function finishSmugglerArtilleryJourney(state: Game, steps: SkillsTechBattleStep[] = []): Game {
+ assert.equal(state.decision?.kind, 'battleCards');
+ const game = act(state, state.decision!.player, { type: 'decision', discard: [] }, steps);
+ return advance(game, g => g.phase === 7 && clean(g), steps);
+}
diff --git a/tests/fixture-smuggler-kwisatz-journey.ts b/tests/fixture-smuggler-kwisatz-journey.ts
new file mode 100644
index 00000000..d0165333
--- /dev/null
+++ b/tests/fixture-smuggler-kwisatz-journey.ts
@@ -0,0 +1,252 @@
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import {
+ advancedNativePlayer, assertAdvancedNativeCustody, initializeAdvancedNativeSkillsSetup,
+} from './fixture-advanced-native-skills';
+import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
+
+export type SmugglerKwisatzJourneyStep = { actor: string; action: Action };
+export type SmugglerKwisatzJourneyTransition = {
+ before: Game; step: SmugglerKwisatzJourneyStep; after: Game;
+};
+export type SmugglerKwisatzJourneyOptions = {
+ /** A genuine fresh undealt Advanced Ix/Atreides/Guild lobby; retain its seat IDs. */
+ initial?: Game;
+ /** Both are original Spice Blow cards: six at Wind Pass North, eight at Broken Land. */
+ pile?: 'short' | 'ample';
+};
+export type SmugglerKwisatzJourneyFixture = {
+ beforeSetup: Game;
+ training: SmugglerKwisatzJourneyTransition[];
+ afterSetup: Game;
+ firstBattle: {
+ before: Game;
+ game: Game;
+ planActions: [SmugglerKwisatzJourneyStep, SmugglerKwisatzJourneyStep];
+ revealed: Game;
+ settled: Game;
+ steps: SmugglerKwisatzJourneyStep[];
+ };
+ shipments: SmugglerKwisatzJourneyTransition[];
+ /** Second-turn original Battle controls, neither plan sealed. */
+ game: Game;
+ owner: string;
+ opponent: string;
+ observer: string;
+ trainer: string;
+ shield: string;
+ territory: string;
+ key: string;
+ planActions: [SmugglerKwisatzJourneyStep, SmugglerKwisatzJourneyStep];
+ steps: SmugglerKwisatzJourneyStep[];
+ staging: string[];
+};
+
+const quiet = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
+const plansReady = (game: Game) => quiet(game) && !!game.battle &&
+ !game.battle.preparation && game.battle.preLeader?.closed !== false;
+
+/** Native no-bid/no-call policy; all preparation and optional card cleanup remain actions. */
+export function nextSmugglerKwisatzJourneyStep(game: Game): SmugglerKwisatzJourneyStep {
+ if (game.decision?.kind === 'battleCards')
+ return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
+ if (game.decision?.kind === 'leaderSkillVisibility')
+ return { actor: game.decision.player, action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } };
+ if (!game.response && !game.decision && !game.phaseOpening && game.battle?.preLeader?.closed === false) {
+ const battle = game.battle;
+ return { actor: battle.preLeader!.ready.includes(battle.attacker) ? battle.defender : battle.attacker,
+ action: { type: 'battlePreparationReady', event: battle.preLeader!.event } };
+ }
+ const step = nextSpiceBankerIncomeNativeStep(game);
+ assert.ok(step, 'The original native programme must retain a continuation.');
+ return step;
+}
+
+export function advanceSmugglerKwisatzJourney(
+ state: Game, until: (game: Game) => boolean, steps: SmugglerKwisatzJourneyStep[] = [],
+): Game {
+ let game = state;
+ for (let i = 0; !until(game) && i < 1000; i++) {
+ assert.equal(game.status, 'playing');
+ const step = nextSmugglerKwisatzJourneyStep(game);
+ steps.push(step); game = applyAction(game, step.actor, step.action);
+ }
+ assert.ok(until(game), 'The native programme did not reach its requested action window.');
+ return game;
+}
+
+/** No live force, wallet, turn, phase, initiative, loss count, KH or receipt is staged.
+ * A paid Guild attack meets the original ten Arrakeen forces. Atreides really
+ * supports/dials seven with another disc, wins, and loses seven forces to the Tanks.
+ * Turn two has exactly one conflict, on an original, genuinely blown spice pile.
+ * The trained Smuggler + legally earned KH uses a conserved original Shield;
+ * its winner-card decision exposes the single collection before ordinary Collection.
+ */
+export function createSmugglerKwisatzJourney(
+ options: SmugglerKwisatzJourneyOptions = {},
+): SmugglerKwisatzJourneyFixture {
+ let game = initializeAdvancedNativeSkillsSetup({ family: 'ixians', requestedSkill: 'smuggler',
+ skillOwner: 'atreides', opponents: ['guild'], initial: options.initial });
+ const beforeSetup = structuredClone(game);
+ const owner = advancedNativePlayer(game, 'atreides').id;
+ const opponent = advancedNativePlayer(game, 'guild').id;
+ const observer = advancedNativePlayer(game, 'ixians').id;
+ const steps: SmugglerKwisatzJourneyStep[] = [];
+ const training: SmugglerKwisatzJourneyTransition[] = [];
+ const act = (step: SmugglerKwisatzJourneyStep) => {
+ steps.push(step); game = applyAction(game, step.actor, step.action); return game;
+ };
+ for (let i = 0; game.status === 'setup' && i < 200; i++) {
+ let step: SmugglerKwisatzJourneyStep;
+ if (game.decision?.kind === 'ixSetup') {
+ const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup![0];
+ assert.ok(card); step = { actor, action: { type: 'decision', card: card.id } };
+ } else if (game.setupStage === 'leaderSkills') {
+ const actor = Object.keys(game.leaderSkills!.offers)[0];
+ const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
+ const skill = actor === owner ? 'smuggler' : offer.cards.find(card => !view.unavailableSkills?.[card]);
+ assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
+ const eligible = game.players.find(p => p.id === actor)!.leaders
+ .filter(leader => view.eligibleLeaders.some(candidate => candidate.id === leader.id))
+ .sort((a, b) => b.strength - a.strength);
+ assert.ok(eligible[0]);
+ step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: eligible[0].id } };
+ } else step = nextSmugglerKwisatzJourneyStep(game);
+ const before = structuredClone(game); act(step);
+ if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
+ }
+ assert.equal(game.status, 'playing');
+ const afterSetup = structuredClone(game);
+ const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'smuggler')!.leader;
+ const staging = [
+ 'Only the original all14 shuffle is controlled by the native setup helper; Ix starting-card choice, physical offers, leader assignments and Traitor choices are original actions.',
+ 'Reorder four original unopened Spice Cards: Hagga Basin, Funeral Plain, Wind Pass North, Broken Land. Both turn-two piles come from the original Advanced Spice Blow; no pile is assigned.',
+ 'First Storm dials are genuine zero actions. Later Advanced Storm entropy is untouched; its one-to-six sectors cannot reach either second-turn battle pile.',
+ ];
+ for (const [position, territory] of ['hagga_basin', 'funeral_plain', 'wind_pass_north', 'broken_land'].entries()) {
+ const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && card.territory === territory);
+ assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+ }
+ const reach = (until: (game: Game) => boolean) => {
+ game = advanceSmugglerKwisatzJourney(game, until, steps); return game;
+ };
+ const shipments: SmugglerKwisatzJourneyTransition[] = [];
+ const ship = (actor: string, territory: string, sector: number) => {
+ const before = structuredClone(game);
+ const step: SmugglerKwisatzJourneyStep = { actor, action: {
+ type: 'ship', territory, sector, amount: 1, allyPayment: 0,
+ } };
+ act(step); reach(g => quiet(g) && !g.pendingShipment && !g.pendingHomeworldShipment);
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ const movement = (turn: number, territory: string, sector: number, actors: string[]) => {
+ reach(g => g.turn === turn && g.phase === 5 && quiet(g));
+ const shipped = new Set();
+ while (game.turn === turn && game.phase === 5) {
+ if (quiet(game) && actors.includes(game.active!) && !shipped.has(game.active!)) {
+ const actor = game.active!;
+ assert.equal(game.players.find(p => p.id === actor)!.shipped, false);
+ ship(actor, territory, sector); shipped.add(actor);
+ } else act(nextSmugglerKwisatzJourneyStep(game));
+ }
+ assert.deepEqual([...shipped].sort(), [...actors].sort());
+ reach(g => g.turn === turn && g.phase === 6 && quiet(g));
+ };
+ const open = (territory: string) => {
+ const before = structuredClone(game), chooser = game.active!;
+ const choice = viewGame(game, chooser).battleChoices.find(c => c.territory === territory &&
+ [c.attacker, c.defender].includes(owner) && [c.attacker, c.defender].includes(opponent));
+ assert.ok(choice && choice.chooser === chooser);
+ act({ actor: chooser, action: { type: 'chooseBattle', territory,
+ target: choice.attacker === chooser ? choice.defender : choice.attacker } });
+ reach(plansReady); assert.deepEqual(game.battle!.plans, {});
+ return before;
+ };
+ const untrained = (actor: string) => {
+ const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
+ const leader = game.players.find(p => p.id === actor)!.leaders.filter(l => !trained.has(l.id) && !l.dead && !l.usedAt)
+ .sort((a, b) => b.strength - a.strength)[0];
+ assert.ok(leader); return leader.id;
+ };
+
+ movement(1, 'arrakeen', 10, [opponent]);
+ assert.equal(advancedNativePlayer(game, 'atreides').forces['arrakeen:10'], 10);
+ assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 0);
+ const firstStart = steps.length;
+ const firstBefore = open('arrakeen'), firstGame = structuredClone(game);
+ const firstPlans: SmugglerKwisatzJourneyFixture['firstBattle']['planActions'] = [
+ { actor: owner, action: { type: 'battlePlan', leader: untrained(owner), dial: 7, support: 7 } },
+ { actor: opponent, action: { type: 'battlePlan', leader: untrained(opponent), dial: 0, support: 0 } },
+ ];
+ for (const step of firstPlans) { act(step); reach(plansReady); }
+ assert.ok(game.battle?.revealed);
+ const firstRevealed = structuredClone(game), firstEvent = game.battle.event;
+ reach(g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard && g.lastBattleContext?.event === firstEvent);
+ const firstSettled = structuredClone(game);
+ assert.equal(game.lastBattleContext!.winner, owner);
+ assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 7);
+ assert.equal(advancedNativePlayer(game, 'atreides').tanks, 7);
+ assert.equal(advancedNativePlayer(game, 'atreides').forces['arrakeen:10'], 3);
+ const firstSteps = steps.slice(firstStart);
+
+ const territory = options.pile === 'ample' ? 'broken_land' : 'wind_pass_north';
+ const sector = options.pile === 'ample' ? 12 : 17, key = `${territory}:${sector}`;
+ movement(2, territory, sector, [owner, opponent]);
+ assert.equal(game.spice[key], options.pile === 'ample' ? 8 : 6);
+ // Exchange, rather than duplicate or enlarge a hand: this is explicitly not a purchase.
+ const own = advancedNativePlayer(game, 'atreides');
+ let shield = own.hand.find(card => card.kind === 'shield');
+ if (!shield) {
+ const index = game.deck.findIndex(card => card.kind === 'shield');
+ assert.ok(index >= 0 && own.hand.length > 0);
+ shield = game.deck[index]; game.deck[index] = own.hand[0]; own.hand[0] = shield;
+ staging.push('At quiet turn-two Battle entry, exchange one original Atreides hand card with an original Shield in undealt stock, conserving hand size and every physical card. No purchase is claimed.');
+ }
+ open(territory);
+ const enemyLeader = untrained(opponent);
+ const planActions: SmugglerKwisatzJourneyFixture['planActions'] = [
+ { actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, defense: shield.id, kwisatz: true } },
+ { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader, dial: 0, support: 0 } },
+ ];
+ assert.equal(game.turn, 2);
+ assert.equal(advancedNativePlayer(game, 'atreides').battleLosses, 7);
+ assert.ok(!advancedNativePlayer(game, 'atreides').kwisatz?.dead);
+ assert.equal(viewGame(game, owner).players.find(p => p.id === owner)!.kwisatz!.active, true);
+ assertAdvancedNativeCustody(game);
+ return { beforeSetup, training, afterSetup,
+ firstBattle: { before: firstBefore, game: firstGame, planActions: firstPlans,
+ revealed: firstRevealed, settled: firstSettled, steps: firstSteps },
+ shipments, game, owner, opponent, observer, trainer, shield: shield.id, territory, key,
+ planActions, steps, staging };
+}
+
+/** Submit the actual own KH=true action before the other still-unsealed plan. */
+export function revealSmugglerKwisatzJourney(
+ fixture: SmugglerKwisatzJourneyFixture, steps: SmugglerKwisatzJourneyStep[] = [],
+): Game {
+ let game = structuredClone(fixture.game);
+ for (const step of fixture.planActions) {
+ steps.push(step); game = applyAction(game, step.actor, step.action);
+ game = advanceSmugglerKwisatzJourney(game, plansReady, steps);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+
+/** Payout-stage API: resolve original no-call actions, stopping before the winner's
+ * optional Shield cleanup. This is still Battle, not ordinary Spice Collection. */
+export function settleSmugglerKwisatzJourney(
+ state: Game, steps: SmugglerKwisatzJourneyStep[] = [],
+): Game {
+ return advanceSmugglerKwisatzJourney(state, game => game.decision?.kind === 'battleCards', steps);
+}
+
+/** Finish the real Shield choice and Ix phase-opening passes, then stop after Collection. */
+export function finishSmugglerKwisatzJourney(
+ state: Game, steps: SmugglerKwisatzJourneyStep[] = [],
+): Game {
+ assert.equal(state.decision?.kind, 'battleCards');
+ const step: SmugglerKwisatzJourneyStep = { actor: state.decision!.player, action: { type: 'decision', discard: [] } };
+ steps.push(step);
+ const game = applyAction(state, step.actor, step.action);
+ return advanceSmugglerKwisatzJourney(game, next => next.phase === 7 && quiet(next), steps);
+}
diff --git a/tests/smuggler-artillery-journey.test.ts b/tests/smuggler-artillery-journey.test.ts
new file mode 100644
index 00000000..3c933d0b
--- /dev/null
+++ b/tests/smuggler-artillery-journey.test.ts
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ createSmugglerArtilleryJourney, finishSmugglerArtilleryJourney, revealSmugglerArtilleryJourney,
+ settleSmugglerArtilleryJourney, smugglerArtilleryPlayer as player,
+} from './fixture-smuggler-artillery-journey';
+
+for (const scenario of [
+ { weapon: 'artillery', ownTraitor: false, stunned: true, transfer: 0 },
+ { weapon: 'artillery', ownTraitor: true, stunned: false, transfer: 6 },
+ { weapon: 'stunner', ownTraitor: false, stunned: false, transfer: 6 },
+] as const) {
+ void test(`genuine native Ix Smuggler: shielded ${scenario.weapon}, own Traitor ${scenario.ownTraitor}`, () => {
+ const f = createSmugglerArtilleryJourney(scenario);
+ for (const shipment of f.shipments) {
+ assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice,
+ shipment.actor === f.owner ? 4 : 2);
+ assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 2);
+ assert.equal(player(shipment.after, shipment.actor).forces['south_mesa:5'], 2);
+ }
+ for (const movement of f.movements) {
+ assert.equal(player(movement.after, movement.actor).forces['south_mesa:5'] ?? 0, 0);
+ assert.equal(player(movement.after, movement.actor).forces[f.key], 2);
+ assert.equal(player(movement.after, movement.actor).spice, player(movement.before, movement.actor).spice);
+ }
+ const piles = { ...f.game.spice };
+ const wallets = Object.fromEntries(f.game.players.map(p => [p.id, p.spice]));
+ const revealed = revealSmugglerArtilleryJourney(f);
+ assert.equal(revealed.battle!.smugglerCollection!.strength, f.cap);
+ assert.equal(revealed.battle!.smugglerCollection!.amount, f.cap);
+ assert.equal(revealed.battle!.smugglerCollection!.stage, 'pending');
+ assert.deepEqual(revealed.spice, piles);
+ assert.equal(player(revealed, f.owner).spice, wallets[f.owner], 'Reveal-time collection is not yet spendable');
+
+ const { game: settled, resolution } = settleSmugglerArtilleryJourney(revealed, f);
+ const receipt = settled.lastBattleContext!.smugglerCollection!;
+ assert.equal(settled.decision!.kind, 'battleCards', 'Real winner cleanup exposes payout before ordinary Collection');
+ assert.equal(settled.phase, 6);
+ assert.equal(settled.lastBattleContext!.winner, f.owner);
+ assert.equal(settled.lastBattleContext!.result, scenario.ownTraitor ? 'traitor' : 'normal');
+ assert.equal(resolution.effects?.stunned ?? false, scenario.stunned);
+ assert.equal(player(settled, f.owner).leaders.find(l => l.id === f.leader)!.dead, false);
+ assert.equal(player(settled, f.opponent).leaders.find(l => l.id === f.opposingLeader)!.dead, scenario.ownTraitor);
+ assert.equal(receipt.stage, scenario.stunned ? 'void' : 'collected');
+ assert.equal(receipt.strength, f.cap, 'Actual zeroing does not rewrite the reveal-time cap');
+ assert.equal(receipt.allocations, undefined, 'No pile-selection action was needed');
+ assert.deepEqual(settled.spice, { ...piles, [f.key]: f.printedAmount - scenario.transfer });
+ const opposingStrength = player(f.game, f.opponent).leaders.find(l => l.id === f.opposingLeader)!.strength;
+ assert.equal(player(settled, f.owner).spice,
+ wallets[f.owner] + scenario.transfer + (scenario.ownTraitor ? opposingStrength : -1),
+ 'Only actual Smuggler transfer, paid support, and a real Traitor bounty affect this wallet');
+ assert.equal(player(settled, f.opponent).spice, wallets[f.opponent]);
+ assert.equal(player(settled, f.owner).forces[f.key], scenario.ownTraitor ? 2 : 1);
+ assert.equal(player(settled, f.opponent).forces[f.key] ?? 0, 0);
+ if (scenario.ownTraitor) {
+ assert.equal(resolution.effects, null, 'The genuine sole own Traitor bypasses Artillery entirely');
+ assert.equal(resolution.scores, null);
+ assert.ok(player(settled, f.owner).revealedTraitors?.includes(f.opposingLeader));
+ } else {
+ const ownSide = revealed.battle!.attacker === f.owner ? 'attacker' : 'defender';
+ const otherSide = ownSide === 'attacker' ? 'defender' : 'attacker';
+ assert.equal(resolution.scores![ownSide], scenario.stunned ? 1 : f.cap + 1);
+ if (scenario.stunned) assert.equal(resolution.scores![otherSide], 0, 'Both living leaders really contribute zero');
+ }
+ const continued = finishSmugglerArtilleryJourney(settled);
+ assert.equal(continued.turn, 1);
+ assert.equal(continued.phase, 7, 'Keep the original optional cards and continue to native Collection');
+ assert.equal(continued.battle, null);
+ });
+}
diff --git a/tests/smuggler-battle.test.ts b/tests/smuggler-battle.test.ts
index 977342bf..13e1d296 100644
--- a/tests/smuggler-battle.test.ts
+++ b/tests/smuggler-battle.test.ts
@@ -178,13 +178,34 @@ void test('changed reveal receipts, spice, plans or skill identity are rejected
assert.equal(viewGame(smugglerBattle(), 'd').battle!.smugglerCollection, null);
assert.equal('frame' in viewGame(pending, 'd').battle!.smugglerCollection!, false);
});
-void test('own modified plans 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 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('Kwisatz increases the reveal-bound Smuggler cap without crediting pending spice', () => {
+ for (const amount of [8, 3]) {
+ const game = smugglerBattle({ advanced: true, atreides: true, amount });
+ game.players[0].battleLosses = 7;
+ const strength = game.players[0].leaders[0].strength + 2;
+ const expected = Math.min(strength, amount);
+ const pending = revealSmuggler(game, { dial: 0, enemyDial: 5, kwisatz: true });
+ assert.equal(pending.battle!.smugglerCollection!.strength, strength);
+ assert.equal(pending.battle!.smugglerCollection!.amount, expected);
+ assert.equal(pending.players[0].spice, 20);
+ assert.equal(pending.spice['wind_pass:14'], amount);
+ const done = finishSmuggler(pending);
+ assert.equal(done.players[0].spice, 20 + expected);
+ assert.equal(done.spice['wind_pass:14'], amount - expected);
+ assert.equal(done.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ }
+});
+void test('a captured Smuggler collects its normal trainer bonus for its controller, not its original owner', () => {
+ const game = smugglerBattle({ captured: true, hide: false });
+ setOtherSkill(game, 'warmaster', 'a');
+ const strength = game.players[1].leaders[0].strength + 1;
+ const pending = revealSmuggler(game, { dial: 0, enemyDial: 5, weapon: game.players[0].hand[0].id });
+ assert.equal(pending.battle!.smugglerCollection!.strength, strength);
+ assert.equal(pending.players[0].spice, 20);
+ const done = finishSmuggler(pending);
+ assert.equal(done.players[0].spice, 20 + strength);
+ assert.equal(done.players[1].spice, 20);
+ assert.equal(done.spice['wind_pass:14'], 8 - strength);
});
void test('the independent Ecaz card variant is gated before a Smuggler plan is sealed', () => {
// The Smuggler battle lives in the Leader Skills profile, which never admits
@@ -200,7 +221,7 @@ void test('the independent Ecaz card variant is gated before a Smuggler plan is
assert.throws(() => viewGame(staged, 'a'));
reject(staged, { type: 'battlePlan' as const, leader: 'emperor-0', dial: 0 });
});
-void test('all four actual bot profiles retain a legal unmodified path and finish pending collection', () => {
+void test('all four actual bot profiles finish collection with the available modified leader plans', () => {
for (const difficulty of DIFFICULTIES) {
let game = smugglerBattle({ advanced: true, atreides: true });
game.players[0].battleLosses = 7;
@@ -213,7 +234,6 @@ void test('all four actual bot profiles retain a legal unmodified path and finis
});
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));
}
diff --git a/tests/smuggler-kwisatz-journey.test.ts b/tests/smuggler-kwisatz-journey.test.ts
new file mode 100644
index 00000000..3ee049f5
--- /dev/null
+++ b/tests/smuggler-kwisatz-journey.test.ts
@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine';
+import { advancedNativePlayer, assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
+import {
+ createSmugglerKwisatzJourney, finishSmugglerKwisatzJourney,
+ revealSmugglerKwisatzJourney, settleSmugglerKwisatzJourney,
+} from './fixture-smuggler-kwisatz-journey';
+
+void test('native Atreides earns KH with seven paid Arrakeen casualties, then its original Smuggler collects a short real Spice Blow', () => {
+ const fixture = createSmugglerKwisatzJourney();
+ const { game, owner, opponent, trainer, key, firstBattle } = fixture;
+ assert.equal(advancedNativePlayer(fixture.afterSetup, 'atreides').forces['arrakeen:10'], 10);
+ assert.equal(viewGame(firstBattle.game, owner).players.find(p => p.id === owner)!.kwisatz!.active, false);
+ assert.notEqual(firstBattle.revealed.battle!.plans[owner].leader, trainer);
+ assert.equal(firstBattle.revealed.battle!.plans[owner].dial, 7);
+ assert.equal(firstBattle.revealed.battle!.plans[owner].support, 7);
+ assert.equal(firstBattle.settled.lastBattleContext!.winner, owner);
+ const veteran = advancedNativePlayer(firstBattle.settled, 'atreides');
+ assert.equal(veteran.battleLosses, 7);
+ assert.equal(veteran.tanks, 7);
+ assert.equal(veteran.forces['arrakeen:10'], 3);
+ assert.equal(veteran.spice, advancedNativePlayer(firstBattle.revealed, 'atreides').spice - 7,
+ 'The actual supported battle pays seven before the pending first Collection opens.');
+ assert.equal(viewGame(game, owner).players.find(p => p.id === owner)!.kwisatz!.active, true);
+ for (const shipment of fixture.shipments) {
+ const payerBefore = shipment.before.players.find(p => p.id === shipment.step.actor)!;
+ const payerAfter = shipment.after.players.find(p => p.id === shipment.step.actor)!;
+ assert.equal(payerBefore.spice - payerAfter.spice, shipment.step.actor === owner ? 2 : 1);
+ assert.equal(payerBefore.reserves - payerAfter.reserves, 1);
+ }
+ assert.equal(game.turn, 2);
+ assert.equal(game.phase, 6);
+ assert.deepEqual(game.battle!.plans, {});
+ assert.equal(game.spice[key], 6);
+ const wallet = advancedNativePlayer(game, 'atreides').spice;
+ const ownSealed = applyAction(game, owner, fixture.planActions[0].action);
+ assert.equal(ownSealed.battle!.plans[owner].kwisatz, true);
+ assert.equal(ownSealed.battle!.plans[owner].defense, fixture.shield);
+ assert.deepEqual(viewGame(ownSealed, opponent).battle!.plans, {});
+ assert.equal(advancedNativePlayer(ownSealed, 'atreides').spice, wallet);
+ const pending = revealSmugglerKwisatzJourney(fixture);
+ const printed = advancedNativePlayer(game, 'atreides').leaders.find(l => l.id === trainer)!.strength;
+ assert.equal(pending.battle!.smugglerCollection!.strength, printed + 2);
+ assert.equal(pending.battle!.smugglerCollection!.amount, Math.min(printed + 2, 6));
+ assert.equal(pending.battle!.smugglerCollection!.stage, 'pending');
+ assert.equal(advancedNativePlayer(pending, 'atreides').spice, wallet);
+ assert.equal(pending.spice[key], 6);
+ const paid = settleSmugglerKwisatzJourney(pending);
+ assert.equal(paid.phase, 6);
+ assert.equal(paid.decision?.player, owner);
+ assert.equal(paid.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ assert.equal(advancedNativePlayer(paid, 'atreides').spice, wallet + 6);
+ assert.equal(paid.spice[key], 0);
+ assert.equal(advancedNativePlayer(paid, 'atreides').leaders.find(l => l.id === trainer)!.dead, false);
+ assert.equal(advancedNativePlayer(paid, 'atreides').kwisatz!.usedAt, fixture.territory);
+ assertAdvancedNativeCustody(paid);
+});
+
+void test('earned KH adds two beyond printed Smuggler strength, pays once before cleanup, and leaves ordinary Collection separate', () => {
+ const fixture = createSmugglerKwisatzJourney({ pile: 'ample' });
+ const wallet = advancedNativePlayer(fixture.game, 'atreides').spice;
+ const pending = revealSmugglerKwisatzJourney(fixture);
+ const printed = advancedNativePlayer(pending, 'atreides').leaders.find(l => l.id === fixture.trainer)!.strength;
+ const amount = printed + 2;
+ assert.ok(amount < pending.spice[fixture.key]);
+ assert.equal(pending.battle!.smugglerCollection!.amount, amount);
+ assert.equal(advancedNativePlayer(pending, 'atreides').spice, wallet);
+ const paid = settleSmugglerKwisatzJourney(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))));
+ assert.equal(paid.phase, 6);
+ assert.equal(paid.decision?.kind, 'battleCards');
+ assert.equal(paid.lastBattleContext!.winner, fixture.owner);
+ assert.equal(paid.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ assert.equal(advancedNativePlayer(paid, 'atreides').spice, wallet + amount);
+ assert.equal(paid.spice[fixture.key], 8 - amount);
+ assert.equal(advancedNativePlayer(paid, 'atreides').tanks, 7);
+ const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(paid)));
+ assert.equal(advancedNativePlayer(restored, 'atreides').spice, wallet + amount);
+ assert.equal(restored.spice[fixture.key], 8 - amount);
+ const beforeDuplicate = structuredClone(restored);
+ assert.throws(() => applyAction(restored, fixture.opponent, { type: 'traitorCall', call: false }));
+ assert.deepEqual(restored, beforeDuplicate);
+ const collected = finishSmugglerKwisatzJourney(restored);
+ assert.equal(collected.phase, 7);
+ assert.equal(collected.spice[fixture.key], 0);
+ assert.equal(advancedNativePlayer(collected, 'atreides').spice, wallet + amount + (8 - amount) + 2,
+ 'Ordinary Collection separately gathers the remainder and native Arrakeen income; it does not pay the skill again.');
+ assert.equal(collected.lastBattleContext!.smugglerCollection!.amount, amount);
+ assert.equal(collected.lastBattleContext!.smugglerCollection!.stage, 'collected');
+ assertAdvancedNativeCustody(collected);
+});