diff --git a/components/game-table.tsx b/components/game-table.tsx
index 6196f3f3..b40f8f41 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -163,7 +163,7 @@ import {
isMirrorWeapon,
} from '@/game/battle-cards';
import { validBattleSlotPair } from '@/game/battle-card-slots';
-import { stoneBurnerPlanBlock } from '@/game/stone-burner';
+import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
import {
@@ -577,6 +577,11 @@ export function GameTable({
);
const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon);
const stoneContext = g.battle?.stoneBurnerContext;
+ const stoneCandidateContext = selectedSpecialWeapon &&
+ (selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw')
+ ? stoneBurnerWithdrawalContext(stoneContext?.occupy,
+ g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle?.harassWithdraw?.occupy)
+ : stoneContext?.occupy;
const stonePlanReason = !selectedSpecialWeapon
? null
: ((mirrorSelected ? mirrorWeaponModeBlock(g) : null) ?? stoneContext?.blocked ??
@@ -595,7 +600,7 @@ export function GameTable({
g.battle!.tieWinner === g.battle!.attacker
? 'attacker'
: 'defender',
- stoneContext.occupy,
+ stoneCandidateContext,
),
)
.find((reason) => reason !== null) ?? null)));
@@ -3083,6 +3088,11 @@ export function GameTable({
still apply.
{g.decision.copiedFrom && Your physical Mirror Weapon copied the opposing Stone Burner. Your choice is independent of its owner’s; winning retains Mirror as Mirror.
}
+
+ Provisional Harass & Withdraw timing: compare undialed
+ forces remaining after a successful withdrawal. An opposing
+ successful Traitor cancels that withdrawal.
+
+ Provisional Stone Burner timing: compare undialed forces remaining after
+ a successful withdrawal, not the forces already returned to reserves.
+ In Occupy, the other ally’s undialed forces still count.
+
);
}
diff --git a/components/revealed-battle.tsx b/components/revealed-battle.tsx
index 9c2059bf..494825dd 100644
--- a/components/revealed-battle.tsx
+++ b/components/revealed-battle.tsx
@@ -2,7 +2,7 @@ import type { GameView } from '@/game/engine';
import { faction } from '@/game/catalog';
import { leaderStrengthLabel } from '@/game/cards';
import { isStoneBurner } from '@/game/battle-cards';
-import { stoneBurnerComparison } from '@/game/stone-burner';
+import { stoneBurnerComparison, stoneBurnerWithdrawalContext } from '@/game/stone-burner';
import { RevealedPlanPieces } from './revealed-plan-pieces';
import { KwisatzInspector } from './kwisatz-inspector';
export function RevealedBattle({ game }: { game: GameView }) {
@@ -10,6 +10,18 @@ export function RevealedBattle({ game }: { game: GameView }) {
if (!battle?.revealed) return null;
const attackerPlan = battle.plans[battle.attacker];
const defenderPlan = battle.plans[battle.defender];
+ let stoneContext = battle.stoneBurnerContext?.occupy;
+ const withdrawing = [battle.attacker, battle.defender].filter(id => {
+ const plan = battle.plans[id];
+ return plan && [plan.weapon, plan.defense].includes('ecaz-harass-withdraw');
+ });
+ for (const id of withdrawing) {
+ const occupy = battle.ecazOccupy?.lead === id ? {
+ cardUser: id, ecaz: battle.ecazOccupy.ecaz, canceled: battle.ecazOccupy.canceled,
+ } : undefined;
+ stoneContext = stoneBurnerWithdrawalContext(stoneContext,
+ id === battle.attacker ? 'attacker' : 'defender', occupy);
+ }
const comparisons =
battle.cards.some(isStoneBurner) &&
battle.ownForces &&
@@ -23,6 +35,8 @@ export function RevealedBattle({ game }: { game: GameView }) {
game.me === battle.defender ? battle.ownForces! : opponent,
defenderPlan.dial,
defenderPlan.support,
+ battle.tieWinner === battle.attacker ? 'attacker' : 'defender',
+ stoneContext,
),
)
: [];
@@ -37,6 +51,13 @@ export function RevealedBattle({ game }: { game: GameView }) {
Both plans are public. Inspect any played component; use the decision controls to resolve the battle. Component guide · Battle rules
+ {comparisons.length > 0 && withdrawing.length > 0 && (
+
+ Provisional Stone Burner counts below assume Harass & Withdraw
+ succeeds. An opposing successful Traitor cancels withdrawal and decides
+ the battle instead.
+
+ )}
{[battle.attacker, battle.defender].map((id) => {
const plan = battle.plans[id];
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index ebd3b753..ac2362bd 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Harass / Stone Burner interaction — 16 October.** Richese-family Harass now
+uses visibly provisional post-withdrawal physical comparison under the standing
+choose-recommended priority; the earlier source question remains unanswered.
+Same-plan, opposing and Mirror-resolved Stone preserve Traitor precedence,
+original support and mandatory disposal. Active Occupy removes only its
+actual card user's fixed or variable undialed pool, preserving the original
+mandatory Ecaz commitment. Public preflight considers possible fixed withdrawal
+without reading hidden cards. No persisted game field is added.
+
+Affected thirteen-file161/161, types/lint and build pass. Eight same-plan
+policy programs/all four profiles accept24 continuation actions. Two actual
+390px native-control journeys prove Basic winner reversal and Advanced typed
+return/support/leader-death/bounty through retention and collection. No root
+overflow or application/browser error in the corrected host's journeys.
+Original Richese lot and shared-preparation fixture drivers were corrected;
+incidental collection-wallet expectations were removed. Current guide parse,
+policy anchor and33 evidence paths resolve; both topics' five facets stay
+Partial. [Policy and scoped proof](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026).
+Ix/Poison Tooth, same-plan Harass/Reinforcements, Reinforcements/Stone, own
+concealed No-Field Harass and other module gates remain. No human server,
+saved room, D1, deployment or public gate changed; final assurance stays due.
+
**Ixian Nexus purchased-card interaction wave — 16 October.**
Purchased-card replacement now composes:
- successful native BG Karama conversion, after its original counter;
diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md
index 755ee273..1745ca7e 100644
--- a/docs/ECAZ_OCCUPY_RULES.md
+++ b/docs/ECAZ_OCCUPY_RULES.md
@@ -249,8 +249,9 @@ Reinforcements uses the card user's own three normal-first reserve counters
and existing +2 score-only modifier, not ally reserves, extra physical dial
losses or support. Existing provisional all-revealed-outcome cost/disposal
is unchanged and remains explicitly not a publisher ruling. Both slots and
-opposing holders work; own same-plan Harass/Reinforcements and Stone/optional
-module guards stay. Basic/unprofiled co-side card battles remain rejected.
+opposing holders work; own same-plan Harass/Reinforcements and Reinforcements
+with Stone/optional modules remain guarded. Basic/unprofiled co-side card
+battles remain rejected.
Native/pure67 cases, relevant284-case batch and27 actual native-program
cases pass, including all four legal policies. Two natural captured games
@@ -297,8 +298,12 @@ active Occupy, or the **selected lead's own pool only** after prevention.
Its total dial offset is removed before native typed casualty enumeration.
Mirror copies use the same comparison and original physical-card retention.
Public opposing No-Field preflight enumerates possible native pools, never the
-actual secret denomination. Ix Stone/Poison Tooth timing, Harass/Stone and
-genuinely winner-changing typed allocations retain their existing guards.
+actual secret denomination. Ix Stone/Poison Tooth timing and genuinely
+winner-changing typed allocations retain their existing guards.
+Harass/Stone now uses the explicitly
+[provisional post-withdrawal policy](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026):
+remove only the actual card user's undialed pool, never recompute original
+fixed commitment and never inspect concealed opposing cards for preflight.
Native Ecaz/Moritani Mirror admission is Advanced only; Basic is unchanged.
The affected rule batch passes **363/363**, with types/lint and build passing.
diff --git a/docs/HARASS_WITHDRAW_RUNTIME.md b/docs/HARASS_WITHDRAW_RUNTIME.md
index 249636fa..1a9b69ca 100644
--- a/docs/HARASS_WITHDRAW_RUNTIME.md
+++ b/docs/HARASS_WITHDRAW_RUNTIME.md
@@ -1,6 +1,6 @@
# Harass & Withdraw development prototype
-Updated 14 September 2026. This connects the physical card to battle plans,
+Updated 16 October 2026. This connects the physical card to battle plans,
private inspections, force returns, controls, AI and saved continuation. It does
not complete the independent three-card variant or open public mode gates.
@@ -31,6 +31,34 @@ Planetologist's existing inspection answers remain unchanged. Ecaz's explicit
instruction to report no weapon/defense is not silently extended to other
slot substitutions.
+## Provisional Stone Burner timing — 16 October 2026
+
+The standing 14 October choose-recommended instruction selects the
+post-withdrawal comparison for the development prototype. The earlier question
+remains unanswered; this is an implementation interpretation, not a new
+publisher ruling or a user answer. The Harass face/designer reveal timing above
+and [Stone's physical undialed-token instruction](STONE_BURNER_ENGINE_AUDIT.md#primary-evidence-and-exact-effect)
+support treating returned reserves as no longer in the battle. A fresh text
+lookup in the supplied Advanced PDF found no named Harass/Stone combined rule.
+
+Normal Stone resolution compares counters left after successful Harass,
+including same-plan and opposing holders and Mirror-copied Stone. A successful
+opposing Traitor cancels the withdrawal; its validated outcome still wins before
+Stone scoring. Allocation choices and original dial/support legality remain
+mandatory. No return, fee, casualty or card disposal executes twice.
+
+Active Occupy returns only the actual card user's pool. Ecaz Harass removes
+the original fixed undialed `floor(E/2)`, leaving the ally variable remainder;
+ally Harass removes variable undialed counters, leaving fixed Ecaz undialed
+forces. Original mandatory `ceil(E/2)` commitment and native ally support stay.
+Canceled Occupy uses its lead's ordinary own pool.
+
+Precommit checks use public configuration and selected lead, never a hidden
+opponent Harass/Stone card. If an opposing Ecaz lead may withdraw its fixed
+remainder, each revealed alternative must have an allocation-invariant winner;
+different alternatives may legitimately have different winners. Actual controls
+label post-withdrawal counts as conditional on no opposing successful Traitor.
+
## Connected behavior
Use the existing independent Ecaz card development setup. All three cards enter
@@ -71,11 +99,12 @@ This is a legal strategy path, not strength calibration.
Leader Skills, Tech Tokens, Stronghold Cards and Discoveries. This includes
the printed own-Homeworld prohibition without claiming other Homeworld
battles are already implemented for this card.
-- Richese card-family configurations remain gated because Stone Burner may
- compare original or post-withdrawal undialed forces, which can reverse the
- winner. A user question has been sent; no answer is assumed. The guard uses
- public configuration, never Stone Burner's hidden custody. CHOAM without
- Richese remains eligible. This is a development boundary, not a printed ban.
+- Richese card-family configurations now admit Harass under the
+ [provisional post-withdrawal policy](#provisional-stone-burner-timing--16-october-2026).
+ The ordinary invariant-allocation guard and separate Ix/Poison Tooth timing
+ boundary remain. No public expansion start or played-game retrofit is added.
+ A card user's own still-concealed No-Field remains guarded until its original
+ reveal; this wave does not certify Harass on a concealed native marker.
- Co-present armies remain guarded in Basic or without the supported Advanced
Occupy profile. The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
returns only actual card-user forces: original fixed Ecaz commitment or the
@@ -87,7 +116,36 @@ This is a legal strategy path, not strength calibration.
unresolved. Both prototypes retain their separate provisional cost and
outcome interpretations.
-## Verification and preservation
+## Scoped interaction proof — 16 October 2026
+
+- The affected thirteen-file offline union passes **161/161**, including
+ Stone/Harass pure and native battle cases, original fixed/variable Occupy,
+ typed/sector returns, Mirror retention and Traitor cancellation. Types/lint
+ and production build pass; build retains module-register, large-chunk and
+ Unknown-route warnings.
+- Eight genuine Richese/classic Basic/Advanced same-plan policy programs
+ exercised all four profiles and24 accepted continuation actions. This is
+ minimal legal participation, not strategic calibration.
+- Two actual styled390px table journeys used original native controls from
+ before plan commitment through winner retention and collection. Basic
+ Harass dial2 returns3, leaving0 versus Stone dial4's1: defender wins, the
+ two committed attackers reach Tanks, and Harass discards once. Advanced
+ dial2/support1 exposes the actual typed choice; selecting one elite
+ commitment returns three ordinary counters, charges support once30→29,
+ kills both original leaders and awards bounty11. Stone winner keeps its
+ physical card and the original battle/decision clears. Conditional revealed
+ counts and the provisional warning are visible; no root overflow or
+ application/browser error occurred in the corrected host's journeys.
+- Native fixtures exposed two driver assumptions: a Richese Bidding lot can
+ exist without an ordinary row, and shared preparation still needs its actual
+ ready actions. Both now follow native controls. Bounty is compared between
+ paired modes rather than pinning a wallet before automatic collection.
+- Current reference parses;33 evidence paths across both topics resolve and
+ all ten checklist facets remain Partial. Runtime artifacts stay outside Git.
+ No human server, saved room, D1, deployment or public start changed. No new
+ broad, HTTP, recovery/privacy/custody assurance campaign ran.
+
+## Historical verification and preservation — 14 September 2026
Focused rules, controls, AI and authenticated recovery suites record the exact
supported paths. Fixtures explicitly distinguish genuine variant setup from
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 571319e5..fec39117 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,33 @@
# Dune implementation status
+## 16 October 2026 — Provisional post-Harass Stone comparison
+
+Removed only the public Richese-family Harass guard. Under standing
+choose-recommended, Stone provisionally compares undialed counters remaining
+after successful withdrawal; the earlier question is still unanswered, not
+a new publisher ruling. Same-plan/opposing/Mirror Stone and active fixed or
+variable Occupy preserve original commitments, support, Traitor cancellation,
+mandatory Harass disposal and native winner retention. Public precommit checks
+possible fixed Ecaz withdrawal without hidden-card reads; no game field added.
+
+Affected thirteen-file **161/161**, types/lint and build pass (module-register,
+large-chunk/Unknown-route warnings). Eight original same-plan policy programs
+exercise all four profiles and24 accepted continuation actions. Two styled390px
+actual native-control journeys pass: Basic0-vs1 winner reversal with3 returned/
+2 Tanks; Advanced typed one-elite commitment returns3 ordinary, costs one
+support, kills both leaders and awards11 bounty. Winner retention clears the
+original battle/decision and reaches collection. Conditional counts and warning
+are visible, without root overflow or journey application/browser errors.
+
+Repaired two real fixture driver assumptions, rather than suppressing native
+windows: Richese Bidding without a normal row, and shared preparation ready
+actions. Bounty assertions compare paired modes instead of incidental
+pre-collection wallet values. Current reference parse/policy anchor and33
+topic evidence paths resolve; all ten facets stay Partial. Other module and
+concealed-marker/relative-timing gates remain. No human server, D1, saved room,
+deployment/public start or broad/HTTP/recovery assurance campaign changed.
+[Interpretation and bounded proof](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026).
+
## 16 October 2026 — Direct Richese special purchase and paid-bid funding
Connected actual Advanced special-Karama cache acquisition during Bidding
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index ff52cd25..0fa5019e 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -759,7 +759,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
| Reinforcements cost | The user question still asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost; no answer is assumed. The bounded classic and Ecaz/Moritani prototype provisionally charges on all revealed outcomes, chooses normal reserves before elite and counts the transfer as battle losses. Those are product interpretations, not a ruling. Same-plan Harass/Stone Burner remain gated; opposing Harass is supported. Eligibility never depends on concealed matching Traitors. | [Source cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Bounded runtime](REINFORCEMENTS_RUNTIME.md) |
-| Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) |
+| Harass / Stone Burner | The earlier question (keep gated or compare after withdrawal) remains unanswered. Under the standing 14 October choose-recommended instruction, the development prototype now provisionally compares undialed counters remaining after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without reading hidden custody. Allocation ambiguity, Ix/Poison Tooth and other module boundaries remain. This is an implementation interpretation, not a publisher ruling or a user answer. | [Provisional timing](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) |
| Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The content-first prototype now preserves paid spice, deducts recorded actual free usage from the doubled rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. This is visibly provisional, not a numerical user ruling. Unknown ledgers, pending repricing, late rate changes and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [provisional runtime](RECRUITS_RUNTIME.md) |
| 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) |
diff --git a/docs/STONE_BURNER_ENGINE_AUDIT.md b/docs/STONE_BURNER_ENGINE_AUDIT.md
index 23c2b454..40c39715 100644
--- a/docs/STONE_BURNER_ENGINE_AUDIT.md
+++ b/docs/STONE_BURNER_ENGINE_AUDIT.md
@@ -16,6 +16,24 @@ Audit: 2026-09-06. Scope: the canonical weapon, its revealed-plan choice and phy
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
+## Harass timing follow-up — 16 October 2026
+
+The independent Ecaz-card variant now uses the explicitly
+[provisional post-withdrawal comparison](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026).
+Returned reserves are outside Stone's undialed pool; same-plan, opposing and
+Mirror-resolved Stone share that rule. Opposing successful Traitor cancels the
+return before comparison, with normal Traitor outcome precedence intact.
+Active Occupy removes only the actual card user's fixed or variable undialed
+contribution and preserves the original mandatory Ecaz dial. Public preflight
+checks possible fixed Ecaz withdrawal without inspecting hidden cards.
+
+The earlier question remains unanswered. This is an interpretation selected
+under the standing choose-recommended priority, not newly retrieved publisher
+authority. Ix/Poison Tooth, Reinforcements/Stone, own concealed No-Field Harass
+and other module boundaries remain separate. The 6 September evidence above
+is historical; the current checkpoint has its own bounded proof.
+
+
## Primary evidence and exact effect
The physical publisher face was visually read at `/tmp/dune-rules/choam-stone-reading.png`, enlarged from the [component photograph](https://cdn.anyfinder.eu/assets/QsUJtVktEC87xwK08oLlwnKyaozUWfuaLzG0LFEn67ENEEVgNrDGoITy3608Dqe7) linked by the [product gallery](https://www.tabletopfinder.eu/en/boardgame/32692/dune-choam-richese). Photograph provenance/hash are recorded in `RICHESE_ACQUISITION_RULES.md`. The visible publisher wording is the evidence; uploader printing/date remains unverified.
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index b5eda970..09bfddf2 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -26,6 +26,8 @@ import {
} from './moritani-retention';
import {
stoneBurnerComparison,
+ stoneBurnerWithdrawalContext,
+ type StoneBurnerContext,
type StoneBurnerComparison,
} from './stone-burner';
import { matchingTraitor } from './traitors';
@@ -423,8 +425,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
if (harass) {
requireQuote(validBattleSlotPair(slots[0], slots[1]),
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
- requireQuote(side.harassWithdraw && !input.homeworld && !hasStone,
- 'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
+ requireQuote(side.harassWithdraw && !input.homeworld,
+ 'Harass & Withdraw needs its supported physical context and cannot combine with Homeworld battles.');
const profile = input.ecazOccupy?.lead === side.id ? input.ecazOccupy : undefined;
const normalization = side.harassWithdraw.occupy;
if (profile) {
@@ -445,43 +447,6 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support, side.harassSelection));
}
}
- const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
- [a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
- ? 'attacker' : 'defender';
- const stoneOccupy = hasStone && input.ecazOccupy ? {
- fixedEcazDial: input.ecazOccupy.fixedEcazDial,
- ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
- } : undefined;
- const stone = hasStone
- ? stoneBurnerComparison(
- a.forces,
- a.plan.dial,
- a.plan.support,
- d.forces,
- d.plan.dial,
- d.plan.support,
- tie,
- input.ecazOccupy ? {
- attacker: input.ecazOccupy.lead === a.id ? stoneOccupy : undefined,
- defender: input.ecazOccupy.lead === d.id ? stoneOccupy : undefined,
- } : undefined,
- )
- : null;
- if (stone) {
- requireQuote(
- !!stone.winner,
- 'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
- );
- for (const [side, weapon] of [
- [a, weapons.attacker],
- [d, weapons.defender],
- ] as const)
- requireQuote(
- weapon.kind !== 'stoneBurner' ||
- ['kill', 'ignore'].includes(side.stoneMode ?? ''),
- 'Choose Stone Burner’s revealed mode before resolving battle.',
- );
- }
requireQuote(
Array.isArray(input.voters) &&
new Set(input.voters.map((v) => v.id)).size === input.voters.length &&
@@ -528,6 +493,51 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return withdrawal && !(side === a ? dc : ac)
? [{ ...withdrawal, player: side.id, card: HARASS_WITHDRAW_CARD }] : [];
});
+ const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
+ [a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
+ ? 'attacker' : 'defender';
+ const stoneOccupy = hasStone && input.ecazOccupy ? {
+ fixedEcazDial: input.ecazOccupy.fixedEcazDial,
+ ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
+ } : undefined;
+ let stoneContext: StoneBurnerContext | undefined = stoneOccupy ? {
+ attacker: input.ecazOccupy!.lead === a.id ? stoneOccupy : undefined,
+ defender: input.ecazOccupy!.lead === d.id ? stoneOccupy : undefined,
+ } : undefined;
+ if (hasStone)
+ for (const side of [a, d])
+ if (harassWithdraw.some(withdrawal => withdrawal.player === side.id))
+ stoneContext = stoneBurnerWithdrawalContext(
+ stoneContext, side === a ? 'attacker' : 'defender', side.harassWithdraw?.occupy,
+ );
+ const stone = hasStone
+ ? stoneBurnerComparison(
+ a.forces,
+ a.plan.dial,
+ a.plan.support,
+ d.forces,
+ d.plan.dial,
+ d.plan.support,
+ tie,
+ stoneContext,
+ )
+ : null;
+ if (stone) {
+ requireQuote(
+ stone.attacker.length > 0 && stone.defender.length > 0 &&
+ (!!stone.winner || ac || dc),
+ 'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
+ );
+ for (const [side, weapon] of [
+ [a, weapons.attacker],
+ [d, weapons.defender],
+ ] as const)
+ requireQuote(
+ weapon.kind !== 'stoneBurner' ||
+ ['kill', 'ignore'].includes(side.stoneMode ?? ''),
+ 'Choose Stone Burner’s revealed mode before resolving battle.',
+ );
+ }
const spiceBankerPayments = [a, d].filter(side => (side.plan.bankerSpice ?? 0) > 0).map(side => {
const waivedByTraitor = side === a ? ac && !dc : dc && !ac;
return { player: side.id, amount: waivedByTraitor ? 0 : side.plan.bankerSpice!, waivedByTraitor };
diff --git a/game/bots.ts b/game/bots.ts
index cfe3f352..50fde1e2 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -112,7 +112,7 @@ import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
-import { stoneBurnerPlanBlock, stoneBurnerComparison } from './stone-burner';
+import { stoneBurnerPlanBlock, stoneBurnerComparison, stoneBurnerWithdrawalContext } from './stone-burner';
import {
applyAction,
normalizeAutomaticGame,
@@ -956,6 +956,11 @@ function plans(g: GameView): Action[] {
const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) ||
isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon));
if (stonePlan && !action.leader) continue;
+ const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
+ const stoneContext = stonePlan && harass
+ ? stoneBurnerWithdrawalContext(b.stoneBurnerContext?.occupy,
+ b.attacker === me.id ? 'attacker' : 'defender', b.harassWithdraw?.occupy)
+ : b.stoneBurnerContext?.occupy;
if (
stonePlan &&
(!b.stoneBurnerContext ||
@@ -969,13 +974,12 @@ function plans(g: GameView): Action[] {
pool,
b.attacker === me.id ? 'attacker' : 'defender',
b.tieWinner === b.attacker ? 'attacker' : 'defender',
- b.stoneBurnerContext?.occupy,
+ stoneContext,
),
))
)
continue;
let withdrawn = 0;
- const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
if (harass) {
if (!b.harassWithdraw) continue;
try {
@@ -2406,6 +2410,16 @@ function policyActions(g: GameView): Action[] {
!!matchingTraitor(me.traitors ?? [], other.leader, card(other.leader));
const side = me.id === b.attacker ? 'attacker' : 'defender';
const pools = b.stoneBurnerContext?.opponentPools ?? [];
+ let context = b.stoneBurnerContext?.occupy;
+ for (const player of [b.attacker, b.defender]) {
+ const plan = b.plans[player];
+ if (![plan.weapon, plan.defense].includes('ecaz-harass-withdraw')) continue;
+ const occupy = b.ecazOccupy?.lead === player ? {
+ cardUser: player, ecaz: b.ecazOccupy.ecaz, canceled: b.ecazOccupy.canceled,
+ } : undefined;
+ context = stoneBurnerWithdrawalContext(context,
+ player === b.attacker ? 'attacker' : 'defender', occupy);
+ }
const winners = b.ownForces
? pools.map((pool) =>
side === 'attacker'
@@ -2417,6 +2431,7 @@ function policyActions(g: GameView): Action[] {
other.dial,
other.support,
b.tieWinner === b.attacker ? 'attacker' : 'defender',
+ context,
).winner
: stoneBurnerComparison(
pool,
@@ -2426,6 +2441,7 @@ function policyActions(g: GameView): Action[] {
own.dial,
own.support,
b.tieWinner === b.attacker ? 'attacker' : 'defender',
+ context,
).winner,
)
: [];
diff --git a/game/card-presentation.ts b/game/card-presentation.ts
index 00ceefa9..48950ea6 100644
--- a/game/card-presentation.ts
+++ b/game/card-presentation.ts
@@ -120,7 +120,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
availability: ecaz.card.effect === 'recruits'
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
: ecaz.card.effect === 'harassWithdraw'
- ? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Richese card combinations and additional optional modules remain guarded.'
+ ? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Stone Burner provisionally compares undialed forces remaining after successful withdrawal; additional optional modules remain guarded.'
: 'Reinforcements has development controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three of the card user’s own reserve forces and adds +2 battle score without extra spice support. Charging that cost on every revealed outcome is provisional; same-plan Harass & Withdraw, Stone Burner and other optional modules remain guarded.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
};
diff --git a/game/engine.ts b/game/engine.ts
index cc6492c1..3af8c863 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -378,7 +378,7 @@ import {
auditCount,
sampleAuditCards,
} from './choam-auditor';
-import { stoneBurnerPlanBlock, type StoneBurnerContext } from './stone-burner';
+import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext, type StoneBurnerContext } from './stone-burner';
import {
STRONGHOLD_CARDS,
createStrongholdCards,
@@ -20692,21 +20692,20 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate,
(field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined);
}
+function harassModuleBlock(g: Game): string | null {
+ if (!g.ecazTreachery)
+ return 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
+ if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
+ return 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
+ return combinedCardBattleBlock(g);
+}
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const b = g.battle!;
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- const combinedBlock = combinedCardBattleBlock(g);
- let blocked: string | null = null;
- if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
- else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id))
+ let blocked = harassModuleBlock(g);
+ if (!blocked && (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)))
blocked = 'Use Harass & Withdraw in your own battle.';
- else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
- blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
- else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
- blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
- else if (combinedBlock)
- blocked = combinedBlock;
- else if (p.noField?.deployed?.location.territory === b.territory)
+ if (!blocked && p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces)
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
@@ -21049,26 +21048,36 @@ function stoneTimingBlock(g: Game): string | null {
function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
const b = g.battle!, profile = ecazBattleProfile(g);
if (!profile || profile.canceled) return undefined;
- const side = { fixedEcazDial: profile.fixedEcazDial, ecazUndialed: Math.floor(profile.ecazForces.normal / 2) };
+ const side = {
+ fixedEcazDial: profile.fixedEcazDial,
+ ecazUndialed: Math.floor(profile.ecazForces.normal / 2),
+ // Configuration and selected lead are public; never inspect the sealed
+ // opponent card to decide whether its fixed undialed Ecaz forces may return.
+ ...(profile.lead === profile.ecaz && !harassModuleBlock(g)
+ ? { possibleEcazWithdrawal: true } : {}),
+ };
return profile.lead === b.attacker ? { attacker: side } : { defender: side };
}
function stonePlanBlock(
g: Game,
p: Player,
- dial: number,
- support: number,
+ plan: Pick
,
): string | null {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- const context = stoneOccupyContext(g);
+ const baseContext = stoneOccupyContext(g);
+ const context = [plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)
+ ? stoneBurnerWithdrawalContext(baseContext, b.attacker === p.id ? 'attacker' : 'defender',
+ harassWithdrawContext(g, p).occupy)
+ : baseContext;
return (
stoneTimingBlock(g) ??
stonePublicPools(g, opponent, p)
.map((pool) =>
stoneBurnerPlanBlock(
planCombatForces(g, p, opponent),
- dial,
- support,
+ plan.dial,
+ plan.support,
pool,
b.attacker === p.id ? 'attacker' : 'defender',
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
@@ -21249,11 +21258,11 @@ function validatePlan(
}
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
if (isMirrorWeapon(w)) {
- const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support);
+ const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
}
if (isStoneBurner(w)) {
- const blocked = stonePlanBlock(g, p, dial, support);
+ const blocked = stonePlanBlock(g, p, plan);
requireRule(
!blocked,
blocked ?? 'Stone Burner cannot resolve this commitment.',
diff --git a/game/reference.ts b/game/reference.ts
index 59c12493..d69826ab 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1106,6 +1106,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Any faction holding canonical Stone Burner may commit it as a Weapon / Special. It is a named special weapon for Voice, not a poison or projectile weapon. Prescience reveals its actual card identity while other plan elements stay private.',
'After both plans reveal, choose to kill both leaders or ignore the strength of leaders who otherwise survive. Neither leader strength nor Kwisatz Haderach strength determines this battle. The choice affects deaths and bounty, not which undialed-force comparison is used. Ignoring strength supplies no protection against another weapon.',
'Compare the number of physical force tokens not dialed into battle. The aggressor wins a tie. Dialed losses and spice support still follow their normal rules; elite strength is not an extra physical token.',
+ 'With Harass & Withdraw, this prototype provisionally compares undialed tokens remaining after successful withdrawal. An opposing successful Traitor cancels the return and decides the outcome instead. In Occupy, only the card user’s pool returns; original fixed Ecaz commitment and the other ally’s undialed counters remain. This timing is an explicit implementation interpretation, not a publisher ruling.',
'The plan editor checks the chosen dial and support against every projected public opposing force possibility. If legal force allocations could change the winner, commitment is blocked until that combined timing is supported. Ix expansion timing involving Poison Tooth also remains guarded pending its ordering ruling. Opposing hidden No-Field values are not inspected.',
'Traitor and Lasgun–shield explosion precedence remain intact. Ordinary bounty applies unless another effect suppresses it. Stone Burner uses the general winning battle-card retention rule: a winner may keep it, and a losing played card is discarded. Normal Moritani ally retention may apply.',
'The post-reveal choice is bound to the current battle event and cannot be repeated after refresh. Review both revealed plans and select one mode; there is no separate victim or death confirmation.',
@@ -1121,7 +1122,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Canonical weapon admission, public pre-commit safety, persisted reveal choice, physical-token comparison and ordinary cleanup are integrated in development fixtures. Ambiguous combinations remain guarded.',
+ 'Canonical weapon admission, public pre-commit safety, persisted reveal choice, physical-token comparison, provisional post-Harass comparison and ordinary cleanup are integrated. Public fixed Ecaz withdrawal alternatives are checked without hidden-card reads. Ambiguous combinations remain guarded.',
},
{
area: 'Player controls',
@@ -1152,6 +1153,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/stone-burner-review.test.ts',
'tests/stone-burner-bots.test.ts',
'tests/stone-burner-recovery.test.ts',
+ 'tests/stone-harass-quote.test.ts',
+ 'tests/stone-harass-runtime.test.ts',
+ 'tests/ecaz-occupy-harass-quote.test.ts',
],
},
],
@@ -4969,16 +4973,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Atreides asks for that weapon or defense category, answer None without exposing Harass & Withdraw. The category is fixed, while the special card may occupy its physical slot. A literal Truthtrance promise that a slot is empty still means no physical card.',
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
- 'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Richese card combinations and additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
+ 'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Stone Burner provisionally compares the undialed forces remaining after successful withdrawal, including same-plan, opposing and copied Stone. Additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, revealed typed/sector return selection, exact committed losses, opponent-Traitor cancellation, mandatory disposal and saved continuation are connected. Richese and optional-module combinations remain gated.', evidence: ['game/harass-withdraw.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment and card inspection are usable. Multiple legal typed or sector returns have an owner choice after plans reveal and before traitor declarations; unique returns are automatic.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, revealed typed/sector returns, exact committed losses, opponent-Traitor cancellation, mandatory disposal and continuation are connected. Richese/Stone uses provisional post-withdrawal comparison with original fixed Occupy costs; other optional modules remain gated.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment and card inspection are usable. Owners select typed/sector returns after reveal; Stone guidance and revealed counts visibly explain provisional post-withdrawal timing and Traitor cancellation.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
- { area: 'Documentation', status: 'Implemented', detail: 'The card guide distinguishes printed effects, explicit inference and unfinished physical allocation/Stone Burner combinations.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the prototype; final sample and browser evidence remains bounded, with full combination acceptance open.', evidence: ['tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'The guide distinguishes printed effects, explicit inference, provisional post-withdrawal Stone timing and remaining module boundaries.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused Stone/Harass and Occupy quote/native regressions cover winner reversal, original costs, typed returns, Mirror retention and Traitor precedence. Two actual mobile native-control journeys and legal four-profile smoke are bounded evidence, not full combination or deployed acceptance. Historical recovery proof is separate.', evidence: ['tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
],
},
{
diff --git a/game/stone-burner.ts b/game/stone-burner.ts
index cbb1269c..f350427d 100644
--- a/game/stone-burner.ts
+++ b/game/stone-burner.ts
@@ -5,6 +5,7 @@ import {
maxCombatSupport,
type CombatForces,
} from './combat';
+import type { HarassWithdrawOccupyContext } from './harass-withdraw';
export type StoneBurnerSide = 'attacker' | 'defender';
export type StoneBurnerComparison = {
@@ -17,12 +18,33 @@ export type StoneBurnerOccupyContext = {
fixedEcazDial: number;
ecazUndialed: number;
};
+export type StoneBurnerSideContext = Partial & {
+ withdrawal?: 'variable' | 'ecaz';
+ /** Public precommit possibility only; never inferred from a hidden hand. */
+ possibleEcazWithdrawal?: boolean;
+};
/** Context belongs to physical battle slots, not the card holder or aggressor. */
export type StoneBurnerContext = {
- attacker?: StoneBurnerOccupyContext;
- defender?: StoneBurnerOccupyContext;
+ attacker?: StoneBurnerSideContext;
+ defender?: StoneBurnerSideContext;
};
+/** Only the card user's own undialed counters leave the physical battle. */
+export function stoneBurnerWithdrawalContext(
+ context: StoneBurnerContext | undefined,
+ side: StoneBurnerSide,
+ occupy?: Pick,
+): StoneBurnerContext {
+ return {
+ ...context,
+ [side]: {
+ ...context?.[side],
+ withdrawal: occupy && !occupy.canceled && occupy.cardUser === occupy.ecaz
+ ? 'ecaz' : 'variable',
+ },
+ };
+}
+
const opposingChoices = new Map();
const MAX_CACHED_POOLS = 128;
@@ -36,10 +58,10 @@ function totals(
forces: CombatForces,
dial: number,
support: number,
- occupy?: StoneBurnerOccupyContext,
+ context?: StoneBurnerSideContext,
): number[] {
- dial -= occupy?.fixedEcazDial ?? 0;
- const fixedUndialed = occupy?.ecazUndialed ?? 0;
+ dial -= context?.fixedEcazDial ?? 0;
+ const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
if (
!validCombatForces(forces) ||
!Number.isFinite(dial) ||
@@ -50,8 +72,12 @@ function totals(
support > maxCombatSupport(forces)
)
return [];
+ // Validate the original commitment before removing its undialed complement.
+ const losses = casualtyOptions(forces, dial, support);
+ if (!losses.length) return [];
+ if (context?.withdrawal === 'variable') return [fixedUndialed];
return sorted(
- casualtyOptions(forces, dial, support).map(
+ losses.map(
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
),
);
@@ -62,13 +88,15 @@ function invariantWinner(
attacker: readonly number[],
defender: readonly number[],
aggressor: StoneBurnerSide,
+ attackerOffset = 0,
+ defenderOffset = 0,
): StoneBurnerSide | null {
if (!attacker.length || !defender.length || !validSide(aggressor))
return null;
- const aMin = attacker[0],
- aMax = attacker[attacker.length - 1];
- const dMin = defender[0],
- dMax = defender[defender.length - 1];
+ const aMin = attacker[0] + attackerOffset,
+ aMax = attacker[attacker.length - 1] + attackerOffset;
+ const dMin = defender[0] + defenderOffset,
+ dMax = defender[defender.length - 1] + defenderOffset;
if (aMin > dMax || (aMin === dMax && aggressor === 'attacker'))
return 'attacker';
if (dMin > aMax || (dMin === aMax && aggressor === 'defender'))
@@ -103,10 +131,10 @@ export function stoneBurnerComparison(
*/
function allOpposingTotals(
forces: CombatForces,
- occupy?: StoneBurnerOccupyContext,
+ context?: StoneBurnerSideContext,
): readonly (readonly number[])[] {
- const fixedUndialed = occupy?.ecazUndialed ?? 0;
- const key = [
+ const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
+ const key = context?.withdrawal === 'variable' ? `variable:${fixedUndialed}` : [
forces.normal,
forces.elite,
forces.eliteStrength,
@@ -116,24 +144,25 @@ function allOpposingTotals(
forces.freeSupport,
!!forces.eliteFreeSupport,
fixedUndialed,
+ context?.withdrawal ?? '',
].join(':');
const cached = opposingChoices.get(key);
if (cached) return cached;
const result: number[][] = [];
- const maximumHalfDial = maxCombatDial(forces) * 2;
- const maximumSupport = maxCombatSupport(forces);
- // Use the same legality and typed-support arithmetic as sealed plans. Cache
- // public pool results so repeated preflights do not repeat this enumeration.
- for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
- for (let support = 0; support <= maximumSupport; support++) {
- const choices = totals(forces, halfDial / 2, support);
- if (choices.length) {
- if (fixedUndialed)
- for (let index = 0; index < choices.length; index++)
- choices[index] += fixedUndialed;
- result.push(choices);
+ // Once the variable pool has withdrawn, every feasible original commitment
+ // leaves the same undialed total, regardless of its dial or support.
+ if (context?.withdrawal === 'variable') result.push([fixedUndialed]);
+ else {
+ const maximumHalfDial = maxCombatDial(forces) * 2;
+ const maximumSupport = maxCombatSupport(forces);
+ // Use the same legality and typed-support arithmetic as sealed plans. Cache
+ // public pool results so repeated preflights do not repeat this enumeration.
+ for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
+ for (let support = 0; support <= maximumSupport; support++) {
+ const choices = totals(forces, halfDial / 2 + (context?.fixedEcazDial ?? 0), support, context);
+ if (choices.length) result.push(choices);
}
- }
+ }
if (opposingChoices.size >= MAX_CACHED_POOLS)
opposingChoices.delete(opposingChoices.keys().next().value!);
opposingChoices.set(key, result);
@@ -162,12 +191,18 @@ export function stoneBurnerPlanBlock(
const ownTotals = totals(own, dial, support, ownOccupy);
if (!ownTotals.length)
return 'Your dial and support do not permit a legal Stone Burner force allocation.';
+ const possibleFixedReturn = opponentOccupy?.possibleEcazWithdrawal &&
+ opponentOccupy.withdrawal !== 'ecaz' ? opponentOccupy.ecazUndialed ?? 0 : 0;
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
const winner =
side === 'attacker'
? invariantWinner(ownTotals, otherTotals, aggressor)
: invariantWinner(otherTotals, ownTotals, aggressor);
- if (winner === null)
+ const withdrawnWinner = !possibleFixedReturn ? winner :
+ side === 'attacker'
+ ? invariantWinner(ownTotals, otherTotals, aggressor, 0, -possibleFixedReturn)
+ : invariantWinner(otherTotals, ownTotals, aggressor, -possibleFixedReturn, 0);
+ if (winner === null || withdrawnWinner === null)
return 'Stone Burner can change the winner with different legal force allocations; this combined allocation timing is not yet supported.';
}
return null;
diff --git a/tests/ecaz-occupy-harass-quote.test.ts b/tests/ecaz-occupy-harass-quote.test.ts
index 7f6b3041..1ebbd235 100644
--- a/tests/ecaz-occupy-harass-quote.test.ts
+++ b/tests/ecaz-occupy-harass-quote.test.ts
@@ -14,6 +14,11 @@ import {
type HarassWithdrawContext,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
+import { leaders } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { quoteBattleResolution, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
+import type { StoneBurnerSide } from '../game/stone-burner';
const native = (normal: number): CombatForces => ({
normal,
@@ -194,3 +199,88 @@ void test('owner and funding boundary rejects invalid total plans before quoting
};
assert.throws(() => quoteHarassWithdraw(canceledWithFixed, 4, 1), HarassWithdrawError);
});
+
+function stoneResolution(p: EcazOccupyBattleProfile, slot: StoneBurnerSide, otherUndialed: number): BattleResolutionInput {
+ const side = (id: string, faction: 'ecaz' | 'ixians' | 'guild', forces: CombatForces, dial: number): ResolutionCombatant => {
+ const leader = { ...leaders(faction)[0], strength: 0 };
+ return { id, faction, forces, leader, spice: 10, hand: [],
+ plan: { dial, support: 1, leader: leader.id, weapon: null, defense: null } };
+ };
+ const own = side(p.lead, p.lead === p.ecaz ? 'ecaz' : 'ixians', p.forces, p.fixedEcazDial + 1);
+ own.ally = p.lead === p.ecaz ? p.ally : p.ecaz;
+ const other = side('opponent', 'guild', native(otherUndialed + 1), 1);
+ const stone = richeseCards().find(card => card.effect === 'stoneBurner')!;
+ const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
+ own.hand = [stone, harass];
+ own.plan.weapon = stone.id;
+ own.plan.defense = harass.id;
+ own.stoneMode = 'ignore';
+ own.harassWithdraw = contextFor(p);
+ return {
+ ecazOccupy: p, advanced: true, turn: 2, territory: 'carthag', aggressor: p.lead,
+ attacker: slot === 'attacker' ? own : other,
+ defender: slot === 'defender' ? own : other,
+ participants: [
+ { id: p.ecaz, faction: 'ecaz', ally: p.ally },
+ { id: p.ally, faction: 'ixians', ally: p.ecaz },
+ { id: other.id, faction: other.faction },
+ ],
+ voters: [p.ecaz, p.ally].map(id => ({ id, beneficiary: p.lead, called: false, traitors: [] as string[] }))
+ .concat([{ id: other.id, beneficiary: other.id, called: false, traitors: [] }]),
+ physicalCards: [stone, harass], pendingAuditorPresent: false, pendingRetentionPresent: false,
+ };
+}
+
+void test('Stone and active Occupy Harass remove only the Ecaz or variable card user undialed pool while preserving original fixed costs', () => {
+ for (const ecazNormal of [5, 6])
+ for (const lead of ['ecaz-user', 'ally-user'])
+ for (const slot of ['attacker', 'defender'] as const) {
+ const p = profile(ecazNormal, lead);
+ const remainingUndialed = lead === p.ecaz ? 3 : Math.floor(ecazNormal / 2);
+ const input = stoneResolution(p, slot, remainingUndialed);
+ const before = structuredClone(input);
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.winner, p.lead);
+ assert.deepEqual(quote.stone, {
+ winner: slot, attacker: [remainingUndialed], defender: [remainingUndialed],
+ });
+ assert.deepEqual(quote.harassWithdraw?.[0].returned, {
+ normal: lead === p.ecaz ? Math.floor(ecazNormal / 2) : 3, elite: 0,
+ });
+ assert.deepEqual(quote.fixedLosses, [{ owner: p.ecaz, normal: Math.ceil(ecazNormal / 2), elite: 0 }]);
+ assert.equal(quote.casualties?.owner, p.ally);
+ assert.equal(quote.casualties?.fullyCommitted, lead === p.ally ? true : undefined);
+ assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
+ assert.deepEqual(quote.payments.map(payment => payment.ownPayment), [1, 1]);
+ assert.deepEqual(quote.winnerCards, [input[slot].plan.weapon]);
+ assert.deepEqual(quote.discarded, [{ player: p.lead, card: input[slot].plan.defense }]);
+ assert.deepEqual(input, before);
+ // The opposite successful call cancels Harass before comparing physical
+ // remainders, without reducing either original Occupy contribution.
+ const voter = input.voters.find(voter => voter.id === 'opponent')!;
+ voter.called = true;
+ voter.traitors = [input[slot].plan.leader!];
+ const canceled = quoteBattleResolution(input);
+ assert.equal(canceled.winner, 'opponent');
+ assert.equal(canceled.harassWithdraw, undefined);
+ assert.deepEqual(canceled.stone?.[slot], [Math.floor(ecazNormal / 2) + 3]);
+ assert.deepEqual(canceled.fixedLosses, []);
+ }
+});
+
+void test('canceled Occupy Harass uses the selected lead native pool with no fixed remainder or recomputed commitment', () => {
+ for (const lead of ['ecaz-user', 'ally-user'])
+ for (const slot of ['attacker', 'defender'] as const) {
+ const p = profile(5, lead, true);
+ const input = stoneResolution(p, slot, 0);
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.winner, p.lead);
+ assert.deepEqual(quote.stone, { winner: slot, attacker: [0], defender: [0] });
+ assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: p.forces.normal - 1, elite: 0 });
+ assert.deepEqual(quote.fixedLosses, []);
+ assert.equal(quote.casualties?.owner, p.lead);
+ assert.equal(quote.casualties?.forces.normal, 1);
+ assert.equal(quote.casualties?.fullyCommitted, true);
+ assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
+ }
+});
diff --git a/tests/ecaz-occupy-stone.test.ts b/tests/ecaz-occupy-stone.test.ts
index 85be8a31..59026da1 100644
--- a/tests/ecaz-occupy-stone.test.ts
+++ b/tests/ecaz-occupy-stone.test.ts
@@ -5,7 +5,7 @@ import { richeseCards } from '../game/richese-cards';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from '../game/combat';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from '../game/ecaz-occupy-battle';
import { quoteBattleResolution, BattleResolutionQuoteError, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote';
-import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSide } from '../game/stone-burner';
+import { stoneBurnerComparison, stoneBurnerPlanBlock, stoneBurnerCompulsionBlock, type StoneBurnerContext, type StoneBurnerOccupyContext, type StoneBurnerSideContext, type StoneBurnerSide } from '../game/stone-burner';
const ordinary = (normal: number): CombatForces => ({
normal, elite: 0, eliteStrength: 1, freeSupport: false,
@@ -79,12 +79,12 @@ void test('public hypothetical native pools remain authoritative under the same
assert.deepEqual(stoneBurnerComparison(ordinary(4), 0, 0, concealedNormals, 3, 0, 'attacker', context).defender, [9]);
});
-function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerOccupyContext): number[] {
+function physicalChoices(pool: CombatForces, dial: number, support: number, p?: StoneBurnerSideContext): number[] {
const offset = p?.ecazUndialed ?? 0;
return casualtyOptions(pool, dial - (p?.fixedEcazDial ?? 0), support)
.map(loss => offset + pool.normal + pool.elite - loss.normal - loss.elite);
}
-function publicPlans(pool: CombatForces, p?: StoneBurnerOccupyContext): { dial: number; support: number; totals: number[] }[] {
+function publicPlans(pool: CombatForces, p?: StoneBurnerSideContext): { dial: number; support: number; totals: number[] }[] {
const plans: { dial: number; support: number; totals: number[] }[] = [];
for (let half = 0; half <= maxCombatDial(pool) * 2; half++)
for (let support = 0; support <= maxCombatSupport(pool); support++) {
diff --git a/tests/fixture-advanced-source.ts b/tests/fixture-advanced-source.ts
index f7898123..0caba449 100644
--- a/tests/fixture-advanced-source.ts
+++ b/tests/fixture-advanced-source.ts
@@ -72,7 +72,8 @@ function nativeStep(game: Game): Game {
}
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'});
if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'});
- if (game.phase !== 6 || !game.active) {
+ // A native Richese lot can own Bidding without an ordinary auction row.
+ if (game.phase !== 3 && (game.phase !== 6 || !game.active)) {
const seat = game.players.find(p => !game.ready.includes(p.id));
if (seat) return applyAction(game, seat.id, {type: 'ready'});
}
diff --git a/tests/fixture-stone-harass.ts b/tests/fixture-stone-harass.ts
new file mode 100644
index 00000000..89e5555a
--- /dev/null
+++ b/tests/fixture-stone-harass.ts
@@ -0,0 +1,352 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
+ viewGame, type Action, type Game, type Player,
+} from '../game/engine';
+import { treacheryDeck, type Card } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { richeseCards } from '../game/richese-cards';
+import { traitorDeck } from '../game/traitors';
+import { defaultHarassWithdrawAllocation, type HarassWithdrawSelection } from '../game/harass-withdraw';
+import { finishHarassPreparation, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
+import {
+ chooseEcazOccupyLead, ecazOccupyFixture, openEcazOccupyPlans, type EcazOccupyFixture,
+} from './fixture-ecaz-occupy';
+import { finishOccupyCardsAftermath } from './fixture-ecaz-occupy-cards';
+
+export const HARASS_CARD = 'ecaz-harass-withdraw';
+export const STONE_CARD = 'richese-stone-burner';
+export const MIRROR_CARD = 'richese-mirror-weapon';
+export type StoneHarassMode = 'kill' | 'ignore';
+export type StoneHarassSide = 'attacker' | 'defender';
+export type StoneHarassStep = { actor: string; action: Action };
+
+export function stoneHarassPlayer(game: Game, id: string): Player {
+ const player = game.players.find(candidate => candidate.id === id);
+ assert.ok(player, 'The genuine setup must contain this physical owner.');
+ return player;
+}
+
+/** Move one original physical face, including the separate native Richese cache. */
+export function takeStoneHarassCard(game: Game, owner: string, id: string): Card {
+ const player = stoneHarassPlayer(game, owner);
+ const held = player.hand.find(card => card.id === id);
+ if (held) return held;
+ for (const zone of [game.richeseCache ?? [], game.deck, game.discard,
+ ...game.players.filter(candidate => candidate.id !== owner).map(candidate => candidate.hand)]) {
+ const index = zone.findIndex(card => card.id === id);
+ if (index < 0) continue;
+ const [card] = zone.splice(index, 1);
+ player.hand.push(card);
+ return card;
+ }
+ throw new Error(`Missing original physical Stone/Harass fixture card ${id}`);
+}
+
+/** Exchange one original held Traitor Card for another real identity. */
+export function holdStoneHarassTraitor(game: Game, owner: string, identity: string): void {
+ const player = stoneHarassPlayer(game, owner);
+ if (player.traitors.includes(identity)) return;
+ assert.ok(player.traitors[0], 'An original Traitor Card must fund this exchange.');
+ const reserve = game.traitorReserve;
+ const index = reserve?.indexOf(identity) ?? -1;
+ if (reserve && index >= 0) {
+ reserve[index] = player.traitors[0];
+ player.traitors[0] = identity;
+ return;
+ }
+ const donor = game.players.find(candidate => candidate.id !== owner && candidate.traitors.includes(identity));
+ assert.ok(donor, 'A real non-loyal Traitor Card must exist in the original source.');
+ const donorIndex = donor.traitors.indexOf(identity);
+ donor.traitors[donorIndex] = player.traitors[0];
+ player.traitors[0] = identity;
+}
+
+/** Physical conservation across the native Richese cache and every ordinary zone. */
+export function stoneHarassCustody(game: Game): void {
+ const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
+ ...(game.richeseRemoved ?? []), ...(game.ixSetupCards ?? []),
+ ...game.players.flatMap(player => player.hand)];
+ assert.deepEqual(cards.map(card => card.id).sort(),
+ [...treacheryDeck(game.expansions), ...ecazTreacheryCards(), ...richeseCards()]
+ .map(card => card.id).sort(),
+ 'The original card family and the Richese cache must each retain every physical face.');
+ assert.deepEqual([
+ ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
+ ...(game.traitorReserve ?? []),
+ ...game.players.flatMap(player => [...player.traitors,
+ ...(player.faceDancers ?? []).map(card => card.leader)]),
+ ].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
+ for (const player of game.players) {
+ assert.equal(player.reserves + player.tanks +
+ Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
+ assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
+ .every(amount => Number.isSafeInteger(amount) && amount >= 0));
+ if (player.elites) {
+ assert.equal(player.elites.reserves + player.elites.tanks +
+ Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0),
+ player.faction === 'emperor' ? 5 : player.faction === 'ixians' ? 7 : 3);
+ assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
+ for (const [key, amount] of Object.entries(player.elites.forces))
+ assert.ok(amount <= (player.forces[key] ?? 0));
+ }
+ }
+}
+
+/** Every original physical traitor identity currently in play. */
+export function traitorIdentities(game: Game): Set {
+ return new Set([...(game.traitorReserve ?? []),
+ ...game.players.flatMap(player => [...player.traitors,
+ ...(player.faceDancers ?? []).map(card => card.leader)])]);
+}
+
+function restage(game: Game, id: string, total: number, location: string): void {
+ const player = stoneHarassPlayer(game, id);
+ player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
+ player.forces = {};
+ player.reserves -= total;
+ if (total) player.forces[location] = total;
+ assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), total);
+}
+
+export type StoneHarassOptions = {
+ advanced?: boolean;
+ /** Which physical slot buys Harass & Withdraw; its owner plays the card. */
+ harass?: StoneHarassSide | null;
+ /** Who holds the physical Stone Burner: the Harass owner's opponent or itself. */
+ stone?: 'opponent' | 'harass' | null;
+ /** Hand the physical Mirror Weapon to the side without the Stone. */
+ mirror?: boolean;
+ /** Stage Harass in the owner's plan: false leaves the same card in hand. */
+ playHarass?: boolean;
+ /** Prefer plan leaders that also exist in the original physical traitor pool. */
+ traitorableLeaders?: boolean;
+ mode?: StoneHarassMode;
+ /** Ordinary counters staged for the native attacker after the original deal. */
+ attackerForces?: number;
+ attackerElite?: number;
+ defenderForces?: number;
+ attackerDial?: number;
+ defenderDial?: number;
+ attackerSupport?: number;
+ defenderSupport?: number;
+ /** Native battle location; the second sector enables the allocation decision. */
+ territory?: string;
+ sector?: number;
+ secondSector?: number;
+ split?: number;
+};
+export type StoneHarassCase = {
+ game: Game;
+ attacker: string;
+ defender: string;
+ harassOwner: string | null;
+ stoneOwner: string | null;
+ mirrorOwner: string | null;
+ territory: string;
+ sector: number;
+ location: string;
+ locations: string[];
+ leaders: Record;
+ dials: Record;
+ supports: Record;
+ mode: StoneHarassMode;
+ plans: StoneHarassStep[];
+};
+
+/** Genuine Basic/Advanced three-seat native setup (classics plus Richese's own
+ * cache). Forces are restaged only as conserved physical counters of the real
+ * battle territory; the battle itself is the original native chooseBattle. */
+export function stoneHarassFixture(options: StoneHarassOptions = {}): StoneHarassCase {
+ const harassOwner = options.harass === null ? null : options.harass === 'attacker' ? 'a' : 'd';
+ const stoneOwner = options.stone === null ? null : options.stone === 'harass'
+ ? harassOwner : harassOwner === 'a' ? 'd' : 'a';
+ assert.ok(stoneOwner !== null || options.mirror !== true,
+ 'A Mirror Weapon needs an opposing physical Stone Burner.');
+ const mirrorOwner = options.mirror
+ ? stoneOwner === 'a' ? 'd' : 'a' : null;
+ const territory = options.territory ?? 'arrakeen';
+ const sector = options.sector ?? 10;
+ const game = harassWithdrawGame({ advanced: options.advanced ?? false,
+ factions: ['emperor', 'atreides', 'richese'],
+ normal: options.attackerForces ?? 5, elite: options.attackerElite ?? 0,
+ prepare: true, territory, sector });
+ const locations = [location(territory, sector)];
+ if ((options.defenderForces ?? 5) !== 5)
+ restage(game, 'd', options.defenderForces ?? 5, locations[0]);
+ if (options.split) {
+ assert.ok(options.secondSector !== undefined,
+ 'A split staging needs the second native sector of this territory.');
+ const second = location(territory, options.secondSector);
+ for (const id of ['a', 'd'] as const) {
+ const player = stoneHarassPlayer(game, id);
+ const available = player.forces[locations[0]] ?? 0;
+ const elite = player.elites?.forces[locations[0]] ?? 0;
+ assert.ok(options.split <= available - elite,
+ 'A split staging keeps every staged elite counter in its original sector.');
+ player.forces[locations[0]] = available - options.split;
+ player.forces[second] = (player.forces[second] ?? 0) + options.split;
+ }
+ locations.push(second);
+ }
+ if (harassOwner) takeHarassCard(game, harassOwner, HARASS_CARD);
+ if (stoneOwner) takeStoneHarassCard(game, stoneOwner, STONE_CARD);
+ if (mirrorOwner) takeStoneHarassCard(game, mirrorOwner, MIRROR_CARD);
+ const dials: Record = { a: options.attackerDial ?? 0, d: options.defenderDial ?? 0 };
+ // Advanced ordinary counter pools require support equal to the dialed counters.
+ const supports: Record = {
+ a: options.attackerSupport ?? (options.advanced ? dials.a : 0),
+ d: options.defenderSupport ?? (options.advanced ? dials.d : 0) };
+ const leaders: Record = {};
+ const plans: StoneHarassStep[] = (['a', 'd'] as const).map(actor => {
+ const available = stoneHarassPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt);
+ const traitor = options.traitorableLeaders ? traitorIdentities(game) : null;
+ leaders[actor] = (traitor ? available.find(leader => traitor.has(leader.id)) : undefined)?.id
+ ?? available[0].id;
+ const weapon = actor === stoneOwner ? STONE_CARD : actor === mirrorOwner ? MIRROR_CARD : undefined;
+ const playHarass = actor === harassOwner && (options.playHarass ?? true);
+ return { actor, action: { type: 'battlePlan', dial: dials[actor], support: supports[actor],
+ leader: leaders[actor],
+ ...(weapon ? { weapon } : {}),
+ ...(playHarass ? { [weapon ? 'defense' : 'weapon']: HARASS_CARD } : {}) } };
+ });
+ stoneHarassCustody(game);
+ return { game, attacker: 'a', defender: 'd', harassOwner, stoneOwner, mirrorOwner,
+ territory, sector, location: locations[0], locations, leaders, dials, supports,
+ mode: options.mode ?? 'ignore', plans };
+}
+
+function location(territory: string, sector: number): string {
+ return `${territory}:${sector}`;
+}
+
+/** Seal the original native plans in their staged actor order. */
+export function sealStoneHarassPlans(fixture: Pick): Game {
+ let game = fixture.game;
+ for (const step of fixture.plans) game = applyAction(game, step.actor, step.action);
+ assert.equal(game.battle?.revealed, true, 'Both original sealed plans must reveal.');
+ return game;
+}
+
+/** Answer only the native reveal offers; never a phase, receipt or mode of our own. */
+export function chooseStoneHarassReveal(state: Game, options: {
+ mode?: StoneHarassMode; modes?: Record;
+ returns?: HarassWithdrawSelection;
+} = {}): Game {
+ let game = state;
+ for (let attempt = 0; attempt < 10; attempt++) {
+ const decision = game.decision;
+ if (decision?.kind === 'harassWithdraw') {
+ const offer = viewGame(game, decision.player).battle?.harassAllocation;
+ assert.ok(offer, 'Choose only the actual revealed physical withdrawal allocation.');
+ game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
+ returns: options.returns ?? defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support) });
+ } else if (decision?.kind === 'stoneBurner') {
+ game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
+ mode: options.modes?.[decision.player] ?? options.mode ?? 'ignore' });
+ } else return game;
+ }
+ throw new Error('The native Stone/Harass reveal offers did not finish.');
+}
+
+/** Traitor declarations and the native aftermath; asserts physical conservation. */
+export function finishStoneHarassBattle(state: Game, options: {
+ mode?: StoneHarassMode; modes?: Record;
+ returns?: HarassWithdrawSelection; traitors?: readonly string[];
+} = {}): Game {
+ let game = chooseStoneHarassReveal(state, options);
+ for (let attempt = 0; game.battle && attempt < 10; attempt++) {
+ assert.equal(game.decision, null, 'Every required reveal choice precedes traitor declarations.');
+ const battle = viewGame(game, game.battle!.attacker).battle!;
+ const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
+ if (!voter) break;
+ game = applyAction(game, voter, { type: 'traitorCall',
+ call: options.traitors?.includes(voter) ?? false });
+ if (game.decision) break;
+ }
+ game = finishOccupyCardsAftermath(game);
+ stoneHarassCustody(game);
+ return game;
+}
+
+export type StoneHarassOccupyOptions = {
+ lead?: 'ecaz' | 'ally';
+ /** Stage Harass in the selected lead's plan; false keeps the same card in hand. */
+ harass?: boolean;
+ stone?: 'opponent' | 'harass';
+ mirror?: boolean;
+ variableDial?: number;
+ support?: number;
+ ecazForces?: number;
+ allyForces?: number;
+ opponentForces?: number;
+ opponentElite?: number;
+ opponentDial?: number;
+ mode?: StoneHarassMode;
+};
+export type StoneHarassOccupyCase = StoneHarassCase & {
+ occupy: EcazOccupyFixture;
+ ecaz: string;
+ ally: string;
+ opponent: string;
+ lead: string;
+};
+
+/** Genuine E3 advanced roster (Ecaz, its real ally, a classic opponent and
+ * Richese's own cache) through the original Nexus, shared shipment and Occupy
+ * lead choice. Only conserved card/force staging exposes the Stone rule. */
+export function stoneHarassOccupyFixture(
+ options: StoneHarassOccupyOptions = {},
+): StoneHarassOccupyCase {
+ const lead: 'ecaz' | 'ally' = options.lead ?? 'ecaz';
+ // The original four-seat advanced lobby supplies Ecaz, its real ally, a
+ // classic opponent and Richese's own card cache; every later step is native.
+ let initial = createGame('STONEOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), true, ['ecaz', 'choam']);
+ for (const faction of ['guild', 'emperor', 'richese'] as const)
+ joinGame(initial, newPlayer(faction, faction, faction));
+ for (const player of initial.players) initial = applyAction(initial, player.id, { type: 'ready' });
+ initial.ecazTreachery = true;
+ initial = initializeEcazOccupyGameForAudit(initial);
+ const occupy = ecazOccupyFixture({ initial, expansions: ['ecaz', 'choam'], ecazTreachery: true,
+ allyFaction: 'guild', opponentFaction: 'emperor',
+ ecazForces: options.ecazForces ?? 5, allyForces: options.allyForces ?? 4,
+ ...(options.opponentForces === undefined ? {} : { opponentForces: options.opponentForces }),
+ ...(options.opponentElite === undefined ? {} : { opponentElite: options.opponentElite }) });
+ const owner = lead === 'ecaz' ? occupy.ecaz : occupy.ally;
+ const harassOwner = options.harass === false ? null : owner;
+ const stoneOwner = options.stone === 'harass' ? owner : occupy.opponent;
+ const mirrorOwner = options.mirror ? stoneOwner === owner ? occupy.opponent : owner : null;
+ if (owner) takeHarassCard(occupy.game, owner, HARASS_CARD);
+ takeStoneHarassCard(occupy.game, stoneOwner, STONE_CARD);
+ if (mirrorOwner) takeStoneHarassCard(occupy.game, mirrorOwner, MIRROR_CARD);
+ const game = finishHarassPreparation(openEcazOccupyPlans(chooseEcazOccupyLead(occupy, lead)));
+ const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
+ assert.ok(profile, 'The native Occupy plan must quote its physical profile.');
+ const variableDial = options.variableDial ?? 0;
+ const leaders: Record = {
+ [owner]: stoneHarassPlayer(game, owner).leaders.find(leader => !leader.dead && !leader.usedAt)!.id,
+ [occupy.opponent]: stoneHarassPlayer(game, occupy.opponent)
+ .leaders.find(leader => !leader.dead && !leader.usedAt)!.id,
+ };
+ const plans: StoneHarassStep[] = [
+ { actor: owner, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
+ support: options.support ?? variableDial, leader: leaders[owner],
+ ...(stoneOwner === owner ? { weapon: STONE_CARD } : {}),
+ ...(mirrorOwner === owner ? { weapon: MIRROR_CARD } : {}),
+ ...(harassOwner && options.harass !== false ? { defense: HARASS_CARD } : {}) } },
+ { actor: occupy.opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0,
+ support: 0, leader: leaders[occupy.opponent],
+ ...(stoneOwner === occupy.opponent ? { weapon: STONE_CARD } : {}),
+ ...(mirrorOwner === occupy.opponent ? { weapon: MIRROR_CARD } : {}) } },
+ ];
+ stoneHarassCustody(game);
+ return { game, plans, occupy, ecaz: occupy.ecaz, ally: occupy.ally, opponent: occupy.opponent,
+ lead: owner, harassOwner, stoneOwner, mirrorOwner,
+ attacker: game.battle!.attacker, defender: game.battle!.defender,
+ territory: occupy.territory, sector: Number(occupy.location.split(':')[1]),
+ location: occupy.location, locations: [occupy.location],
+ leaders, dials: { [owner]: profile.fixedEcazDial + variableDial,
+ [occupy.opponent]: options.opponentDial ?? 0 },
+ supports: { [owner]: options.support ?? 0, [occupy.opponent]: 0 },
+ mode: options.mode ?? 'ignore' };
+}
diff --git a/tests/harass-withdraw.test.ts b/tests/harass-withdraw.test.ts
index bc847cdc..740b748d 100644
--- a/tests/harass-withdraw.test.ts
+++ b/tests/harass-withdraw.test.ts
@@ -659,15 +659,7 @@ void test('Basic and Advanced Ixian half-dial withdrawal preserves all seven phy
}
});
-void test('public Richese and optional-module contexts remain guarded', () => {
- const richese = harassWithdrawGame({
- factions: ['emperor', 'atreides', 'richese'],
- });
- assert.match(
- viewGame(richese, 'a').battle!.harassWithdraw!.blocked!,
- /Richese card family/,
- );
- reject(richese, 'a', ownPlan(richese), /Richese card family/);
+void test('additional optional-module contexts remain guarded', () => {
const g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] });
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
const forged = structuredClone(g);
diff --git a/tests/stone-burner.test.ts b/tests/stone-burner.test.ts
index 6e23e4b1..fe09e5fe 100644
--- a/tests/stone-burner.test.ts
+++ b/tests/stone-burner.test.ts
@@ -5,6 +5,7 @@ import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
stoneBurnerCompulsionBlock,
+ stoneBurnerWithdrawalContext,
type StoneBurnerSide,
} from '../game/stone-burner';
@@ -316,3 +317,70 @@ void test('public compulsion matches existence of a supported zero-spice plan wi
/physical force pools/,
);
});
+
+void test('post-withdrawal comparison resolves original typed ambiguity without skipping original plan legality', () => {
+ assert.equal(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0).winner, null);
+ const context = stoneBurnerWithdrawalContext(undefined, 'attacker');
+ assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', context), {
+ winner: 'defender', attacker: [0], defender: [2],
+ });
+ assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(3), 'attacker', 'defender', context), null);
+ for (const [dial, support] of [[9, 0], [3, 7], [0.25, 0], [0, 1]]) {
+ assert.deepEqual(stoneBurnerComparison(emperor, dial, support, ordinary(1), 0, 0, 'attacker', context).attacker, []);
+ assert.notEqual(stoneBurnerPlanBlock(emperor, dial, support, ordinary(1), 'attacker', 'attacker', context), null);
+ }
+});
+
+void test('both returned undialed pools tie at zero for the actual aggressor', () => {
+ const context = stoneBurnerWithdrawalContext(
+ stoneBurnerWithdrawalContext(undefined, 'attacker'), 'defender',
+ );
+ for (const aggressor of ['attacker', 'defender'] as const)
+ assert.deepEqual(stoneBurnerComparison(ordinary(5), 3, 0, ordinary(3), 1, 0, aggressor, context), {
+ winner: aggressor, attacker: [0], defender: [0],
+ });
+});
+
+void test('active Occupy withdrawal removes only the named owner contribution and preserves the original fixed dial', () => {
+ const original = { attacker: { fixedEcazDial: 3, ecazUndialed: 2 } };
+ const before = structuredClone(original);
+ const ecaz = stoneBurnerWithdrawalContext(original, 'attacker', {
+ canceled: false, cardUser: 'ecaz', ecaz: 'ecaz',
+ });
+ const variable = stoneBurnerWithdrawalContext(original, 'attacker', {
+ canceled: false, cardUser: 'ally', ecaz: 'ecaz',
+ });
+ assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', ecaz).attacker, [1, 2, 3]);
+ assert.deepEqual(stoneBurnerComparison(emperor, 6, 1, ordinary(3), 1, 0, 'attacker', variable).attacker, [2]);
+ assert.deepEqual(stoneBurnerComparison(emperor, 2, 0, ordinary(3), 1, 0, 'attacker', variable).attacker, []);
+ const canceled = stoneBurnerWithdrawalContext(undefined, 'attacker', {
+ canceled: true, cardUser: 'ecaz', ecaz: 'ecaz',
+ });
+ assert.deepEqual(stoneBurnerComparison(emperor, 3, 1, ordinary(3), 1, 0, 'attacker', canceled).attacker, [0]);
+ assert.deepEqual(original, before);
+});
+
+void test('public possible opposing Ecaz withdrawal checks both scenarios without conflating their winners or cached totals', () => {
+ for (const side of ['attacker', 'defender'] as const) {
+ const opposingSide = side === 'attacker' ? 'defender' : 'attacker';
+ const fixed = { fixedEcazDial: 4, ecazUndialed: 4 };
+ const context = { [opposingSide]: fixed };
+ assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null);
+ assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, {
+ [opposingSide]: { ...fixed, possibleEcazWithdrawal: true },
+ }), null);
+ assert.notEqual(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side,
+ stoneBurnerWithdrawalContext(context, opposingSide, { canceled: false, cardUser: 'ecaz', ecaz: 'ecaz' }),
+ ), null);
+ assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side,
+ stoneBurnerWithdrawalContext(context, opposingSide),
+ ), null);
+ // Populate/use the original cache again after the actual withdrawal modes.
+ assert.equal(stoneBurnerPlanBlock(emperor, 3, 1, ordinary(2), side, side, context), null);
+ // A revealed return can change the winner, but neither scenario has an
+ // unresolved physical allocation when both sides have deterministic totals.
+ assert.equal(stoneBurnerPlanBlock(ordinary(2), 0, 0, ordinary(0), side, side, {
+ [opposingSide]: { ...fixed, possibleEcazWithdrawal: true },
+ }), null);
+ }
+});
diff --git a/tests/stone-harass-quote.test.ts b/tests/stone-harass-quote.test.ts
new file mode 100644
index 00000000..05490f7e
--- /dev/null
+++ b/tests/stone-harass-quote.test.ts
@@ -0,0 +1,181 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { type CombatForces } from '../game/combat';
+import { HARASS_WITHDRAW_CARD, type HarassWithdrawContext } from '../game/harass-withdraw';
+import { stoneBurnerComparison, type StoneBurnerSide } from '../game/stone-burner';
+import {
+ quoteBattleResolution,
+ BattleResolutionQuoteError,
+ type BattleResolutionInput,
+ type ResolutionCombatant,
+} from '../game/battle-resolution-quote';
+
+const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards(), ...ecazTreacheryCards()];
+const opposite = (side: StoneBurnerSide): StoneBurnerSide => side === 'attacker' ? 'defender' : 'attacker';
+function withdrawal(forces: CombatForces): HarassWithdrawContext {
+ return {
+ blocked: null, forces,
+ locations: { 'carthag:10': { normal: forces.normal, elite: forces.elite } },
+ };
+}
+function fixture(harassSide: StoneBurnerSide = 'attacker', samePlan = true, advanced = true): BattleResolutionInput {
+ const side = (id: string, faction: 'emperor' | 'guild', normal: number): ResolutionCombatant => {
+ const leader = { ...leaders(faction)[0], strength: 0 };
+ return {
+ id, faction, spice: 10, hand: [], leader,
+ forces: { normal, elite: 0, eliteStrength: 1, freeSupport: !advanced },
+ plan: { dial: 1, support: advanced ? 1 : 0, leader: leader.id, weapon: null, defense: null },
+ };
+ };
+ const attacker = side('a', 'emperor', harassSide === 'attacker' ? 5 : 3);
+ const defender = side('d', 'guild', harassSide === 'defender' ? 5 : 3);
+ const input: BattleResolutionInput = {
+ advanced, turn: 2, territory: 'carthag', attacker, defender,
+ voters: [attacker, defender].map(s => ({ id: s.id, beneficiary: s.id, called: false, traitors: [] })),
+ participants: [attacker, defender].map(s => ({ id: s.id, faction: s.faction })),
+ physicalCards: catalog(), pendingAuditorPresent: false, pendingRetentionPresent: false,
+ };
+ const harass = input.physicalCards.find(c => c.id === HARASS_WITHDRAW_CARD)! as Card;
+ const stone = input.physicalCards.find(c => (c as Card).effect === 'stoneBurner')! as Card;
+ input[harassSide].hand = [harass];
+ input[harassSide].plan.defense = harass.id;
+ input[harassSide].harassWithdraw = withdrawal(input[harassSide].forces);
+ const stoneSide = samePlan ? harassSide : opposite(harassSide);
+ input[stoneSide].hand = [...input[stoneSide].hand, stone];
+ input[stoneSide].plan.weapon = stone.id;
+ input[stoneSide].stoneMode = 'ignore';
+ return input;
+}
+
+void test('same-plan and opposing Stone compare only counters left after Harass, reversing the original winner in either slot and rules mode', () => {
+ for (const advanced of [false, true])
+ for (const harassSide of ['attacker', 'defender'] as const)
+ for (const samePlan of [false, true]) {
+ const input = fixture(harassSide, samePlan, advanced);
+ const otherSide = opposite(harassSide);
+ const original = stoneBurnerComparison(input.attacker.forces, 1, advanced ? 1 : 0,
+ input.defender.forces, 1, advanced ? 1 : 0);
+ assert.equal(original.winner, harassSide);
+ const before = structuredClone(input);
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.result, 'normal');
+ assert.equal(quote.winner, input[otherSide].id);
+ assert.deepEqual(quote.stone?.[harassSide], [0]);
+ assert.deepEqual(quote.stone?.[otherSide], [2]);
+ assert.deepEqual(quote.scores, { attacker: 1, defender: 1 });
+ assert.deepEqual(quote.leaderDeaths, { attacker: false, defender: false });
+ assert.deepEqual(quote.harassWithdraw?.[0].returned, { normal: 4, elite: 0 });
+ assert.deepEqual(quote.destroyedArmies, [input[harassSide].id]);
+ assert.deepEqual(quote.payments.map(p => p.ownPayment), advanced ? [1, 1] : []);
+ assert.ok(quote.discarded.some(c => c.player === input[harassSide].id && c.card === HARASS_WITHDRAW_CARD));
+ const stoneSide = samePlan ? harassSide : otherSide;
+ assert.equal(quote.winnerCards.includes(input[stoneSide].plan.weapon!), !samePlan);
+ if (advanced)
+ assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
+ else assert.equal(quote.basicWinnerLosses, 1);
+ assert.deepEqual(input, before);
+ }
+});
+
+void test('post-withdrawal zero ties honor the actual aggressor and a Harass winner keeps only Stone with its original commitment', () => {
+ for (const harassSide of ['attacker', 'defender'] as const)
+ for (const aggressor of ['attacker', 'defender'] as const) {
+ const input = fixture(harassSide);
+ input[opposite(harassSide)].forces.normal = 1;
+ input.aggressor = input[aggressor].id;
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.winner, input[aggressor].id);
+ assert.deepEqual(quote.stone, { winner: aggressor, attacker: [0], defender: [0] });
+ if (aggressor === harassSide) {
+ assert.deepEqual(quote.winnerCards, [input[harassSide].plan.weapon]);
+ assert.equal(quote.casualties?.fullyCommitted, true);
+ assert.equal(quote.casualties?.forces.normal, 1);
+ assert.deepEqual(quote.casualties?.options, [{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }]);
+ }
+ }
+});
+
+function typedFixture(): BattleResolutionInput {
+ const input = fixture('attacker', false);
+ input.attacker.forces = { normal: 5, elite: 1, eliteStrength: 2, freeSupport: false };
+ input.attacker.plan.dial = 3;
+ input.attacker.harassWithdraw = withdrawal(input.attacker.forces);
+ // Original legal commitments leave one, two or three counters. This return
+ // selects three normal and one elite fighter; all undialed counters leave.
+ input.attacker.harassSelection = { 'carthag:10': { normal: 2, elite: 0 } };
+ return input;
+}
+
+void test('a selected typed Harass commitment resolves the previously ambiguous Stone winner, not the original dial strength', () => {
+ const input = typedFixture();
+ assert.equal(stoneBurnerComparison(input.attacker.forces, 3, 1, input.defender.forces, 1, 1).winner, null);
+ const quote = quoteBattleResolution(input);
+ assert.deepEqual(quote.stone, { winner: 'defender', attacker: [0], defender: [2] });
+ assert.deepEqual(quote.harassWithdraw?.[0].remaining, { ...input.attacker.forces, normal: 3, elite: 1 });
+ assert.deepEqual(quote.scores, { attacker: 3, defender: 1 });
+ assert.equal(quote.winner, 'd');
+ for (const [dial, support] of [[9, 1], [3, 7]]) {
+ const invalid = structuredClone(input);
+ invalid.attacker.plan.dial = dial;
+ invalid.attacker.plan.support = support;
+ assert.throws(() => quoteBattleResolution(invalid), BattleResolutionQuoteError);
+ }
+});
+
+void test('validated opposing and mutual traitors cancel Harass before Stone counts while traitor outcomes bypass allocation ambiguity', () => {
+ for (const mutual of [false, true]) {
+ const input = typedFixture();
+ input.voters[1].called = true;
+ input.voters[1].traitors = [input.attacker.plan.leader!];
+ if (mutual) {
+ input.voters[0].called = true;
+ input.voters[0].traitors = [input.defender.plan.leader!];
+ }
+ const before = structuredClone(input);
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.harassWithdraw, undefined);
+ assert.deepEqual(quote.stone, { winner: null, attacker: [1, 2, 3], defender: [2] });
+ assert.equal(quote.result, mutual ? 'mutualTraitors' : 'traitor');
+ assert.equal(quote.winner, mutual ? null : 'd');
+ assert.equal(quote.casualties, null);
+ assert.deepEqual(input, before);
+ }
+ const ownCall = typedFixture();
+ ownCall.voters[0].called = true;
+ ownCall.voters[0].traitors = [ownCall.defender.plan.leader!];
+ const quote = quoteBattleResolution(ownCall);
+ assert.equal(quote.result, 'traitor');
+ assert.equal(quote.winner, 'a');
+ assert.deepEqual(quote.stone?.attacker, [0]);
+ assert.equal(quote.harassWithdraw?.length, 1);
+ assert.equal(quote.payments[0].freeByTraitor, true);
+});
+
+void test('Mirror-copied Stone uses resolved weapons after Harass and retains original mode, leader death and bounty rules', () => {
+ const input = fixture();
+ const mirror = catalog().find(c => c.effect === 'mirrorWeapon')!;
+ input.defender.hand = [mirror];
+ input.defender.plan.weapon = mirror.id;
+ input.defender.stoneMode = 'kill';
+ input.attacker.leader!.strength = 3;
+ input.defender.leader!.strength = 2;
+ const quote = quoteBattleResolution(input);
+ assert.equal(quote.winner, 'd');
+ assert.deepEqual(quote.stone, { winner: 'defender', attacker: [0], defender: [2] });
+ assert.deepEqual(quote.leaderDeaths, { attacker: true, defender: true });
+ assert.deepEqual(quote.scores, { attacker: 1, defender: 1 });
+ assert.deepEqual(quote.bounty, { player: 'd', amount: 5 });
+ assert.deepEqual(quote.winnerCards, [mirror.id]);
+ assert.deepEqual(quote.discarded, [
+ { player: 'a', card: input.attacker.plan.weapon },
+ { player: 'a', card: HARASS_WITHDRAW_CARD },
+ ]);
+ delete input.defender.stoneMode;
+ assert.throws(() => quoteBattleResolution(input), BattleResolutionQuoteError);
+ const invalidVoter = fixture();
+ invalidVoter.voters[1].called = true;
+ assert.throws(() => quoteBattleResolution(invalidVoter), BattleResolutionQuoteError);
+});
diff --git a/tests/stone-harass-runtime.test.ts b/tests/stone-harass-runtime.test.ts
new file mode 100644
index 00000000..e8221580
--- /dev/null
+++ b/tests/stone-harass-runtime.test.ts
@@ -0,0 +1,313 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, type Action, type Game } from '../game/engine';
+import {
+ HARASS_CARD, MIRROR_CARD, STONE_CARD, finishStoneHarassBattle, holdStoneHarassTraitor,
+ sealStoneHarassPlans, stoneHarassFixture, stoneHarassOccupyFixture, stoneHarassPlayer,
+} from './fixture-stone-harass';
+
+const seat = (game: Game, id: string) => stoneHarassPlayer(game, id);
+const territoryForces = (game: Game, id: string, territory: string) =>
+ Object.entries(seat(game, id).forces)
+ .filter(([key]) => key.startsWith(`${territory}:`))
+ .reduce((sum, [, amount]) => sum + amount, 0);
+const discardCount = (game: Game, id: string) =>
+ game.discard.filter(card => card.id === id).length;
+const handHas = (game: Game, id: string, card: string) =>
+ seat(game, id).hand.some(held => held.id === card);
+const leaderState = (game: Game, id: string, leader: string) =>
+ seat(game, id).leaders.find(candidate => candidate.id === leader)!;
+const planOf = (plans: readonly { actor: string; action: Action }[], actor: string) =>
+ plans.find(step => step.actor === actor)!.action;
+
+void test('Stone Burner compares the counters the mandatory Harass return leaves, reversing the winner', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0 };
+ const plain = stoneHarassFixture({ ...options, playHarass: false });
+ const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
+ assert.equal(plainDone.lastBattleContext?.winner, 'd',
+ 'without the Harass commitment the defender’s five undialed counters decide the ordinary Stone battle');
+ assert.equal(territoryForces(plainDone, 'd', plain.territory), 5);
+ assert.equal(seat(plainDone, 'd').tanks, 0);
+ assert.equal(seat(plainDone, 'a').tanks, 5, 'the defeated attacker loses its whole committed army');
+ assert.equal(territoryForces(plainDone, 'a', plain.territory), 0);
+ assert.ok(handHas(plainDone, 'd', HARASS_CARD), 'an unused Harass card stays in its owner’s hand');
+ assert.equal(discardCount(plainDone, HARASS_CARD), 0);
+
+ const harassed = stoneHarassFixture(options);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
+ assert.equal(done.lastBattleContext?.winner, 'a',
+ 'the same orders reverse once the mandatory return leaves the defender no physical counter');
+ assert.equal(seat(done, 'd').reserves, 20, 'every undialed defender counter returns to its reserves');
+ assert.equal(territoryForces(done, 'd', harassed.territory), 0);
+ assert.equal(seat(done, 'd').tanks, 0, 'returned counters are never casualties');
+ assert.equal(seat(done, 'a').tanks, 1, 'the winner still loses the counter it dialed');
+ assert.equal(territoryForces(done, 'a', harassed.territory), 4);
+ assert.ok(handHas(done, 'a', STONE_CARD), 'the winning attacker retains its physical Stone Burner');
+ assert.equal(discardCount(done, HARASS_CARD), 1,
+ 'the used Harass & Withdraw card is discarded exactly once');
+ assert.equal(discardCount(done, STONE_CARD), 0);
+ }
+});
+
+void test('Stone Burner zero/zero comparison awards the aggressor tie to the native attacker', () => {
+ for (const advanced of [false, true]) {
+ const fixture = stoneHarassFixture({ advanced, harass: 'defender', stone: 'opponent',
+ attackerForces: 5, defenderForces: 5, attackerDial: 5, defenderDial: 0 });
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture));
+ assert.equal(done.lastBattleContext?.winner, 'a',
+ 'with both sides at zero undialed counters the native attacker wins the tie');
+ assert.equal(seat(done, 'a').tanks, 5, 'the winning attacker still loses every counter it dialed');
+ assert.equal(territoryForces(done, 'a', fixture.territory), 0);
+ assert.equal(seat(done, 'd').reserves, 20);
+ assert.equal(territoryForces(done, 'd', fixture.territory), 0);
+ assert.equal(seat(done, 'd').tanks, 0);
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+ }
+});
+
+void test('Stone mode preserves Harass returns while deciding native leader deaths and bounty', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'attacker' as const, stone: 'opponent' as const,
+ attackerForces: 5, defenderForces: 5, attackerDial: 2, defenderDial: 0 };
+ const ignoredFixture = stoneHarassFixture({ ...options, mode: 'ignore' });
+ const ignored = finishStoneHarassBattle(sealStoneHarassPlans(ignoredFixture), { mode: 'ignore' });
+ assert.equal(ignored.lastBattleContext?.winner, 'd');
+ assert.equal(seat(ignored, 'a').tanks, 2);
+ assert.equal(seat(ignored, 'a').reserves, 18);
+ assert.equal(discardCount(ignored, HARASS_CARD), 1);
+ assert.equal(leaderState(ignored, ignoredFixture.attacker, ignoredFixture.leaders.a).dead, false,
+ 'an ignored Stone keeps both participating leaders alive');
+ assert.equal(leaderState(ignored, ignoredFixture.defender, ignoredFixture.leaders.d).dead, false);
+
+ const killedFixture = stoneHarassFixture({ ...options, mode: 'kill' });
+ const killed = finishStoneHarassBattle(sealStoneHarassPlans(killedFixture), { mode: 'kill' });
+ assert.equal(killed.lastBattleContext?.winner, killedFixture.defender);
+ assert.equal(leaderState(killed, killedFixture.attacker, killedFixture.leaders.a).dead, true,
+ 'a killing Stone kills both participating leaders');
+ assert.equal(leaderState(killed, killedFixture.defender, killedFixture.leaders.d).dead, true);
+ assert.equal(leaderState(killed, killedFixture.attacker, killedFixture.leaders.a).deaths, 1);
+ assert.equal(leaderState(killed, killedFixture.defender, killedFixture.leaders.d).deaths, 1);
+ assert.equal(seat(killed, killedFixture.defender).spice, seat(ignored, ignoredFixture.defender).spice +
+ leaderState(killedFixture.game, killedFixture.attacker, killedFixture.leaders.a).strength +
+ leaderState(killedFixture.game, killedFixture.defender, killedFixture.leaders.d).strength,
+ 'the winner collects the bounty of both killed leaders');
+ assert.equal(seat(killed, 'a').reserves, 18);
+ assert.equal(seat(killed, 'a').tanks, 2);
+ assert.equal(discardCount(killed, HARASS_CARD), 1);
+ }
+});
+
+void test('a same-plan Stone and Harass commitment returns its owner’s counters before the comparison', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'defender' as const, stone: 'harass' as const,
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0 };
+ const plain = finishStoneHarassBattle(sealStoneHarassPlans(
+ stoneHarassFixture({ ...options, playHarass: false })));
+ assert.equal(plain.lastBattleContext?.winner, 'd',
+ 'the same physical Stone plan wins while its undialed counters still stand');
+ assert.equal(territoryForces(plain, 'd', 'arrakeen'), 5);
+ assert.equal(seat(plain, 'd').tanks, 0);
+
+ const harassed = stoneHarassFixture(options);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
+ assert.equal(done.lastBattleContext?.winner, 'a');
+ assert.equal(seat(done, 'd').reserves, 20);
+ assert.equal(territoryForces(done, 'd', harassed.territory), 0);
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+ assert.equal(discardCount(done, STONE_CARD), 1,
+ 'the same plan’s physical Stone is discarded with the defeated side');
+ }
+});
+
+void test('a Mirror Weapon copying the opposing Stone keeps the copied comparison and its own retention', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'attacker' as const, stone: 'opponent' as const, mirror: true,
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 5 };
+ const fixture = stoneHarassFixture(options);
+ assert.equal(fixture.mirrorOwner, 'a');
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture),
+ { modes: { a: 'ignore', d: 'ignore' } });
+ assert.equal(done.lastBattleContext?.winner, 'a',
+ 'the copied Stone uses the zero/zero aggressor tie after Harass returns the copier’s undialed counters');
+ assert.ok(handHas(done, 'a', MIRROR_CARD), 'the winning Mirror keeps its own physical card');
+ assert.equal(seat(done, 'a').reserves, 19);
+ assert.equal(seat(done, 'a').tanks, 1);
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+ assert.equal(discardCount(done, MIRROR_CARD), 0);
+ assert.equal(discardCount(done, STONE_CARD), 1, 'the defeated original Stone is discarded');
+ assert.equal(leaderState(done, 'a', fixture.leaders.a).dead, false);
+
+ const killingFixture = stoneHarassFixture(options);
+ const killing = finishStoneHarassBattle(sealStoneHarassPlans(killingFixture),
+ { modes: { a: 'kill', d: 'ignore' } });
+ assert.equal(leaderState(killing, 'a', killingFixture.leaders.a).dead, true,
+ 'the independent Mirror mode kills both participating leaders');
+ assert.equal(leaderState(killing, 'd', killingFixture.leaders.d).dead, true);
+ }
+});
+
+void test('Stone Burner precommit is public: the same commitment is decided before and after hidden opposing plans', () => {
+ const messages: string[] = [];
+ for (const hiddenDial of [1, 5]) {
+ const ambiguous = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
+ attackerForces: 2, attackerElite: 1, attackerDial: 2, attackerSupport: 1,
+ defenderForces: 5, defenderDial: hiddenDial });
+ const stonePlan = planOf(ambiguous.plans, 'a');
+ const hiddenPlan = planOf(ambiguous.plans, 'd');
+ const before = { spice: seat(ambiguous.game, 'a').spice, reserves: seat(ambiguous.game, 'a').reserves,
+ hand: seat(ambiguous.game, 'a').hand.map(card => card.id) };
+ const attempt = (state: Game) => {
+ try {
+ applyAction(state, 'a', stonePlan);
+ assert.fail('an allocation-dependent Stone commitment was accepted');
+ } catch (error) {
+ assert.match(String(error), /change the winner/);
+ return String(error);
+ }
+ };
+ const first = attempt(ambiguous.game);
+ assert.equal(ambiguous.game.battle?.plans.a, undefined,
+ 'a rejected Stone commitment never becomes a hidden sealed plan');
+ assert.deepEqual({ spice: seat(ambiguous.game, 'a').spice,
+ reserves: seat(ambiguous.game, 'a').reserves,
+ hand: seat(ambiguous.game, 'a').hand.map(card => card.id) }, before);
+ const sealed = applyAction(ambiguous.game, 'd', hiddenPlan);
+ assert.notEqual(sealed.battle?.plans.d, undefined, 'the unrelated hidden plan seals natively');
+ assert.equal(attempt(sealed), first,
+ 'the same commitment is rejected identically whatever the hidden opposing plan holds');
+ messages.push(first);
+
+ const legal = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
+ attackerForces: 2, attackerElite: 1, attackerDial: 0, defenderForces: 5, defenderDial: hiddenDial });
+ const accepted = applyAction(legal.game, 'd', planOf(legal.plans, 'd'));
+ const committed = applyAction(accepted, 'a', planOf(legal.plans, 'a'));
+ assert.notEqual(committed.battle?.plans.a, undefined,
+ 'a physical-commitment-safe Stone plan is accepted regardless of the same hidden opposing plans');
+ }
+ assert.equal(messages[0], messages[1], 'the public rejection never reads private hands or plans');
+});
+
+void test('active Occupy with the fixed Ecaz user returns only the fixed counters, flipping the Stone winner', () => {
+ const options = { lead: 'ecaz' as const, ecazForces: 5, allyForces: 4, opponentForces: 5, opponentDial: 0 };
+ const plain = stoneHarassOccupyFixture({ ...options, harass: false });
+ const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
+ assert.equal(plainDone.lastBattleContext?.winner, plain.lead,
+ 'without the return the combined fixed and variable counters decide the battle');
+ assert.equal(seat(plainDone, plain.opponent).tanks, 5, 'the defeated opponent loses its whole army');
+ assert.equal(territoryForces(plainDone, plain.ecaz, plain.territory), 2,
+ 'the winning coalition still pays its original fixed Ecaz commitment');
+ assert.equal(territoryForces(plainDone, plain.ally, plain.territory), 4);
+
+ const harassed = stoneHarassOccupyFixture(options);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
+ assert.equal(done.lastBattleContext?.winner, harassed.opponent,
+ 'returning only the fixed Ecaz counters reverses the Stone winner');
+ assert.equal(seat(done, harassed.ecaz).reserves - seat(harassed.game, harassed.ecaz).reserves, 2,
+ 'the Ecaz card user returns exactly its own undialed counters');
+ assert.equal(seat(done, harassed.ally).reserves - seat(harassed.game, harassed.ally).reserves, 0,
+ 'the ally’s variable army is untouched by the Ecaz user’s return');
+ assert.equal(territoryForces(done, harassed.ally, harassed.territory), 0,
+ 'the unchanged ally army is destroyed with the defeated coalition');
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+ assert.ok(handHas(done, harassed.opponent, STONE_CARD), 'the winning opponent retains the physical Stone');
+});
+
+void test('active Occupy with the variable ally user returns its own army and leaves the fixed counters', () => {
+ const options = { lead: 'ally' as const, ecazForces: 5, allyForces: 4, opponentForces: 3, opponentDial: 0 };
+ const plain = stoneHarassOccupyFixture({ ...options, harass: false });
+ const plainDone = finishStoneHarassBattle(sealStoneHarassPlans(plain));
+ assert.equal(plainDone.lastBattleContext?.winner, plain.lead);
+ assert.equal(seat(plainDone, plain.opponent).tanks, 3);
+
+ const harassed = stoneHarassOccupyFixture(options);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(harassed));
+ assert.equal(done.lastBattleContext?.winner, harassed.opponent,
+ 'only the fixed Ecaz counters remain once the ally returns its own variable army');
+ assert.equal(seat(done, harassed.ally).reserves - seat(harassed.game, harassed.ally).reserves, 4,
+ 'the ally card user returns its own whole undialed army');
+ assert.equal(seat(done, harassed.ecaz).reserves - seat(harassed.game, harassed.ecaz).reserves, 0,
+ 'the fixed Ecaz commitment is never recomputed into a return');
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+});
+
+void test('the opposing active-Occupy Stone precommit never reads the coalition’s private hand', () => {
+ const sealed: boolean[] = [];
+ for (const holdsHarass of [true, false]) {
+ const fixture = stoneHarassOccupyFixture({ lead: 'ecaz', ecazForces: 5, allyForces: 4,
+ opponentForces: 5, harass: false });
+ if (!holdsHarass) {
+ const hand = seat(fixture.game, fixture.lead).hand;
+ const index = hand.findIndex(card => card.id === HARASS_CARD);
+ assert.ok(index >= 0, 'The fixture stages the physical Harass card in the lead’s hand.');
+ fixture.game.deck.push(hand.splice(index, 1)[0]);
+ }
+ const before = { spice: seat(fixture.game, fixture.lead).spice,
+ reserves: seat(fixture.game, fixture.lead).reserves };
+ const next = applyAction(fixture.game, fixture.opponent, planOf(fixture.plans, fixture.opponent));
+ assert.equal(next.battle?.revealed, false, 'the coalition’s own plan is still outstanding');
+ assert.deepEqual({ spice: seat(next, fixture.lead).spice, reserves: seat(next, fixture.lead).reserves },
+ before, 'sealing the opposing plan spends nothing of the coalition’s');
+ sealed.push(next.battle?.plans[fixture.opponent] !== undefined);
+ }
+ assert.deepEqual(sealed, [true, true],
+ 'the same opposing Stone commitment is accepted with or without the coalition’s hidden card');
+});
+
+void test('a successful opposing traitor call cancels the withdrawal before any counter returns', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0,
+ traitorableLeaders: true };
+ const fixture = stoneHarassFixture(options);
+ holdStoneHarassTraitor(fixture.game, fixture.attacker, fixture.leaders[fixture.defender]);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture), { traitors: [fixture.attacker] });
+ assert.equal(done.lastBattleContext?.result, 'traitor');
+ assert.equal(done.lastBattleContext?.winner, fixture.attacker);
+ assert.equal(seat(done, fixture.defender).reserves, 15,
+ 'an opposing successful call cancels the return entirely');
+ assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
+ assert.equal(seat(done, fixture.defender).tanks, 5);
+ assert.equal(leaderState(done, fixture.defender, fixture.leaders[fixture.defender]).dead, true);
+ assert.equal(discardCount(done, HARASS_CARD), 1,
+ 'the used Harass card is discarded even when its effect was canceled');
+ }
+});
+
+void test('an own successful traitor call leaves the withdrawal standing', () => {
+ for (const advanced of [false, true]) {
+ const options = { advanced, harass: 'defender' as const, stone: 'opponent' as const,
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 0,
+ traitorableLeaders: true };
+ const fixture = stoneHarassFixture(options);
+ holdStoneHarassTraitor(fixture.game, fixture.defender, fixture.leaders[fixture.attacker]);
+ const done = finishStoneHarassBattle(sealStoneHarassPlans(fixture), { traitors: [fixture.defender] });
+ assert.equal(done.lastBattleContext?.result, 'traitor');
+ assert.equal(done.lastBattleContext?.winner, fixture.defender);
+ assert.equal(seat(done, fixture.defender).reserves, 20,
+ 'the withdrawn counters still return for the called traitor’s own side');
+ assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
+ assert.equal(seat(done, fixture.attacker).tanks, 5);
+ assert.equal(leaderState(done, fixture.attacker, fixture.leaders[fixture.attacker]).dead, true);
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+ }
+});
+
+void test('a multi-sector withdrawal uses the native allocation decision and returns only undialed counters', () => {
+ const fixture = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent',
+ attackerForces: 5, defenderForces: 5, attackerDial: 1, defenderDial: 2, attackerSupport: 1,
+ territory: 'imperial_basin', sector: 9, secondSector: 11, split: 2 });
+ assert.deepEqual(seat(fixture.game, 'd').forces['imperial_basin:11'], 2);
+ const revealed = sealStoneHarassPlans(fixture);
+ assert.equal(revealed.decision?.kind, 'harassWithdraw',
+ 'the native allocation decision is offered for a partial multi-sector return');
+ assert.equal(revealed.decision?.player, fixture.defender);
+ const done = finishStoneHarassBattle(revealed);
+ assert.equal(done.lastBattleContext?.winner, fixture.attacker);
+ assert.equal(seat(done, fixture.defender).reserves, 18, 'only the three undialed counters return');
+ assert.equal(seat(done, fixture.defender).tanks, 2, 'the two dialed counters are lost with the battle');
+ assert.equal(territoryForces(done, fixture.defender, fixture.territory), 0);
+ assert.equal(seat(done, fixture.attacker).tanks, 1);
+ assert.equal(discardCount(done, HARASS_CARD), 1);
+});