From a1517ec949b2d604fe3e16e982b0ce9aed5b6f3c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:14:14 +1100 Subject: [PATCH] Connect owned multi-pile battle skill choices --- components/game-table.tsx | 14 +- components/leader-skill-battle-guide.tsx | 4 +- components/sandmaster-victory-spice.tsx | 42 +++++ components/smuggler-battle-collection.tsx | 76 +++++++++ docs/CURRENT_STATUS.md | 14 ++ docs/IMPLEMENTATION_STATUS.md | 30 ++++ docs/LEADER_BATTLE_EFFECTS.md | 30 +++- docs/RULE_DECISIONS.md | 1 + docs/SMUGGLER_BATTLE.md | 83 ++++++++-- game/admin-seat-ai-shape.ts | 2 + game/bots.ts | 7 +- game/engine.ts | 92 +++++++++-- game/leader-skill-battle-board.ts | 49 ++++-- game/reference.ts | 8 +- game/richese-cancellation.ts | 2 + game/smuggler-battle.ts | 90 ++++++++++- tests/leader-battle-effects-controls.test.tsx | 1 + tests/leader-battle-effects.test.ts | 20 ++- tests/leader-skill-battle-board.test.ts | 62 ++++++++ tests/smuggler-battle.test.ts | 149 +++++++++++++++++- 20 files changed, 705 insertions(+), 71 deletions(-) create mode 100644 components/sandmaster-victory-spice.tsx create mode 100644 components/smuggler-battle-collection.tsx create mode 100644 tests/leader-skill-battle-board.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index bb99a9c5..6b13af26 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -78,6 +78,8 @@ 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 { SmugglerBattleCollection } from './smuggler-battle-collection'; +import { SandmasterVictorySpice } from './sandmaster-victory-spice'; import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { MentatQuestion, MentatHistory } from './mentat-question'; import { BureaucratPayment } from './bureaucrat-payment'; @@ -2537,6 +2539,10 @@ export function GameTable({ ? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange' : g.decision.kind === 'sukRescue' ? 'Save your battle casualties' + : g.decision.kind === 'smugglerCollection' + ? 'Choose Smuggler collection sectors' + : g.decision.kind === 'sandmasterVictorySpice' + ? 'Choose Sandmaster victory spice sector' : g.decision.kind === 'harassWithdraw' ? 'Choose undialed forces to return' : g.decision.kind === 'diplomatRetreat' @@ -2989,6 +2995,10 @@ export function GameTable({ mode: 'ignore', })} + ) : g.decision.kind === 'smugglerCollection' ? ( + + ) : g.decision.kind === 'sandmasterVictorySpice' ? ( + ) : g.decision.kind === 'harassWithdraw' ? ( <> @@ -4933,8 +4943,8 @@ export function GameTable({ g.battle.revealed ? ( <> {g.battle.smugglerCollection &&

- {g.players.find(p => p.id === g.battle!.smugglerCollection!.player)?.name}’s Smuggler has {g.battle.smugglerCollection.amount} spice pending from the reveal-time pile. - Collection is automatic if the leader survives, even after a defeat. That spice is not yet spendable. + {g.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.

} {!g.battle.traitorSubmitted.includes(me.id) && ( <> diff --git a/components/leader-skill-battle-guide.tsx b/components/leader-skill-battle-guide.tsx index 35cfd4bc..8140977a 100644 --- a/components/leader-skill-battle-guide.tsx +++ b/components/leader-skill-battle-guide.tsx @@ -17,10 +17,10 @@ export function LeaderSkillBattleGuide({ game, leader }: { game: GameView; leade return
{mentat &&

Mentat adds 2 to this leader’s battle strength if the leader survives.

} {bureaucrat &&

Bureaucrat subtracts {leaderSkillStrongholdCount(game, opponent)} from your opponent’s battle total if your leader survives. Count each occupied stronghold once, including contested strongholds. Advisors do not count.

} - {sandmaster &&

If this Sandmaster survives and wins, add 3 spice to the territory’s existing spice pile automatically. No spice is added when the territory has no spice.

} + {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 spice in this territory when both plans reveal, capped by this leader’s unmodified strength. If the leader survives, collect it automatically even if you lose or have no forces left. Pending spice cannot fund this plan. Strength modifiers and combined modules remain under review.' + ? '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.' : '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/sandmaster-victory-spice.tsx b/components/sandmaster-victory-spice.tsx new file mode 100644 index 00000000..6453f134 --- /dev/null +++ b/components/sandmaster-victory-spice.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { splitLocation, territory } from '@/game/board'; +import type { Action } from '@/game/engine'; +import type { SandmasterVictorySpiceOffer } from '@/game/leader-skill-battle-board'; +import { Button } from './ui/button'; + +type Props = { + offer: SandmasterVictorySpiceOffer | null | undefined; + busy: boolean; + act: (action: Action) => void; +}; + +export function SandmasterVictorySpice({ offer, busy, act }: Props) { + if (!offer) return null; + const name = territory(offer.territory).name; + return ( +
+

Sandmaster · victory spice · {name}

+

Place the mandatory +3 board spice on one existing pile. This grants no direct faction income.

+

Provisional allocation policy: the winning owner selects an existing pile. This is a prototype choice, not a publisher sector-placement ruling.

+
+ {offer.piles.map((pile) => { + const sector = splitLocation(pile.key).sector; + return ( +
+

{name}, sector {sector}: {pile.before} → {pile.after} spice (+3).

+ +
+ ); + })} +
+
+ ); +} diff --git a/components/smuggler-battle-collection.tsx b/components/smuggler-battle-collection.tsx new file mode 100644 index 00000000..419ff93e --- /dev/null +++ b/components/smuggler-battle-collection.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useId, useState } from 'react'; +import { splitLocation, territory } from '@/game/board'; +import { defaultSmugglerBattleAllocation, type SmugglerBattleCollectionOffer } from '@/game/smuggler-battle'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +function initialAllocation(offer: SmugglerBattleCollectionOffer): Record { + const allocations = defaultSmugglerBattleAllocation(offer.piles, offer.amount); + return Object.fromEntries(Object.keys(offer.piles).map(key => [key, String(allocations[key] ?? 0)])); +} + +export function SmugglerBattleCollection({ offer, busy, act }: { + offer: SmugglerBattleCollectionOffer; + busy: boolean; + act: (action: { type: 'decision'; event: string; allocations: Record }) => void; +}) { + const id = useId(); + const [selection, setSelection] = useState(() => ({ event: offer.event, values: initialAllocation(offer) })); + const values = selection.event === offer.event ? selection.values : initialAllocation(offer); + const entries = Object.entries(offer.piles).sort(([a], [b]) => a.localeCompare(b)); + const allocations: Record = {}; + let total = 0; + let legal = true; + for (const [key, available] of entries) { + const text = values[key] ?? ''; + const amount = Number(text); + if (!text.trim() || !Number.isSafeInteger(amount) || amount < 0 || amount > available) legal = false; + if (amount > 0) allocations[key] = amount; + total += amount; + } + legal = legal && total === offer.amount; + + return ( +
+

Choose Smuggler collection sectors

+

+ Your Smuggler survived. Collect exactly {offer.amount} spice from the + revealed piles in {territory(offer.territory).name} before battle settlement. +

+

+ Provisional allocation policy: you choose the sectors. + This prototype policy is not a publisher ruling; the total and unmodified + leader-strength cap do not change. +

+ {entries.map(([key, available], index) => ( + + ))} + + {legal ? `${total} of ${offer.amount} spice selected.` : + `Choose whole spice within each pile's limit, totaling exactly ${offer.amount}.`} + + +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b464a722..78b6a26b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Multi-pile battle skills prototype — 9 October.** A surviving Smuggler +controller can allocate its fixed reveal-time collection across existing +sectors; a surviving Sandmaster winner selects one remaining pile for +3. +Single-pile, forced all/none and void collections remain automatic. +Owner selection is explicitly provisional, not a publisher ruling. +Both mode smokes preserve frozen spice until the choices finish, then settle +Smuggler before Sandmaster and resume the original battle. All four legal +bot policies finish the same path. A 390px isolated production-control +surface collects six spice as 2/4, then adds three to sector 15, leaving +Wind Pass piles 2/4 and the Smuggler holder at 26 spice. See +[Smuggler allocation](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026) +and [Sandmaster placement](LEADER_BATTLE_EFFECTS.md#source-contract). + + **Kulon/Ornithopter coexistence prototype — 9 October.** Active Kulon no longer blocks the card's fixed-range mode in either rules mode. The labeled provisional policy retains its three-territory maximum and extends both diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 277c0480..b42b1f05 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 9 October 2026 — owned multi-pile Smuggler and Sandmaster choices + +The multi-pile guards now have working choices in supported Basic/Advanced +skill profiles. Smuggler retains a signed reveal-time aggregate and exact +per-pile capacities. Only a surviving partial collection needs an owned +allocation; death, all/none and a single pile remain automatic. Sandmaster +then offers the winner one existing positive pile for +3, with no new pile +or direct faction income. The sector-selection policies are explicitly +provisional, not publisher adjudications. + +The final-outcome gateway waits for both choices before physical settlement, +then retains existing retreat, casualties, card cleanup and later effects. +Malformed/foreign choices and stale replays reject; JSON preserves the +event and signed allocation. Modified Smuggler strength, unrelated module +guards and public starts remain unchanged. + +Actual-engine smoke runs in both modes: collect six as 2/4 from original +4/5 piles, select sector 15 for +3, leave piles 2/4 and credit the collector +20→26 once. All four profiles finish the path through legal owned actions. +A standalone Sandmaster selects 9→12 in sector 15 without changing sector +14 or personal spice. Seven focused allocation/transition cases pass. +Chromium at 390px used both production components, actual styles and the +real engine in isolated in-memory QA; it produced the same final values. +No existing room or deployed acceptance result is claimed. + +Frozen checks: `check:quick` passes and 52 affected receipt, battle, +control, retreat and cancellation cases pass. No full suite, HTTP/recovery +campaign, whole-database comparison or deployment was run. Temporary QA +scripts and the dedicated in-memory service were removed. + ## 9 October 2026 — two Occupy failures isolated to test contracts and staging Deterministic seed 8 reproduces the recorded missing-immediate-plan failure. diff --git a/docs/LEADER_BATTLE_EFFECTS.md b/docs/LEADER_BATTLE_EFFECTS.md index f6cce75d..e37acbad 100644 --- a/docs/LEADER_BATTLE_EFFECTS.md +++ b/docs/LEADER_BATTLE_EFFECTS.md @@ -23,9 +23,15 @@ walkthrough supply the individual effects; see the [component inventory](LEADER_ though combined-module play remains gated. - [Sandmaster, 15:48](https://www.youtube.com/watch?v=XT_azRVLq_0&t=948s): a surviving skilled winner adds three board spice to an existing pile in the - battle territory automatically. This grants no direct income and does not - require surviving forces. No pile means no addition. Multiple positive piles - are guarded before the final battle vote pending a sector-placement ruling. + battle territory. This grants no direct income and does not require surviving + forces. No pile means no addition; a single positive pile resolves automatically. + With several positive piles, the winning owner must select exactly one existing + pile after survival is established and before physical battle settlement. Each + option shows its current quantity and the exact +3 result; this mandatory effect + has no decline or extra acknowledgement. **Provisional allocation policy:** + owner selection is a prototype choice, not a publisher sector-placement ruling. + It cannot create a new sector pile. Smuggler collection precedes placement, so + exhausted piles are not offered. - [Rihani, 12:49](https://www.youtube.com/watch?v=XT_azRVLq_0&t=769s): a face-up native trainer grants the normal private inspection after a victory using another leader. A surviving selected native skilled winner receives both @@ -57,11 +63,13 @@ alone. These boundaries are implementation limits, not prohibitions in the game. ## Connected behavior and checks Shared battle quotes calculate score changes; bots use the same public -stronghold count. Sandmaster resolves automatically. Rihani offers owned -draw/decline controls, then separate inspectable keep-new and reveal-old -selections. All four AI profiles use only their permitted view. Only the owner -receives inspection history and drawn identities. The public log names the -returned old card, never the kept or unkept new identity. +stronghold count. Sandmaster resolves automatically for zero or one remaining +positive pile; multiple piles use an owned, event-bound placement decision with +one direct button per existing sector. Only supplied public pile quantities appear +in the controls. Rihani offers owned draw/decline controls, then separate inspectable +keep-new and reveal-old selections. All four AI profiles use only their permitted +view. Only the owner receives inspection history and drawn identities. The public +log names the returned old card, never the kept or unkept new identity. Physical snapshots, an event-bound obligation and saved stages preserve Rihani through casualties, winner cleanup and captive return. Independent review found @@ -72,6 +80,12 @@ captured eligibility, actual-use exclusion and native Suk rescue preceding a captured Rihani exchange. Focused checks also cover score-boundary winners, dead/bluffed skills, Sandmaster without surviving forces and UI privacy. +Pure Sandmaster boundary tests cover exact +3 offers, independent sector selection, +empty and singleton behavior, malformed locations and quantities, overflow, +unoffered keys and input immutability. Isolated Chromium at 390px exercised +the production placement control after Smuggler allocation, selected sector +15 and observed its actual +3 change before the original battle continued. + Fresh browser room `HNGSR8GB` used genuine module setup, then a separately staged conserved battle. The human inspected two private cards, drew two separate cards, refreshed the pending exchange and selected a keep and public diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 2fbae713..56ba10fb 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -20,6 +20,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) | | 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) | No publication or faction/mode gate is opened. The exact 7 October user diff --git a/docs/SMUGGLER_BATTLE.md b/docs/SMUGGLER_BATTLE.md index 4b661d98..d4dee75e 100644 --- a/docs/SMUGGLER_BATTLE.md +++ b/docs/SMUGGLER_BATTLE.md @@ -12,9 +12,9 @@ against a player view that intentionally lacks private/internal configuration. The engine now projects `smugglerCollectionSupported` beside the existing saved battle format flag; bots use that authoritative rule result rather than reconstructing the module profile. Modified-strength and multiple-positive-pile -guards still apply to each actual plan. No public mode or strategy tuning is -added. A real trainer-plan regression fails before and passes after repair; -all three original captures finish without rejection. Source trees/counts and +guards applied to each actual plan at that checkpoint; the 9 October allocation +policy below replaces only the latter. No public mode or strategy tuning is added. +All three original captures finish without rejection. Source trees/counts and limits are in the [native Discovery evidence](DISCOVERY_PROTOTYPE.md#native-skill-runtime-evidence). ## Rule and timing contract @@ -25,13 +25,13 @@ selected skilled leader survive. The card does not require winning or retaining forces. A captured skilled leader grants its lower effect to its controller. At public plan reveal the server records the battle, controller, selected leader, -plans, skill posture and territory's existing spice. The amount is the smaller of -that single pile and the unmodified disc strength. This pending amount is not -personal spice and cannot fund a sealed plan or another payment. After survival -is known, the server transfers it once, including for a surviving loser, before -Sandmaster adds any after-victory spice. A pile drained to zero cannot receive -Sandmaster's addition. This deferred settlement and ordering remain explicit -timing inferences, not a newly discovered publisher ruling. +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 +payment. After survival is known, the server transfers it once, including for a +surviving loser, before Sandmaster adds any after-victory spice. A pile drained +to zero cannot receive Sandmaster's addition. This deferred settlement and +ordering remain explicit timing inferences, not a newly discovered publisher ruling. A weapon death, opposing or mutual Traitor, or Lasgun–Shield explosion voids the collection. A successful sole own Traitor call can leave the Smuggler alive and @@ -46,13 +46,17 @@ payment, so it does not invoke Banker or Bureaucrat payment handling. profiles. Public mode flags and the player's own chosen plan determine the guard; an opponent's concealed cards do not control eligibility. - Guidance explains the pending amount, survival and spending restriction. - Collection is automatic, with the existing faction-colored action notice and - an explanatory chronicle entry; there is no confirmation step. + 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. -- Versioned, signed reveal receipts survive private JSON/SQLite continuation. +- 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 + deferred until all rules are implemented. Reads/actions reject changed plans, receipts, pile or identity. Public views expose the selected leader, controller and pending amount only after reveal; the internal binding frame is never projected. Concurrent final votes use the @@ -60,14 +64,59 @@ payment, so it does not invoke Banker or Bureaucrat payment handling. - Battles already saved without the new version keep their original behavior. Subsequent battles use the new flow. There is no database migration or reset. +## 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 +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 +after revealed outcome establishes survival and before physical settlement, +preceding any Sandmaster placement choice. Inputs start with a legal deterministic +fill and require the exact total within each revealed pile's capacity. + +`game/smuggler-battle.ts` preserves legacy receipt fields and signatures: + +- `SmugglerBattleReceipt` retains `key`, `before`, `amount`, `stage`, and its + original battle binding. Multi-pile receipts add + `piles?: Record`, use `key: null`, and set `before` to the + 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. +- `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 + choice, debits those concrete sectors, and returns `{ receipt, amount, spice }`. + Death returns a signed `void` receipt 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, + piles: Record }`. + `SmugglerBattleCollection({ offer, busy, act })` inspects only those public + piles and submits `{ type: 'decision', event, allocations }`; noninteger, + over-limit and incorrect-total selections cannot be submitted. + +Focused pure regressions cover differing valid sector debits, immutable +allocation rejection, omitted zeroes, same-total reveal-pile redistribution, +choice-signature corruption, death/all/none automation and legacy single-pile +settlement. These regressions specify prototype allocation boundaries, not new +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, other optional modules and multiple positive piles remain -guarded before the Smuggler plan seals. These development restrictions are not -rules that forbid those combinations in the physical game. Stone Burner and -other expansion combinations therefore have no connected acceptance claim here. +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. Focused engine tests cover winners/losers in both modes, empty/short piles, zero surviving forces, death and Traitor outcomes, explosion, capture custody, diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index 8b8586b4..63f82bde 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -25,6 +25,8 @@ const nullableCount = (value: unknown) => value === null || count(value); const decisions = { ecazBattleLead: true, harassWithdraw: true, + smugglerCollection: true, + sandmasterVictorySpice: true, diplomatDefense: true, diplomatRetreat: true, leaderSkillVisibility: true, diff --git a/game/bots.ts b/game/bots.ts index 80c08adb..0bf651d8 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4,7 +4,7 @@ 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 { smugglerBattlePlanBlock } from './smuggler-battle'; +import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots'; import { mirrorWeaponModeBlock } from './mirror-weapon-mode'; import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw'; @@ -1611,6 +1611,11 @@ function policyActions(g: GameView): Action[] { // A decision without its private view offer must not become an ordinary action. if (d.kind === 'nexusIxianReplacement') return []; if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return []; + if (d.kind === 'smugglerCollection') + return [{ type: 'decision', event: d.event, + allocations: defaultSmugglerBattleAllocation(d.piles, d.amount) }]; + if (d.kind === 'sandmasterVictorySpice') + return d.piles.length ? [{ type: 'decision', event: d.event, key: d.piles[0].key }] : []; if (d.kind === 'harassWithdraw') { const offer = g.battle?.harassAllocation; if (!offer || offer.event !== d.event) return []; diff --git a/game/engine.ts b/game/engine.ts index 03977f83..87f7b067 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15,12 +15,12 @@ import { } from './leader-skill-combat'; import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate'; import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces'; -import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board'; +import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board'; import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer'; import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement'; import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field'; import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; -import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle'; +import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle'; import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectible, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; @@ -693,6 +693,7 @@ export type Battle = { /** New battles bind automatic collection to the original public plan reveal. */ smugglerCollectionVersion?: 1; smugglerCollection?: SmugglerBattleReceipt | null; + sandmasterPile?: string; /** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */ harassAllocationVersion?: 1; harassAllocationEvent?: string; @@ -790,6 +791,8 @@ export type Auction = { export type Decision = | { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] } | { kind: 'harassWithdraw'; player: string; event: string } + | ({ kind: 'smugglerCollection' } & SmugglerBattleCollectionOffer) + | ({ kind: 'sandmasterVictorySpice' } & SandmasterVictorySpiceOffer) | { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string } | { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] } | { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean } @@ -21232,10 +21235,24 @@ function smugglerBattleIntegrity(g: Game) { const b = g.battle; if (b?.smugglerCollectionVersion !== undefined || b?.smugglerCollection !== undefined) { requireRule(b && b.smugglerCollectionVersion === 1, 'The saved Smuggler collection lost its battle version.'); - if (b.revealed) requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(pendingSmugglerBattle(g)), - 'The saved Smuggler collection changed its original plans, leader or reveal-time spice.'); + if (b.revealed) { + let expected = pendingSmugglerBattle(g); + if (expected && b.smugglerCollection?.allocations) + expected = nexusRule(() => chooseSmugglerBattleAllocation(expected!, b.smugglerCollection!.allocations!)); + requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(expected), + 'The saved Smuggler collection changed its original plans, leader or reveal-time spice.'); + } else requireRule(b.smugglerCollection === undefined, 'Smuggler collection cannot precede public Battle Plans.'); } + if (g.decision?.kind === 'smugglerCollection' || g.decision?.kind === 'sandmasterVictorySpice') { + requireRule(b?.revealed && g.phase === 6 && b.event === g.decision.event && + traitorVoters(g, b).every(id => b.traitorCalls[id] !== undefined), + 'The pile choice needs the completed revealed battle outcome.'); + const quote = currentBattleResolutionQuote(g); + requireRule(JSON.stringify(g.decision) === JSON.stringify( + g.decision.kind === 'smugglerCollection' ? quote.smugglerAllocation : quote.sandmasterSelection), + 'The saved pile choice changed its owner, amount or sectors.'); + } const context = g.lastBattleContext, receipt = context?.smugglerCollection; if (receipt) requireRule(receipt.event === context!.event && receipt.turn === context!.turn && receipt.territory === context!.territory && context!.combatants.includes(receipt.player) && @@ -21319,11 +21336,29 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { ordinaryLeaderSkillModeSupported(g) && (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 smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!, - !(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null; - if (smuggler) requireRule(Number.isSafeInteger(getPlayer(g, smuggler.receipt.player).spice + smuggler.amount), - 'Smuggler collection would overflow the spice balance.'); - if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, smuggler?.spice ?? g.spice)); + const collection = b.smugglerCollection; + const survives = !!collection && + !(collection.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender); + const smugglerAllocation: Extract | null = + collection && smugglerBattleAllocationRequired(collection, survives) + ? { 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) + requireRule(Number.isSafeInteger(getPlayer(g, collection.player).spice + collection.amount), + 'Smuggler collection would overflow the spice balance.'); + const afterCollection = smuggler?.spice ?? g.spice; + const sandmasterPiles = quote.sandmaster && !smugglerAllocation + ? nexusRule(() => sandmasterVictoryPiles(b.territory, afterCollection)) : []; + const sandmasterSelection: Extract | null = + sandmasterPiles.length > 1 && !b.sandmasterPile + ? { kind: 'sandmasterVictorySpice', event: b.event!, player: quote.winner!, + territory: b.territory, piles: sandmasterPiles } + : null; + if (quote.sandmaster && !smugglerAllocation && !sandmasterSelection) + nexusRule(() => sandmasterVictorySpice(b.territory, afterCollection, b.sandmasterPile)); if (quote.rihani) { const winner = getPlayer(g, quote.winner!); nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), @@ -21346,7 +21381,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite); if (quote.homeworldExplosion) for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses); - return { ...quote, smuggler }; + return { ...quote, smuggler, smugglerAllocation, sandmasterSelection }; } catch (error) { if ( error instanceof BattleResolutionQuoteError || @@ -21428,13 +21463,18 @@ function diplomatRetreatIntegrity(g: Game) { } function advanceBattleOutcome(g: Game) { const b = g.battle!; + const quote = currentBattleResolutionQuote(g); + if (quote.smugglerAllocation || quote.sandmasterSelection) { + g.decision = quote.smugglerAllocation ?? quote.sandmasterSelection; + return; + } if (b.diplomatRetreatVersion !== 1 || ![b.attacker, b.defender].some(id => battleLeaderSkills(g, getPlayer(g, id)).some(skill => skill.skill === 'diplomat' && b.plans[id].leader === skill.leader && (!skill.faceUp || skill.captured)))) { resolveBattle(g); return; } - const offer = diplomatRetreatOffer(g, currentBattleResolutionQuote(g)); + const offer = diplomatRetreatOffer(g, quote); if (!offer) { resolveBattle(g); return; } requireRule(b.event && !b.diplomatRetreat, 'The Diplomat retreat needs one current battle.'); const event = crypto.randomUUID(); @@ -21449,6 +21489,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { // Quote before any withdrawal: fixedLosses retains the original ceil(E/2) // physical commitment, never one recalculated from Ecaz's diminished board. const quote = currentBattleResolutionQuote(g); + requireRule(!quote.smugglerAllocation && !quote.sandmasterSelection, + 'Finish the owned battle spice-pile choice before resolving losses.'); const a = getPlayer(g, b.attacker), d = getPlayer(g, b.defender), ap = b.plans[a.id], @@ -21826,16 +21868,20 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { if (quote.smuggler) { const { receipt, amount } = quote.smuggler; const collector = getPlayer(g, receipt.player); - if (amount && receipt.key) g.spice[receipt.key] = receipt.before - amount; + if (amount) { + if (receipt.piles) + for (const key of Object.keys(receipt.piles)) g.spice[key] = quote.smuggler!.spice[key]; + else if (receipt.key) g.spice[receipt.key] = receipt.before - amount; + } collector.spice += amount; g.lastBattleContext.smugglerCollection = receipt; log(g, receipt.stage === 'void' ? `${collector.name}'s Smuggler did not survive. Its pending collection is void; no spice was taken from ${combatLocationName(g, b.territory)}.` - : `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time pile held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`, + : `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time piles held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`, { faction: collector.faction, name: 'Smuggler collection' }); } if (winner && quote.sandmaster) { - const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice)); + const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice, b.sandmasterPile)); if (placement) { g.spice[placement.key] = placement.after; g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement }; @@ -29234,6 +29280,24 @@ function applyActionInner( if (destination === null) log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' }); resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number }); + } else if (decision.kind === 'smugglerCollection') { + const receipt = g.battle?.smugglerCollection; + requireRule(receipt?.event === decision.event && receipt.player === id && + action.event === decision.event && action.allocations !== undefined && + Object.keys(action).every(key => ['type', 'event', 'allocations'].includes(key)), + 'Choose the exact Smuggler amount from its offered reveal-time piles.'); + g.battle!.smugglerCollection = nexusRule(() => chooseSmugglerBattleAllocation( + receipt!, action.allocations as Record)); + g.decision = null; + advanceBattleOutcome(g); + } else if (decision.kind === 'sandmasterVictorySpice') { + requireRule(g.battle?.event === decision.event && action.event === decision.event && + typeof action.key === 'string' && decision.piles.some(pile => pile.key === action.key) && + Object.keys(action).every(key => ['type', 'event', 'key'].includes(key)), + 'Choose one existing pile for this surviving Sandmaster victory.'); + g.battle!.sandmasterPile = action.key as string; + g.decision = null; + advanceBattleOutcome(g); } else if (decision.kind === 'harassWithdraw') { const receipt = g.battle?.harassAllocation, offer = harassAllocationOffer(g); requireRule(Object.keys(action).every(key => ['type', 'event', 'returns'].includes(key)) && diff --git a/game/leader-skill-battle-board.ts b/game/leader-skill-battle-board.ts index 788dc543..54433f84 100644 --- a/game/leader-skill-battle-board.ts +++ b/game/leader-skill-battle-board.ts @@ -1,4 +1,5 @@ -import { gameTerritories, splitLocation, type MobileBoard } from './board'; +import { gameTerritories, location, splitLocation, validLocation } from './board'; +import type { MobileBoard } from './board'; import { fighterCount } from './advisors'; import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; import type { ForcePresence } from './force-presence'; @@ -14,13 +15,43 @@ export function leaderSkillStrongholdCount( fighterCount(player, t.id) > 0).length; } +export type SandmasterVictorySpiceOffer = { + event: string; + player: string; + territory: string; + piles: Array<{ key: string; before: number; after: number }>; +}; + /** The card adds board spice to an existing pile; it grants no direct faction income. */ -export function sandmasterVictorySpice(territory: string, spice: Readonly>) { - const piles = Object.entries(spice).filter(([key, amount]) => amount > 0 && splitLocation(key).territory === territory); - if (piles.length > 1) throw new Error('Sandmaster placement among multiple spice piles awaits its sector ruling.'); - if (!piles.length) return null; - const [key, before] = piles[0]; - if (!Number.isSafeInteger(before) || before < 1 || !Number.isSafeInteger(before + 3)) - throw new Error('Sandmaster requires a valid existing spice pile.'); - return { key, before, after: before + 3 }; +export function sandmasterVictoryPiles( + territory: string, + spice: Readonly>, +): Array<{ key: string; before: number; after: number }> { + const piles: Array<{ key: string; before: number; after: number }> = []; + for (const [key, before] of Object.entries(spice)) { + const at = splitLocation(key); + if (at.territory !== territory) continue; + if (!validLocation(at.territory, at.sector) || key !== location(at.territory, at.sector) || + !Number.isSafeInteger(before) || before < 0 || + (before > 0 && !Number.isSafeInteger(before + 3))) + throw new Error('Sandmaster requires a valid existing spice pile.'); + if (before > 0) piles.push({ key, before, after: before + 3 }); + } + return piles; +} + +/** Owner selection among existing piles is provisional, not a publisher ruling. */ +export function sandmasterVictorySpice( + territory: string, + spice: Readonly>, + key?: string, +) { + const piles = sandmasterVictoryPiles(territory, spice); + if (key !== undefined) { + const selected = piles.find((pile) => pile.key === key); + if (!selected) throw new Error('Sandmaster must select an offered existing spice pile.'); + return selected; + } + if (piles.length > 1) throw new Error('Sandmaster requires selection of one existing spice pile.'); + return piles[0] ?? null; } diff --git a/game/reference.ts b/game/reference.ts index a9bbdd2d..69cbca62 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4731,14 +4731,14 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.', 'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.', 'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. When a native Ixian Hidden Mobile Stronghold relocates, its interior passengers count as entering the territories it points into, so the Sandmaster collects there before the faction’s own traversed-sector collection. Multiple piles and other special relocation remain unfinished.', - 'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.', + 'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.', 'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.', 'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.', 'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.', '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.', 'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.', - 'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.', + '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.', '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(' ')}`), @@ -4746,8 +4746,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 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 normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route, native Fremen worm-ride and physical Fremen Ambassador destination collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat canonical printed-defense copying are connected; skilled Diplomat retreat transfers undialed normal/elite forces before losing casualties. Normal Banker income connects only in its separate fresh profiles; remaining bands and combined modules stay open. Bureaucrat redirects supported third-party auction, shipment and bribe payments with saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/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 automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and optional worm/physical Ambassador destination checkboxes. 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/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.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, Sandmaster route, worm and physical Ambassador destination collection, 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:'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:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/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/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/richese-cancellation.ts b/game/richese-cancellation.ts index 82e5748a..b9ec2a3b 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -19,6 +19,8 @@ const DECISIONS = { diplomatDefense: true, diplomatRetreat: true, harassWithdraw: true, + smugglerCollection: true, + sandmasterVictorySpice: true, choamAudit: true, nexusChoamInspection: true, choamAuditPayment: true, diff --git a/game/smuggler-battle.ts b/game/smuggler-battle.ts index dfe4635a..acb9d4b6 100644 --- a/game/smuggler-battle.ts +++ b/game/smuggler-battle.ts @@ -7,8 +7,25 @@ import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSk export type SmugglerBattleReceipt = { event: string; turn: number; territory: string; player: string; leader: string; frame: string; strength: number; key: string | null; before: number; amount: number; + piles?: Record; allocations?: Record; stage: 'pending' | 'collected' | 'void'; signature: string; }; +export type SmugglerBattleCollectionOffer = { + event: string; player: string; territory: string; amount: number; piles: Record; +}; + +/** Minimal legal participation; strategy must not invent hidden pile information. */ +export function defaultSmugglerBattleAllocation(piles: Readonly>, amount: number) { + const allocations: Record = {}; + let remaining = amount; + for (const key of Object.keys(piles).sort()) { + const collected = Math.min(remaining, piles[key]); + if (collected > 0) allocations[key] = collected; + remaining -= collected; + if (!remaining) break; + } + return allocations; +} export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) { return ordinaryLeaderSkillModeSupported(g) && (nativeExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))); @@ -35,16 +52,20 @@ export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boo export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string { return JSON.stringify({ ...receipt, signature: undefined }); } -export function smugglerBattlePile(territory: string, spice: Readonly>) { +export function smugglerBattlePile(territory: string, spice: Readonly>): { + key: string | null; before: number; piles?: Record; +} { const piles = Object.entries(spice).filter(([key]) => splitLocation(key).territory === territory); for (const [key, amount] of piles) { const at = splitLocation(key); if (!validGameLocation({}, at.territory, at.sector) || key !== `${at.territory}:${at.sector}` || !Number.isSafeInteger(amount) || amount < 0) throw new Error('Smuggler needs a valid spice pile.'); } - const positive = piles.filter(([, amount]) => amount > 0); - if (positive.length > 1) throw new Error('Smuggler collection among multiple spice piles awaits its allocation ruling.'); - return positive.length ? { key: positive[0][0], before: positive[0][1] } : { key: null, before: 0 }; + const positive = piles.filter(([, amount]) => amount > 0).sort(([a], [b]) => a.localeCompare(b)); + const before = positive.reduce((total, [, amount]) => total + amount, 0); + if (!Number.isSafeInteger(before)) throw new Error('Smuggler needs a safe aggregate spice amount.'); + return positive.length > 1 ? { key: null, before, piles: Object.fromEntries(positive) } : + positive.length ? { key: positive[0][0], before } : { key: null, before: 0 }; } export function createSmugglerBattle(input: { event: string; turn: number; territory: string; player: string; frame: string; @@ -63,14 +84,67 @@ export function createSmugglerBattle(input: { receipt.signature = smugglerBattleSignature(receipt); return receipt; } +function recordedPiles(receipt: SmugglerBattleReceipt): Record { + return receipt.piles ?? (receipt.key ? { [receipt.key]: receipt.before } : {}); +} +function validateSmugglerBattleReceipt(receipt: SmugglerBattleReceipt) { + if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) || + !Number.isSafeInteger(receipt.strength) || receipt.strength < 0 || + !Number.isSafeInteger(receipt.before) || receipt.before < 0 || + receipt.amount !== Math.min(receipt.strength, receipt.before)) + throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.'); + const snapshot = smugglerBattlePile(receipt.territory, recordedPiles(receipt)); + if (snapshot.key !== receipt.key || snapshot.before !== receipt.before || + (!!snapshot.piles !== !!receipt.piles) || + (receipt.piles && Object.keys(receipt.piles).length !== Object.keys(snapshot.piles!).length)) + throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.'); + if (receipt.allocations !== undefined) normalizeSmugglerBattleAllocation(receipt, receipt.allocations); +} +function normalizeSmugglerBattleAllocation(receipt: SmugglerBattleReceipt, + allocations: Record): Record { + if (!allocations || typeof allocations !== 'object' || Array.isArray(allocations)) + throw new Error('Choose a Smuggler allocation among the revealed spice piles.'); + const piles = recordedPiles(receipt); + let total = 0; + for (const [key, amount] of Object.entries(allocations)) { + if (!Object.prototype.hasOwnProperty.call(piles, key) || piles[key] <= 0 || + !Number.isSafeInteger(amount) || amount < 0 || amount > piles[key]) + throw new Error('Smuggler allocation must use whole spice within each revealed positive pile.'); + total += amount; + } + if (total !== receipt.amount) throw new Error('Smuggler allocation must collect the exact pending amount.'); + 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 && + Object.keys(recordedPiles(receipt)).length > 1 && receipt.amount > 0 && receipt.amount < receipt.before; +} +export function chooseSmugglerBattleAllocation(receipt: SmugglerBattleReceipt, + allocations: Record): SmugglerBattleReceipt { + validateSmugglerBattleReceipt(receipt); + const chosen: SmugglerBattleReceipt = { ...receipt, + ...(receipt.piles ? { piles: { ...receipt.piles } } : {}), + allocations: normalizeSmugglerBattleAllocation(receipt, allocations), signature: '' }; + 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>) { - if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) || - receipt.amount !== Math.min(receipt.strength, receipt.before) || - JSON.stringify(smugglerBattlePile(receipt.territory, spice)) !== JSON.stringify({ key: receipt.key, before: receipt.before })) + 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)) + 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 ?? + (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: '' }; settled.signature = smugglerBattleSignature(settled); - return { receipt: settled, amount, spice: receipt.key ? { ...spice, [receipt.key]: receipt.before - amount } : { ...spice } }; + return { receipt: settled, amount, spice: remaining }; } diff --git a/tests/leader-battle-effects-controls.test.tsx b/tests/leader-battle-effects-controls.test.tsx index e193577b..68a9d01d 100644 --- a/tests/leader-battle-effects-controls.test.tsx +++ b/tests/leader-battle-effects-controls.test.tsx @@ -133,3 +133,4 @@ void test('Rihani return controls separate new-card keeps from unused old-card r assert.ok(completed.includes(`returned ${identityName(owner, given)}`)); assert.equal(historyHtml(viewGame(done, 'd')), ''); }); + diff --git a/tests/leader-battle-effects.test.ts b/tests/leader-battle-effects.test.ts index 218b065f..84ced9a1 100644 --- a/tests/leader-battle-effects.test.ts +++ b/tests/leader-battle-effects.test.ts @@ -63,9 +63,23 @@ void test('Sandmaster adds board spice once after a surviving victory even when } const empty = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'sandmaster' })); assert.equal(empty.lastBattleContext?.sandmaster, undefined); - const ambiguous = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14 }); - ambiguous.spice = { 'wind_pass:14': 2, 'wind_pass:15': 3 }; - reject(ambiguous, 'd', { type: 'traitorCall', call: false }); +}); + +void test('Sandmaster owner selects one remaining existing pile and resumes without crediting income', () => { + const start = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14 }); + start.spice = { 'wind_pass:14': 2, 'wind_pass:15': 9 }; + const pending = resolveLeaderSkillBattle(start); + assert.equal(pending.decision?.kind, 'sandmasterVictorySpice'); + const event = pending.decision!.event; + reject(pending, 'd', { type: 'decision', event, key: 'wind_pass:15' }); + reject(pending, 'a', { type: 'decision', event, key: 'red_chasm:7' }); + const done = applyAction(JSON.parse(JSON.stringify(pending)), 'a', + { type: 'decision', event, key: 'wind_pass:15' }); + assert.deepEqual(done.spice, { 'wind_pass:14': 2, 'wind_pass:15': 12 }); + assert.equal(done.players[0].spice, 20); + assert.equal(done.lastBattleContext!.sandmaster!.key, 'wind_pass:15'); + assert.equal(done.battle, null); + reject(done, 'a', { type: 'decision', event, key: 'wind_pass:15' }); }); void test('native Rihani has a compulsory private peek then commits its separate optional draw before seeing cards', () => { diff --git a/tests/leader-skill-battle-board.test.ts b/tests/leader-skill-battle-board.test.ts new file mode 100644 index 00000000..19d750c1 --- /dev/null +++ b/tests/leader-skill-battle-board.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { sandmasterVictoryPiles, sandmasterVictorySpice } from '../game/leader-skill-battle-board'; + +void test('Sandmaster offers exactly +3 on each existing positive battle-territory pile without mutation', () => { + const spice = Object.freeze({ 'wind_pass:14': 2, 'wind_pass:15': 9, 'wind_pass:16': 0, 'red_chasm:7': 4 }); + const offers = sandmasterVictoryPiles('wind_pass', spice); + assert.deepEqual(offers, [ + { key: 'wind_pass:14', before: 2, after: 5 }, + { key: 'wind_pass:15', before: 9, after: 12 }, + ]); + assert.throws(() => sandmasterVictorySpice('wind_pass', spice), /requires selection/); + for (const offered of offers) { + const selected = sandmasterVictorySpice('wind_pass', spice, offered.key); + assert.ok(selected); + assert.deepEqual(selected, offered); + } + assert.deepEqual(spice, { 'wind_pass:14': 2, 'wind_pass:15': 9, 'wind_pass:16': 0, 'red_chasm:7': 4 }); +}); + +void test('Sandmaster has no addition for empty or exhausted piles and resolves a singleton automatically', () => { + const noPileSpice: Array> = [{}, { 'wind_pass:14': 0 }, { 'red_chasm:7': 8 }]; + for (const spice of noPileSpice) { + assert.deepEqual(sandmasterVictoryPiles('wind_pass', spice), []); + assert.equal(sandmasterVictorySpice('wind_pass', spice), null); + assert.throws(() => sandmasterVictorySpice('wind_pass', spice, 'wind_pass:14'), /offered existing/); + } + const spice = { 'wind_pass:14': 0, 'wind_pass:15': 1, 'red_chasm:7': 8 }; + const expected = { key: 'wind_pass:15', before: 1, after: 4 }; + assert.deepEqual(sandmasterVictorySpice('wind_pass', spice), expected); + assert.deepEqual(sandmasterVictorySpice('wind_pass', spice, expected.key), expected); + assert.throws(() => sandmasterVictorySpice('wind_pass', spice, 'wind_pass:14'), /offered existing/); + assert.throws(() => sandmasterVictorySpice('wind_pass', spice, 'red_chasm:7'), /offered existing/); +}); + +void test('Sandmaster rejects invalid quantities, malformed sectors and unknown territories rather than creating piles', () => { + for (const before of [-1, 0.5, NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1, + Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER]) { + const spice = { 'wind_pass:14': before, 'wind_pass:15': 2 }; + const snapshot = { ...spice }; + assert.throws(() => sandmasterVictoryPiles('wind_pass', spice), /valid existing spice pile/); + assert.throws(() => sandmasterVictorySpice('wind_pass', spice, 'wind_pass:15'), /valid existing spice pile/); + assert.deepEqual(spice, snapshot); + } + for (const key of ['wind_pass', 'wind_pass:', 'wind_pass:1', 'wind_pass:14:extra', 'wind_pass:014', + 'wind_pass:14.0', 'wind_pass:NaN', 'wind_pass:Infinity']) { + for (const amount of [0, 1]) { + assert.throws(() => sandmasterVictoryPiles('wind_pass', { [key]: amount }), /valid existing spice pile/); + } + } + assert.throws(() => sandmasterVictorySpice('not_a_territory', { 'not_a_territory:14': 2 })); +}); + +void test('Sandmaster accepts the largest safe +3 result and rejects every unoffered exact key', () => { + const spice = { 'wind_pass:14': Number.MAX_SAFE_INTEGER - 3, 'wind_pass:15': 2, 'red_chasm:7': 3 }; + assert.deepEqual(sandmasterVictorySpice('wind_pass', spice, 'wind_pass:14'), { + key: 'wind_pass:14', before: Number.MAX_SAFE_INTEGER - 3, after: Number.MAX_SAFE_INTEGER, + }); + for (const key of ['', 'wind_pass:16', 'wind_pass:014', 'wind_pass:14:extra', 'wind_pass:1', 'red_chasm:7']) { + assert.throws(() => sandmasterVictorySpice('wind_pass', spice, key), /offered existing/); + } +}); diff --git a/tests/smuggler-battle.test.ts b/tests/smuggler-battle.test.ts index 9c989643..8873ce82 100644 --- a/tests/smuggler-battle.test.ts +++ b/tests/smuggler-battle.test.ts @@ -5,6 +5,10 @@ import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { smugglerBattle, revealSmuggler, finishSmuggler, setOtherSkill } from './fixture-smuggler-battle'; +import { + chooseSmugglerBattleAllocation, createSmugglerBattle, settleSmugglerBattle, + smugglerBattleAllocationRequired, smugglerBattleSignature, +} from '../game/smuggler-battle'; function reject(game: Game, action: Action, id = 'a') { const before = JSON.stringify(game); @@ -116,6 +120,46 @@ void test('Smuggler collection precedes a surviving Sandmaster winner and may re assert.equal(!!done.lastBattleContext!.sandmaster, amount === 8); } }); +void test('owned multi-pile Smuggler allocation resumes into Sandmaster selection before physical battle settlement', () => { + for (const advanced of [false, true]) { + const start = smugglerBattle({ advanced }); + start.spice = { 'wind_pass:14': 4, 'wind_pass:15': 5 }; + setOtherSkill(start, 'sandmaster'); + start.battle!.leaderSkillHidden!.d = true; + let game = finishSmuggler(revealSmuggler(start, { dial: 0, enemyDial: 5, enemyLeader: 'guild-0' })); + assert.equal(game.decision?.kind, 'smugglerCollection'); + const event = game.decision!.event; + reject(game, { type: 'decision', event, allocations: { 'wind_pass:14': 5, 'wind_pass:15': 1 } }); + reject(game, { type: 'decision', event, allocations: { 'wind_pass:14': 2, 'wind_pass:15': 4 } }, 'd'); + game = applyAction(normalizeAutomaticGame(JSON.parse(JSON.stringify(game))), 'a', + { type: 'decision', event, allocations: { 'wind_pass:14': 2, 'wind_pass:15': 4 } }); + assert.equal(game.decision?.kind, 'sandmasterVictorySpice'); + assert.equal(game.players[0].spice, 20); + assert.deepEqual(game.spice, { 'wind_pass:14': 4, 'wind_pass:15': 5 }); + reject(game, { type: 'decision', event, key: 'wind_pass:16' }, 'd'); + game = applyAction(JSON.parse(JSON.stringify(game)), 'd', + { type: 'decision', event, key: 'wind_pass:15' }); + assert.equal(game.players[0].spice, 26); + assert.deepEqual(game.spice, { 'wind_pass:14': 2, 'wind_pass:15': 4 }); + assert.equal(game.battle, null); + assert.equal(game.lastBattleContext!.sandmaster!.key, 'wind_pass:15'); + reject(game, { type: 'decision', event, allocations: { 'wind_pass:14': 2, 'wind_pass:15': 4 } }); + } +}); + +void test('multi-pile explosion voids collection without offering sectors or restoring destroyed spice', () => { + const game = smugglerBattle(); + game.spice['wind_pass:15'] = 5; + const defense = take(game, 'a', 'shield'); + const enemyWeapon = take(game, 'd', 'lasgun'); + const done = finishSmuggler(revealSmuggler(game, { defense, enemyWeapon })); + assert.equal(done.lastBattleContext!.result, 'explosion'); + assert.equal(done.lastBattleContext!.smugglerCollection!.stage, 'void'); + assert.equal(done.spice['wind_pass:14'], undefined); + assert.equal(done.spice['wind_pass:15'], undefined); + assert.equal(done.players[0].spice, 20); + assert.equal(done.battle, null); +}); void test('changed reveal receipts, spice, plans or skill identity are rejected by reads, actions and restoration', () => { const pending = revealSmuggler(smugglerBattle()); for (const corrupt of [ @@ -134,12 +178,10 @@ 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 and multiple piles fail before sealing without changing any resources', () => { +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 multiple = smugglerBattle(); multiple.spice['wind_pass:15'] = 2; - reject(multiple, { type: 'battlePlan', leader: 'emperor-0', dial: 0 }); const trainer = smugglerBattle({ captured: true, hide: false }); setOtherSkill(trainer, 'warmaster', 'a'); reject(trainer, { type: 'battlePlan', leader: 'guild-0', dial: 0, weapon: trainer.players[0].hand[0].id }); @@ -177,3 +219,104 @@ void test('all four actual bot profiles retain a legal unmodified path and finis assert.ok(game.lastBattleContext!.smugglerCollection, difficulty); } }); + +function collectionReceipt(spice: Record, strength = 6) { + return createSmugglerBattle({ event: 'battle:collection', turn: 1, territory: 'wind_pass', + player: 'a', frame: 'revealed-plan-binding', supported: true, spice, + plan: { leader: { id: 'emperor-0', strength }, + assignments: [{ skill: 'smuggler', leader: 'emperor-0', faceUp: false, captured: false }] } })!; +} +void test('aggregate receipt fixes the cap while a signed owner choice changes concrete debited sectors', () => { + const spice = { 'wind_pass:15': 5, 'wind_pass:14': 4, 'wind_pass:16': 0, 'carthag:11': 3 }; + const receipt = collectionReceipt(spice); + assert.equal(receipt.key, null); + assert.equal(receipt.before, 9); + assert.equal(receipt.amount, 6); + assert.deepEqual(receipt.piles, { 'wind_pass:14': 4, 'wind_pass:15': 5 }); + assert.equal(smugglerBattleAllocationRequired(receipt, true), true); + const before = structuredClone({ spice, receipt }); + assert.throws(() => settleSmugglerBattle(receipt, true, spice)); + for (const allocations of [ + { 'wind_pass:14': 1, 'wind_pass:15': 5 }, + { 'wind_pass:14': 4, 'wind_pass:15': 2 }, + ]) { + const chosen = chooseSmugglerBattleAllocation(receipt, allocations); + assert.equal(chosen.signature, smugglerBattleSignature(chosen)); + assert.notEqual(chosen.signature, receipt.signature); + assert.equal(smugglerBattleAllocationRequired(chosen, true), false); + const result = settleSmugglerBattle(JSON.parse(JSON.stringify(chosen)), true, { ...spice }); + assert.equal(result.amount, 6); + assert.equal(result.receipt.stage, 'collected'); + assert.equal(result.receipt.signature, smugglerBattleSignature(result.receipt)); + assert.deepEqual(result.spice, { ...spice, 'wind_pass:14': 4 - allocations['wind_pass:14'], + 'wind_pass:15': 5 - allocations['wind_pass:15'] }); + assert.throws(() => settleSmugglerBattle(result.receipt, true, result.spice)); + } + assert.deepEqual({ spice, receipt }, before); +}); +void test('allocation rejects unknown or empty keys, unsafe quantities, overdraw and incorrect totals immutably', () => { + const spice = { 'wind_pass:14': 4, 'wind_pass:15': 5, 'wind_pass:16': 0 }; + const receipt = collectionReceipt(spice); + const invalidAllocations: Record[] = [ + { 'wind_pass:14': 5, 'wind_pass:15': 1 }, + { 'wind_pass:14': -1, 'wind_pass:15': 7 }, + { 'wind_pass:14': 1.5, 'wind_pass:15': 4.5 }, + { 'wind_pass:14': Number.MAX_SAFE_INTEGER + 1 }, + { 'wind_pass:14': NaN }, + { 'wind_pass:14': 1, 'wind_pass:15': 4 }, + { 'wind_pass:14': 2, 'wind_pass:15': 5 }, + { 'wind_pass:14': 1, 'wind_pass:15': 5, 'wind_pass:16': 0 }, + { 'wind_pass:14': 1, 'wind_pass:15': 5, 'carthag:11': 0 }, + {}, + ]; + for (const allocations of invalidAllocations) { + const before = structuredClone({ spice, receipt, allocations }); + assert.throws(() => chooseSmugglerBattleAllocation(receipt, allocations)); + assert.deepEqual({ spice, receipt, allocations }, before); + } + const third = { ...spice, 'wind_pass:16': 2 }; + const chosen = chooseSmugglerBattleAllocation(collectionReceipt(third), + { 'wind_pass:14': 1, 'wind_pass:15': 5, 'wind_pass:16': 0 }); + assert.deepEqual(chosen.allocations, { 'wind_pass:14': 1, 'wind_pass:15': 5 }); +}); +void test('death, full aggregate collection, zero cap and legacy single receipts require no allocation choice', () => { + const spice = { 'wind_pass:14': 4, 'wind_pass:15': 5 }; + const pending = collectionReceipt(spice); + assert.equal(smugglerBattleAllocationRequired(pending, false), false); + const dead = settleSmugglerBattle(pending, false, spice); + assert.equal(dead.amount, 0); + assert.equal(dead.receipt.stage, 'void'); + assert.deepEqual(dead.spice, spice); + for (const strength of [0, 9, 12]) { + const receipt = collectionReceipt(spice, strength); + assert.equal(smugglerBattleAllocationRequired(receipt, true), false); + const result = settleSmugglerBattle(receipt, true, spice); + assert.equal(result.amount, strength ? 9 : 0); + assert.deepEqual(result.spice, strength ? { 'wind_pass:14': 0, 'wind_pass:15': 0 } : spice); + } + const single = { 'wind_pass:14': 8 }; + const legacy = collectionReceipt(single); + assert.equal('piles' in legacy, false); + assert.equal('allocations' in legacy, false); + assert.equal(smugglerBattleAllocationRequired(legacy, true), false); + assert.deepEqual(settleSmugglerBattle(JSON.parse(JSON.stringify(legacy)), true, single).spice, + { 'wind_pass:14': 2 }); +}); +void test('selected aggregate settlement rejects a changed reveal pile or choice binding without mutation', () => { + const spice = { 'wind_pass:14': 4, 'wind_pass:15': 5 }; + const chosen = chooseSmugglerBattleAllocation(collectionReceipt(spice), + { 'wind_pass:14': 1, 'wind_pass:15': 5 }); + const changedPiles: Record[] = [ + { ...spice, 'wind_pass:14': 3, 'wind_pass:15': 6 }, + { ...spice, 'wind_pass:16': 1 }, + ]; + for (const changed of changedPiles) { + const before = structuredClone({ chosen, changed }); + assert.throws(() => settleSmugglerBattle(chosen, true, changed)); + assert.deepEqual({ chosen, changed }, before); + } + const corrupted = { ...chosen, allocations: { 'wind_pass:14': 2, 'wind_pass:15': 4 } }; + const before = structuredClone({ corrupted, spice }); + assert.throws(() => settleSmugglerBattle(corrupted, true, spice)); + assert.deepEqual({ corrupted, spice }, before); +});