diff --git a/README.md b/README.md
index 7e509d11..9917c7e8 100644
--- a/README.md
+++ b/README.md
@@ -245,8 +245,12 @@ with the existing `--db`, `--room`, `--version` and private `--out` arguments
on a fresh ready2..6-seat Advanced lobby: native Ecaz with classic, Ixian,
Tleilaxu and CHOAM opponents, optional native Moritani. Select distinct `ecaz`
and each native `ix`/`choam` family: ordinary33/47/35 cards retain original
-offers/HMS/Auditor/Face Dancers. Richese mixed planning, three-card variant
-and other overlays remain unfinished. The selected lead owns leaders/cards/
+offers/HMS/Auditor/Face Dancers. Explicit `--ecaz-treachery` independently adds
+all three physical cards before setup, making36/50/38. Harass returns only
+card-user undialed forces while preserving original fixed commitment;
+Reinforcements costs three own reserves under its documented provisional policy.
+Richese mixed planning and other overlays remain unfinished.
+The selected lead owns leaders/cards/
payment; total dial includes mandatory free `ceil(E/2)` plus native allied
strength. Karama switches to the selected lead's own army, other ally zero.
Whole participating armies must be clear and connected; BG's voluntary fighter
diff --git a/components/harass-withdraw.tsx b/components/harass-withdraw.tsx
index 12d8c557..8622e5b6 100644
--- a/components/harass-withdraw.tsx
+++ b/components/harass-withdraw.tsx
@@ -51,6 +51,14 @@ export function HarassWithdrawGuide({
className="notice space-y-2"
>
Harass & Withdraw
+ {preview.occupy &&
+ Occupy returns only the card user’s own undialed fighters, not their
+ ally’s army. {preview.occupy.cardUser === preview.occupy.ecaz && !preview.occupy.canceled
+ ? `The original ${preview.occupy.fixedEcazDial} fixed Ecaz fighters remain committed at free full strength; allied variable support is separate.`
+ : !preview.occupy.canceled
+ ? `Your physical commitment uses the variable dial after subtracting ${preview.occupy.fixedEcazDial} fixed Ecaz strength.`
+ : 'With Occupy canceled, use only your selected lead’s own native army.'}
+
}
{preview.blocked || state.blocked ? (
) : state.returned ? (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 14b37eac..292e0283 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -25,6 +25,24 @@ authorization and deployment/publication safeguards remain intact. Use the
[bounded context index](DEVELOPMENT.md#context-index) and
[feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule).
+**Independent cards + Advanced Occupy — bounded Development Verified:** fresh
+explicit CLI `--ecaz-treachery` adds all three physical cards36/50/38/50.
+Own Ecaz Harass returns only its floor count while original mandatory ceil
+stays fixed/free; allied/opposing/canceled typed pools stay owner-labelled.
+Reinforcements retains provisional own3-reserve/+2-score accounting.
+Native/pure67, affected284/284 and27 actual programs/four policy seals pass.
+Current46 complete games:26,619 accepted/no rejects/696JSON continuations,
+source unchanged and9 combined lead choices. Full counters correct earlier
+compact-summary misreading: prior five/23-game batches had1/3 choices.
+Original CLI/human390px **T8T3ZYWH v12** and **WW54BZGM v12** prove own
+withdrawal/fixed loss and decisive +2/own reserve cost through actual Collection.
+Current mandatory-discard owner/selected-commitment blockers are fixed; Basic/
+unprofiled co-side guard stays. Final scoped reviews find no defect.
+See [combined-card source and proof](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026).
+Final types/lint, canonical guide10/10, build,390px bounded guide and actual
+explicit-variant CLI help pass; ten changed-region local links/anchors are valid.
+Owned drivers/tabs/workers are removed/closed without resetting games.
+
**Native E1/E2 Ecaz Occupy — bounded Development Verified:** existing profile
now composes Ixians/Tleilaxu/CHOAM with Ecaz/classic/optional Moritani2..6 and
distinct selected E3/E1/E2 ordinary33/47/35 decks. Physical Ix owns native
@@ -33,8 +51,8 @@ support shares exclude free fixed Ecaz strength. External FaceDance returns
both winning armies with combined maximum; own-winning Tleilaxu is excluded.
Native34, affected191/191, types/lint and19 actual program cases pass;23
unstaged games complete13,004 accepted/no rejects/339JSON continuations,
-source unchanged. They contain no combined lead choice; native cases and
-human390px **5B4FW8D3 v12** and **UVGT4DLN v10** prove that rule path.
+source unchanged. Full `results.json` records three combined lead choices;
+human390px **5B4FW8D3 v12** and **UVGT4DLN v10** exercise explicit rule edges.
Two scoped rule reviews find no defect. Richese mixed planning, optional
overlays, full factions/public starts remain gated; assurance follows all rules.
See [native force-owner integration](ECAZ_OCCUPY_RULES.md#native-e1e2-force-owner-integration--2-october-2026).
@@ -64,8 +82,8 @@ Moritani2..6 Advanced, base33/no overlays; full Ecaz and public starts remain ga
See [the cutover and proof](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
Final coherent affected rule/guide **173/173** passes. Current-source five
unstaged2..6 games complete:2,123 accepted actions, zero rejections and54JSON
-continuations, `sourceUnchanged:true`. They contain no combined lead choice;
-the seven native cases and human rooms prove that path, not these game samples.
+continuations, `sourceUnchanged:true`. Full `results.json` records one
+combined lead choice; the seven native cases and human rooms cover exact branches.
Final types/lint, corrected guide10/10, build and390px Partial/Development
Verified guide pass; six changed-region local links/anchors are valid.
Code **e112df858a5a3d27992d22478c5c0d4f3dfac642** is pushed.
diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md
index acb5c1dc..ac773d5a 100644
--- a/docs/ECAZ_OCCUPY_RULES.md
+++ b/docs/ECAZ_OCCUPY_RULES.md
@@ -72,9 +72,9 @@ legal plans for either lead, active or canceled.
The coherent affected rule/guide batch passes **173/173**. Current-source
five unstaged2..6 games complete with2,123 accepted actions, no rejections
-and54JSON continuations, `sourceUnchanged:true`. No game sample triggers
-a combined lead choice: profile continuation is observed there, while the
-native cases and human rooms establish the actual combined rule path.
+and54JSON continuations, `sourceUnchanged:true`. Full `results.json` records
+one actual combined lead choice. Native cases and human rooms provide the
+explicit arithmetic, casualty and counter-path evidence.
### Native E1/E2 force-owner integration — 2 October 2026
@@ -100,9 +100,9 @@ Native34 rule cases, coherent affected191/191, types/lint and19 actual program
cases pass, including Ix substitution decline/prevention and zero/full whole
co-side replacement. Twenty-three unstaged classic/paired/mixed2..6 games
complete with13,004 accepted actions/no rejection and339JSON continuations,
-`sourceUnchanged:true`. They contain no combined lead choice; those samples
-prove profile continuation, not the rare rule transitions covered by native
-cases and human controls. Two scoped rule reviews find no defect.
+`sourceUnchanged:true`. Full `results.json` records three actual combined
+lead choices. Native cases and human controls additionally exercise the typed/
+replacement boundaries. Two scoped rule reviews find no defect.
Original CLI/human390px **5B4FW8D3 v12** let physical Ixians sacrifice two
Suboids to recover two Cyborgs after an Ecaz-led plan; Ecaz retained its Shield
@@ -112,6 +112,55 @@ returned Ecaz1/Guild2 to their own reserves, placed three Tleilaxu forces and
retained the original Ecaz Slip Tip and bounty. Both continue the original
phase rather than manufacturing another battle or card owner.
+### Independent cards with combined armies — 2 October 2026
+
+The existing fresh `ecaz-occupy` CLI accepts explicit `--ecaz-treachery`,
+independently selecting all three physical cards before the original setup:
+36 Ecaz-only,50 Ix,38 CHOAM,50 both. No default/public start or saved retrofit.
+
+The printed E3p16 Harass FAQ returns only the card user's own undialed
+forces. An active Ecaz card user returns `floor(original E/2)` and retains
+the original `ceil(E/2)` free/full physical commitment; no ally support is
+charged to those fixed fighters. A selected ally uses its typed variable
+commitment after subtracting fixed strength. Canceled Occupy uses the lead's
+ordinary own pool. Opposing successful Traitor cancels returns; withdrawn
+reserves are outside later loss, explosion and FaceDance replacement.
+This is a composition of the own-only FAQ and authorized Advanced arithmetic,
+not a newly retrieved detailed Ecaz casualty FAQ.
+
+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.
+
+Native/pure67 cases, relevant284-case batch and27 actual native-program
+cases pass, including all four legal policies. Two natural captured games
+exposed an existing mandatory-discard callback comparing casualties to the
+logical winner rather than physical owner. Correct owner and explicit fully
+committed Harass pool constraints now preserve the selected loss allocation.
+Both original captures continued to completion; no database reset or new
+recovery-audit campaign. Obsolete wording/log/default pins were deleted.
+Final scoped source/native reviews find no defect.
+
+Current-source46 ordinary/variant2..6 games complete26,619 accepted actions/
+no rejection/696JSON continuations, source unchanged. Full `results.json`
+records9 actual combined lead choices; compact `report.json` lacks counters.
+Original CLI/human390px **T8T3ZYWH v12** sealed Ecaz Harass total5/support2,
+returned Ecaz2 reserves, sent original fixed3 to Tanks and left Guild2.
+**WW54BZGM v12** sealed total5/support2 with Reinforcements; its +2 won the
+real equal-leader comparison, paid three Ecaz reserves, left Ecaz2/Guild2 and
+sent six total Ecaz counters to Tanks. Both reach original Collection.
+
+### Encounter evidence correction — 2 October 2026
+
+Earlier “no combined lead choice” statements were incorrect: the compact
+`report.json` deliberately omits `used` counters. The immutable full
+`results.json` records1 choice in the five-game checkpoint and3 in the
+23-game checkpoint. Neither historical report is rewritten. The current
+46-game original/variant batch records9 actual combined lead choices.
+
## Provenance and attribution correction
| Source | Observed wording or correction | Authority and conclusion |
diff --git a/docs/HARASS_WITHDRAW_RUNTIME.md b/docs/HARASS_WITHDRAW_RUNTIME.md
index c54e9936..249636fa 100644
--- a/docs/HARASS_WITHDRAW_RUNTIME.md
+++ b/docs/HARASS_WITHDRAW_RUNTIME.md
@@ -76,8 +76,11 @@ This is a legal strategy path, not strength calibration.
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.
-- Co-present reciprocal Ecaz allied armies on either side reject before
- sealing; other faction integration gates remain in force. This prototype does not settle Ecaz's undialed allied support allocation.
+- 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
+ selected ally's native variable commitment. It never returns the other ally
+ or recomputes mandatory fixed losses from the diminished board.
- [Bounded Reinforcements](REINFORCEMENTS_RUNTIME.md) can appear in the
opposing Battle Plan in classic-faction Basic/Advanced games. The two cards
cannot share one player's plan; their relative same-plan timing remains
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 0d3cbed6..63639381 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,58 @@
# Dune implementation status
+## 2 October 2026 — independent Treachery cards with Advanced Occupy
+
+Existing `ecaz-occupy` now accepts explicit fresh CLI `--ecaz-treachery`,
+independently including all three original cards:36 Ecaz-only,50 Ix,38 CHOAM,
+50 both. Native original setup/owned cards are retained; no public/default start
+or started-game retrofit. Basic/unprofiled co-side card battles stay guarded.
+
+Three bounded owners shared owner-labelled Harass total-dial normalization,
+actual native reveal/outcome integration and original-source cases. Own Ecaz
+Harass returns its floor count while original mandatory ceil remains fixed/free;
+selected ally uses typed total-minus-fixed commitment. Opponent/canceled
+ordinary pools stay separate. Returned reserves remain outside later losses/
+explosion/FaceDance. Reinforcements preserves its existing provisional own
+three-reserve normal-first cost and +2 score-only policy on every revealed
+outcome, not a new publisher interpretation or ally reserve charge.
+
+Exact first own-Ecaz Harass/Reinforcements cases and types/lint passed.
+Full-case/native natural play then exposed the mandatory-discard callback's
+logical-winner/physical-owner mismatch. Explicit fullyCommitted Harass facts
+also preserve exact selected physical losses instead of unconstrained
+re-enumeration. Both captured original games now complete after the fix.
+Independent review additionally restored the outside-profile co-side guard;
+native before/after reproduction proved Basic accepted before and rejects now.
+Current regression checks exercise both blockers; old wording/log/default pins
+were removed instead of re-pinned. Native counter precedence in the advisor
+fixture was corrected without forcing a reaction or suppressing its window.
+
+Pure/native67, coherent affected284/284, types/lint and27 actual native
+program cases pass, including all four real card policy seals. Current46
+ordinary/variant2..6 games complete26,619 accepted/no rejects/696JSON
+continuations, source unchanged, with nine actual combined lead choices.
+Earlier “no combined lead choice” statements were projection-reading errors:
+compact `report.json` omits counters; immutable full `results.json` records
+one in the five-game and three in the23-game checkpoint. Those reports remain
+unchanged; current guidance corrects the claims.
+
+Original CLI/human390px **T8T3ZYWH v12** used Ecaz Harass total5/support2:
+Ecaz2 returned to reserves, original fixed3 went to Tanks, Guild2 remained.
+**WW54BZGM v12** used equal real leaders and total5/support2 Reinforcements;
+its +2 won, three own reserves were spent, Ecaz2/Guild2 remained and six total
+Ecaz counters entered Tanks. Both reached original Collection.
+
+Final `check:quick` types/lint, canonical guide10/10, build and390px
+Partial / Development: Verified guide pass. Ten changed-region local links/
+anchors and actual variant CLI help are valid. Both fresh36-card CLI starts
+were exercised. Owned three native/diagnostic drivers are removed; three
+managed human/guide tabs and both isolated workers are closed.
+
+Final source/native scoped reviews find no defect. No new recovery/privacy/
+custody/seat/getter/wholeDB/HTTP/full6,500-suite assurance campaign; direct
+current playability blockers were fixed. Full rules/factions/combined modes,
+later comprehensive review/assurance and deployment remain unfinished.
+
## 2 October 2026 — native Ixian, Tleilaxu and CHOAM Ecaz Occupy integration
The existing Advanced `ecaz-occupy` profile now admits source-selected native
@@ -25,9 +78,9 @@ traitor CHOAM payments, and zero/full whole-co-side replacement.
Twenty-three unstaged classic/paired/mixed2..6 games complete with13,004
accepted actions/no rejection and339JSON continuations, `sourceUnchanged:true`.
-Exact family metadata33/47/35 matches selected decks. No sample reaches a
-combined lead choice; native programs/meaningful cases and human rooms establish
-that rare rule path, while game samples prove profile continuation.
+Exact family metadata33/47/35 matches selected decks. Full `results.json`
+records three actual combined lead choices; native programs/meaningful cases
+and human rooms additionally establish the exact typed/replacement boundaries.
Original CLI/human390px **5B4FW8D3 v12** let actual Ixians exchange two
surviving Suboids for two lost Cyborgs after Ecaz formed the plan. Ecaz retained
@@ -86,9 +139,9 @@ with exactly1 Ecaz support debit. Both resumed original Collection.
Final coherent affected rule/guide **173/173** passes. The current-rule five
unstaged2..6 games complete with2,123 accepted actions/no rejections and54JSON
-continuations, `sourceUnchanged:true`, ordinary33/E3 report metadata. No sample
-uses a combined lead choice; those games prove profile continuation, while
-named actual native cases and human rooms prove the rare combined battle path.
+continuations, `sourceUnchanged:true`, ordinary33/E3 report metadata. Full
+`results.json` records one actual combined lead choice; named native cases
+and human rooms additionally establish the exact arithmetic and counter paths.
The earlier pre-storm-fix report is preserved as different-source evidence.
Final `check:quick` types/lint, corrected canonical guide10/10 and build pass.
diff --git a/docs/REINFORCEMENTS_RUNTIME.md b/docs/REINFORCEMENTS_RUNTIME.md
index c0c6d0d0..b8170176 100644
--- a/docs/REINFORCEMENTS_RUNTIME.md
+++ b/docs/REINFORCEMENTS_RUNTIME.md
@@ -3,13 +3,19 @@
The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements
card independently of choosing the Ecaz faction. Its bounded handler supports
Basic or Advanced classic-faction tables and clean Ecaz/Moritani expansion
-tables without other optional modules or co-present Ecaz allied armies. The
+tables without other optional modules. The fresh opted-in Advanced Occupy
+profile additionally composes co-present armies and native Ixian/Tleilaxu/CHOAM
+families; Basic/unprofiled co-side use stays guarded. The
holder may commit it in either Battle Plan card slot. It is neither a weapon
nor a defense: Prescience of a category receives **None**, while the physical
slot stays occupied and hidden until reveal. A named-card inspection still
fixes the exact card. Harass & Withdraw and Stone Burner cannot share this
plan; Homeworld and other optional-module combinations remain gated.
+The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
+retains own-holder reserve cost and selected lead card/payment ownership.
+It does not revise the provisional outcome accounting below.
+
The holder must have three own reserve counters when sealing the plan. The owner-only battle offer displays availability and the exact provisional cost. The current prototype deterministically takes ordinary reserves first, then elite reserves if needed, transferring precisely three physical counters to the corresponding Tanks subpools when the battle settles. There is no spice payment or on-board arrival. The +2 changes the calculated normal-outcome battle score, **not** the committed physical dial, support payment, casualty count or leader survival. The card is discarded after any revealed result, including an own, opposing or mutual Traitor call and Lasgun/Shield explosion. The cost is likewise paid on each of those outcomes. A successful Traitor call still supersedes the normal score; the modifier never overrides it. Public history records the transfer; the sealed card and owner reserve mix stay private until the reveal. JSON-restored decisions and transactional room updates cannot charge the same cost twice.
**Provisional interpretations, not publisher rulings:** The card says “increase the dialed number by 2” and transfer three reserves, but no retrieved combined ruling answers whether a sole successful Traitor caller instead keeps their reserves, how the transfer orders against an explosion, whether +2 adds Advanced support or physical losses, which reserve types the holder may select, or whether the three spent reserves count as battle losses for Atreides. For this bounded prototype, payment is at battle settlement before on-board casualties on every outcome; the engine counts the three as battle losses, chooses normal-first rather than offering a type selection, and keeps the score bonus separate from physical dial. [The source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) records the competing reading and pending user question. These choices are visible so they can be revised without changing prior hands or inventing hidden information.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 1e40fcc0..13979e47 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -111,6 +111,13 @@ donor income and whole Ecaz co-side FaceDance return with original lead rewards.
Exact selected E3/E1/E2 decks retain33/47/35 cards; Richese mixed planning,
other overlays and full Ecaz acceptance remain open. See the
[source comparison and exact cutover](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026).
+Explicit `--ecaz-treachery` selects the independent three-card variant before
+the original setup, adding3 to each family deck. The printed own-only Harass
+FAQ composes with original fixed Ecaz commitment or selected ally variable
+forces; returned reserves are outside later loss/FaceDance. Reinforcements
+keeps its existing provisional own-reserve cost/+2-score policy rather than
+claiming a new outcome ruling. Basic/unprofiled co-side card use remains guarded.
+See [combined-card boundaries](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026).
Comprehensive recovery/privacy/custody review follows all rules rather than
becoming another prototype assurance wave.
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index 07a442f2..fbfb47df 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -176,6 +176,8 @@ export type BattleResolutionQuote = {
sandmaster?: string;
casualties: {
owner?: string;
+ /** The returned Harass complement fixes all remaining physical fighters. */
+ fullyCommitted?: true;
forces: CombatForces;
dial: number;
support: number;
@@ -419,8 +421,23 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
'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(JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
+ const profile = input.ecazOccupy?.lead === side.id ? input.ecazOccupy : undefined;
+ const normalization = side.harassWithdraw.occupy;
+ if (profile) {
+ const ownForces = side.id === profile.ecaz ? profile.ecazForces : profile.allyForces;
+ requireQuote(normalization &&
+ normalization.cardUser === side.id && normalization.ecaz === profile.ecaz &&
+ normalization.ally === profile.ally && normalization.canceled === profile.canceled &&
+ normalization.fixedEcazDial === profile.fixedEcazDial &&
+ JSON.stringify(normalization.variableForces) === JSON.stringify(profile.forces) &&
+ JSON.stringify(side.forces) === JSON.stringify(profile.forces) &&
+ JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(ownForces),
+ 'The Occupy withdrawal context must identify the card user’s own counters and the original mandatory Ecaz commitment.');
+ } else {
+ requireQuote(!normalization &&
+ JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
'The withdrawal context must match the current battle force roles.');
+ }
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support, side.harassSelection));
}
}
@@ -724,6 +741,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
dial: winner.plan.dial,
support: winner.plan.support,
options,
+ ...(withdrawal ? { fullyCommitted: true as const } : {}),
};
} else {
requireQuote(
@@ -746,7 +764,18 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
...destroyedArmies.filter(owner => owner !== profile.ecaz && owner !== profile.ally),
...occupyOutcome.destroyedArmies,
])];
- if (winner?.id === profile.lead) casualties = occupyOutcome.casualties;
+ if (winner?.id === profile.lead) {
+ casualties = occupyOutcome.casualties;
+ // The outcome already holds the original fixed Ecaz commitment. Only a
+ // withdrawal by the variable army's owner can constrain its typed losses.
+ const withdrawal = harassWithdraw.find(entry => entry.player === casualties?.owner);
+ if (casualties && withdrawal) {
+ const options = casualties.options.filter(option =>
+ option.normal === withdrawal.remaining.normal && option.elite === withdrawal.remaining.elite);
+ requireQuote(options.length > 0, 'The Occupy winner lost its selected physical Harass commitment.');
+ casualties = { ...casualties, forces: withdrawal.remaining, options, fullyCommitted: true };
+ }
+ }
}
const strongholdIncome =
result === 'mutualTraitors'
diff --git a/game/engine.ts b/game/engine.ts
index be3cdeaf..716968ce 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1425,6 +1425,8 @@ export type Game = {
cards: string[];
casualties?: {
owner?: string;
+ /** Harass has already returned the complement of this physical pool. */
+ fullyCommitted?: true;
forces: CombatForces;
dial: number;
support: number;
@@ -6451,13 +6453,18 @@ function treacheryDiscardIntegrity(g: Game) {
'The mandatory battle discard cause is inconsistent.',
);
if (c.casualties) {
- const { forces, dial, support, options } = c.casualties;
+ const { forces, dial, support, options, fullyCommitted } = c.casualties;
const winner = g.players.find((p) => p.id === c.winner);
- const pool = winner ? combatArmy(g, winner.id, c.territory) : { normal: 0, elite: 0 };
+ const forceOwner = g.players.find((p) => p.id === (c.casualties!.owner ?? c.winner));
+ const pool = forceOwner ? combatArmy(g, forceOwner.id, c.territory) : { normal: 0, elite: 0 };
const total = pool.normal + pool.elite;
const elites = pool.elite;
requireRule(
- winner &&
+ winner && forceOwner &&
+ (forceOwner.id === winner.id || (forceOwner.ally === winner.id &&
+ winner.ally === forceOwner.id &&
+ (winner.faction === 'ecaz' || forceOwner.faction === 'ecaz'))) &&
+ (fullyCommitted === undefined || fullyCommitted === true) &&
forces &&
validCombatForces(forces) &&
Number.isSafeInteger(forces.normal) &&
@@ -6475,7 +6482,10 @@ function treacheryDiscardIntegrity(g: Game) {
Array.isArray(options) &&
options.length > 0 &&
JSON.stringify(options) ===
- JSON.stringify(casualtyOptions(forces, dial, support)),
+ JSON.stringify(fullyCommitted
+ ? casualtyOptions(forces, dial, support).filter(option =>
+ option.normal === forces.normal && option.elite === forces.elite)
+ : casualtyOptions(forces, dial, support)),
'The saved battle casualties do not match the committed dial, support and effective forces.',
);
}
@@ -9129,14 +9139,16 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.expansions.includes(faction(p.faction).expansion))) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
!g.techTokens && !g.discoveryEnabled && !g.discoveries &&
- !g.discoveryStash && !g.greatMaker && !g.ecazTreachery;
+ !g.discoveryStash && !g.greatMaker &&
+ (g.ecazTreachery === undefined || g.ecazTreachery === true);
}
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
- 'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese, optional modules or the independent Ecaz Treachery variant.');
+ 'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese or optional overlays.');
requireFreshBaseRuntime(state);
- const g = initializeFactionExpansionsGameForAudit(state);
+ requireFreshFactionInventory(state);
+ const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
g.ecazOccupyPreview = true;
return g;
}
@@ -11929,30 +11941,33 @@ function recruitsModeSupported(g: Game) {
!g.discoveryEnabled
);
}
-function ecazAllyAtBattle(g: Game, side: Player, territoryId: string) {
- const ally = g.players.find(player => player.id === side.ally && player.ally === side.id);
- return !!ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') &&
- Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === territoryId);
-}
+function combinedCardBattleBlock(g: Game): string | null {
+ const battle = g.battle;
+ if (!battle || battle.ecazOccupy) return null;
+ const ecaz = byFaction(g, 'ecaz');
+ const ally = ecaz && g.players.find(player => player.id === ecaz.ally && player.ally === ecaz.id);
+ if (ecaz && ally &&
+ (battle.attacker === ecaz.id || battle.defender === ecaz.id ||
+ battle.attacker === ally.id || battle.defender === ally.id) &&
+ fighterCount(ecaz, battle.territory) > 0 && fighterCount(ally, battle.territory) > 0)
+ return 'Combined card armies require the supported Advanced Occupy battle profile.';
+ return null;
+}
function reinforcementsModeSupported(g: Game) {
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
- (g.expansions.length === 0 || pairedEcaz) &&
- g.players.every(player => {
- const expansion = faction(player.faction).expansion;
- return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
- });
+ ((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
+ ((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
+ const expansion = faction(player.faction).expansion;
+ return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
+ })));
}
function currentReinforcementsCost(g: Game, p: Player) {
requireRule(reinforcementsModeSupported(g),
- 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.');
- if (g.battle) {
- const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker);
- requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) &&
- !ecazAllyAtBattle(g, other, g.battle.territory),
- 'Reinforcements with combined Occupy armies needs its additional-card source ruling.');
- }
+ 'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.');
+ const combinedBlock = combinedCardBattleBlock(g);
+ requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.');
try {
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
} catch (error) {
@@ -11966,14 +11981,14 @@ function reinforcementsPreview(g: Game, p: Player) {
!p.hand.some(isReinforcements)) return null;
const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0)));
const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0));
+ const combinedBlock = combinedCardBattleBlock(g);
let blocked: string | null = null;
if (!reinforcementsModeSupported(g))
- blocked = 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.';
+ blocked = 'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.';
else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id])
blocked = 'Choose Reinforcements before sealing your Battle Plan.';
- else if (ecazAllyAtBattle(g, p, battle.territory) ||
- ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory))
- blocked = 'Reinforcements with combined Occupy armies needs its additional-card source ruling.';
+ else if (combinedBlock)
+ blocked = combinedBlock;
else if (normal + elite < 3)
blocked = 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite };
@@ -18085,6 +18100,7 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
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))
@@ -18093,14 +18109,22 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
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 (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory))
- blocked = 'Harass & Withdraw with combined Occupy armies needs its additional-card source ruling.';
+ else if (combinedBlock)
+ blocked = combinedBlock;
else if (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)
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
- return { blocked, forces: combatForces(g, p, b.territory, other), locations };
+ const profile = ecazBattleProfile(g);
+ const forces = profile?.lead === p.id
+ ? p.id === profile.ecaz ? profile.ecazForces : profile.allyForces
+ : combatForces(g, p, b.territory, profile?.lead === other.id ? getPlayer(g, profile.forceOwner) : other);
+ return { blocked, forces, locations,
+ ...(profile?.lead === p.id ? { occupy: {
+ cardUser: p.id, ecaz: profile.ecaz, ally: profile.ally, canceled: profile.canceled,
+ fixedEcazDial: profile.fixedEcazDial, variableForces: profile.forces,
+ } } : {}) };
}
function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick) {
try {
@@ -20320,6 +20344,8 @@ function advanceBattleOutcome(g: Game) {
function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
const b = g.battle!;
requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.');
+ // Quote before any withdrawal: fixedLosses retains the original ceil(E/2)
+ // physical commitment, never one recalculated from Ecaz's diminished board.
const quote = currentBattleResolutionQuote(g);
const a = getPlayer(g, b.attacker),
d = getPlayer(g, b.defender),
diff --git a/game/harass-withdraw.ts b/game/harass-withdraw.ts
index e4f22676..bab3c368 100644
--- a/game/harass-withdraw.ts
+++ b/game/harass-withdraw.ts
@@ -11,10 +11,22 @@ export type HarassWithdrawForces = Readonly<{ normal: number; elite: number }>;
export type HarassWithdrawSelection = Readonly<
Record
>;
+/** Owner-labelled facts from the selected Occupy plan, not a merged army.
+ * fixedEcazDial is the original mandatory commitment, captured before return. */
+export type HarassWithdrawOccupyContext = Readonly<{
+ cardUser: string;
+ ecaz: string;
+ ally: string;
+ canceled: boolean;
+ fixedEcazDial: number;
+ variableForces: CombatForces;
+}>;
export type HarassWithdrawContext = Readonly<{
blocked: string | null;
forces: CombatForces;
locations: Readonly>;
+ /** Absent for ordinary battles and for the Occupy opponent. */
+ occupy?: HarassWithdrawOccupyContext;
}>;
export type HarassWithdrawPreview = HarassWithdrawContext &
Readonly<{
@@ -90,6 +102,88 @@ function validateContext(context: HarassWithdrawContext) {
);
return locations;
}
+function sameForcePool(a: CombatForces, b: CombatForces): boolean {
+ return (
+ a.normal === b.normal &&
+ a.elite === b.elite &&
+ a.eliteStrength === b.eliteStrength &&
+ a.freeSupport === b.freeSupport &&
+ !!a.normalFixedHalf === !!b.normalFixedHalf &&
+ !!a.normalFreeSupport === !!b.normalFreeSupport &&
+ !!a.eliteFreeSupport === !!b.eliteFreeSupport &&
+ (a.temporaryElite ?? 0) === (b.temporaryElite ?? 0)
+ );
+}
+function ownCommitments(
+ context: HarassWithdrawContext,
+ dial: number,
+ support: number,
+): HarassWithdrawForces[] {
+ const occupy = context.occupy;
+ if (occupy === undefined) return casualtyOptions(context.forces, dial, support);
+ requireHarass(
+ record(occupy) &&
+ [occupy.cardUser, occupy.ecaz, occupy.ally].every(
+ (id) => typeof id === 'string' && id.trim().length > 0,
+ ) &&
+ occupy.ecaz !== occupy.ally &&
+ [occupy.ecaz, occupy.ally].includes(occupy.cardUser) &&
+ typeof occupy.canceled === 'boolean' &&
+ Number.isSafeInteger(occupy.fixedEcazDial) &&
+ validCombatForces(occupy.variableForces),
+ 'Harass & Withdraw needs the selected Occupy card user and native force owners.',
+ );
+ if (occupy.canceled) {
+ requireHarass(
+ occupy.fixedEcazDial === 0 &&
+ sameForcePool(context.forces, occupy.variableForces),
+ 'Canceled Occupy uses only the card user’s normal battle pool, with no fixed contribution.',
+ );
+ return casualtyOptions(context.forces, dial, support);
+ }
+ requireHarass(
+ occupy.fixedEcazDial > 0 && occupy.fixedEcazDial <= 10,
+ 'Active Occupy needs its original mandatory Ecaz commitment.',
+ );
+ const variable = casualtyOptions(
+ occupy.variableForces,
+ dial - occupy.fixedEcazDial,
+ support,
+ );
+ requireHarass(
+ variable.length > 0,
+ 'The total Occupy dial and spice must match the ally’s native variable commitment.',
+ );
+ if (occupy.cardUser === occupy.ally) {
+ requireHarass(
+ sameForcePool(context.forces, occupy.variableForces),
+ 'Harass & Withdraw returns only the ally card user’s own variable army.',
+ );
+ return variable;
+ }
+ requireHarass(
+ context.forces.elite === 0 &&
+ !context.forces.normalFixedHalf &&
+ context.forces.normal >= occupy.fixedEcazDial,
+ 'Harass & Withdraw needs the Ecaz card user’s own ordinary mandatory fighters.',
+ );
+ // The selected lead's spice supports the ally, never these fully supported
+ // free Ecaz counters. Do not recompute ceil(E/2) after a physical return.
+ return [{ normal: occupy.fixedEcazDial, elite: 0 }];
+}
+function committedForces(
+ context: HarassWithdrawContext,
+ committed: HarassWithdrawForces,
+): CombatForces {
+ const occupy = context.occupy;
+ return {
+ ...context.forces,
+ ...committed,
+ ...(occupy && !occupy.canceled && occupy.cardUser === occupy.ecaz
+ ? { freeSupport: true }
+ : {}),
+ };
+}
/** Distinct physical dial commitments; equivalent support allocations are not extra choices. */
export function harassWithdrawCommitments(
context: HarassWithdrawContext,
@@ -97,7 +191,7 @@ export function harassWithdrawCommitments(
support: number,
): { normal: number; elite: number }[] {
validateContext(context);
- const choices = casualtyOptions(context.forces, dial, support);
+ const choices = ownCommitments(context, dial, support);
requireHarass(
choices.length > 0,
'Dial and spice must match a legal physical force commitment.',
@@ -217,7 +311,7 @@ export function quoteHarassWithdraw(
);
return {
returned,
- remaining: { ...forces, ...committed },
+ remaining: committedForces(context, committed),
locations: Object.fromEntries(
entries
.filter(([, group]) => group.normal || group.elite)
@@ -263,7 +357,7 @@ export function quoteHarassWithdraw(
}
return {
returned,
- remaining: { ...forces, normal: committed.normal, elite: committed.elite },
+ remaining: committedForces(context, committed),
locations: Object.fromEntries(allocation),
};
}
diff --git a/game/reference.ts b/game/reference.ts
index 93787e67..6fceb01b 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1265,14 +1265,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
'A Tleilaxu winning co-side cannot Face Dance itself. External Face Dance returns both actual winning allied armies with a combined survivor maximum, while the selected winner keeps original rewards and card cleanup. Reserve/board zero/partial/full replacements remain native choices, not a copied player.',
- 'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with distinct required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Richese mixed planning, independent E3 variant and other overlays remain open; full Ecaz and public starts remain gated.',
+ 'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
+ 'Reinforcements consumes three card-user reserves normal-first and adds +2 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
+ 'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Explicit --ecaz-treachery independently adds all three cards for36/50/38; Richese mixed planning and other overlays remain open. Full factions/public starts stay gated.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response and owner-labelled physical dial/outcome integration are implemented. Optional/exotic compositions remain open.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Original human390px rooms exercise mandatory/free total dials, native Karama own-army redial, physical Ix two-Suboid/two-Cyborg exchange after an Ecaz plan, and full three-force FaceDance replacement across both winning armies. Selected lead retains its cards and native Collection continuation.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic/cancellation, unchanged Basic and specific Duke override are separated from historical source gaps and optional-module gates.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
- { area: 'Verification', status: 'Partial', detail: 'Development Verified: first source arithmetic/native program, expanded native34 cases and19 actual programs, relevant191-case batch, four policies and original390px human aftermath. Twenty-three unstaged2..6 classic/paired/mixed games complete13004 accepted actions/no rejection; none reaches combined lead choice, so native cases/human rooms prove that path. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/ecaz-occupy-options.test.ts', 'tests/ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Development Verified: original arithmetic/native force integration, independent-card native/pure67 cases, relevant284-case batch,27 actual programs/four policies and original390px human Harass/Reinforcements. Current46 ordinary/variant2..6 games complete26619 accepted actions/no rejection with nine actual combined lead choices in full results. Prior five/23-game full counters record one/three, correcting compact-summary misreading. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-cards-runtime.test.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
@@ -4587,21 +4589,21 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Reinforcements',
category: 'Cards',
coverage: 'Partial',
- developmentStage: 'Prototyped',
+ developmentStage: 'Verified',
summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.',
steps: [
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
- 'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction and paired Ecaz/Moritani prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.',
+ 'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
- 'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner, co-present Ecaz allied armies, other optional modules and other expansion pairings remain unavailable in this bounded prototype.',
+ 'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. The explicit Advanced Occupy/independent-card profile is verified through native and human play; Basic/unprofiled co-side, own same-plan Harass/Reinforcements, Stone Burner and other optional modules remain guarded.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic or paired Ecaz/Moritani Basic/Advanced games.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and single mandatory discard connect in classic/paired modes and explicit native Advanced Occupy. Selected lead cards/payment and variable ally casualties stay separate.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Rule, paired-faction engine/controls and saved concurrent recovery cases cover bounded behavior; co-present Ecaz allied armies, the full card variant and combined optional modules remain uncertified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Existing rule/control proofs plus combined-card67 cases, actual27-case program, four policies, natural combined games and390px decisive equal-leader Reinforcements cover the bounded profile. Full faction/optional-module acceptance remains open; comprehensive assurance follows all rules.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
},
{
@@ -4617,7 +4619,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
- 'With Ecaz and an ally together, only the card user’s undialed forces return. Combined army attribution remains unfinished. Public variant starts and complete rules acceptance remain gated.',
+ '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: [
@@ -4639,7 +4641,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
- 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic or Ecaz/Moritani games.',
+ 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck; Ix or combined decks contain47. Ecaz faction selection adds no cards. Explicit independent variant adds Recruits/Harass/Reinforcements for36/50/38, with classic/paired controls and a separately verified native Advanced Occupy combined-army path.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Fresh unsupported movement arrivals still reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. The bounded Ecaz/Moritani same-entry Ambassador/Terror overlap instead snapshots both eligible owners in storm order without replaying the original move.',
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent when its still-gated arrival cannot commit; older No-Field, Smuggler, Nexus and Ambassador shipments retain their separate recovery boundaries. Classic plus Ecaz/Moritani Ambassador/Terror overlap has a provisional saved storm-order sequence, while other simultaneous priorities remain unfinished.',
diff --git a/tests/ecaz-occupy-cards-runtime.test.ts b/tests/ecaz-occupy-cards-runtime.test.ts
new file mode 100644
index 00000000..5ec4d177
--- /dev/null
+++ b/tests/ecaz-occupy-cards-runtime.test.ts
@@ -0,0 +1,377 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { treacheryDeck } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { splitLocation } from '../game/board';
+import {
+ createEcazOccupySetup, ecazOccupyTerritoryForces, type EcazOccupyFixtureOptions,
+} from './fixture-ecaz-occupy';
+import {
+ ecazOccupyCardsCase, finishOccupyCardsAftermath, occupyCardsPlayer as seat,
+ type OccupyCardsCase,
+} from './fixture-ecaz-occupy-cards';
+import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
+
+const forces = (result: OccupyCardsCase, id: string, game = result.game) =>
+ ecazOccupyTerritoryForces(game, id, result.fixture.territory);
+const reserveChange = (result: OccupyCardsCase, id: string) =>
+ seat(result.game, id).reserves - seat(result.before, id).reserves;
+const tankChange = (result: OccupyCardsCase, id: string) =>
+ seat(result.game, id).tanks - seat(result.before, id).tanks;
+
+function physicalSource(game: Game, count: number): void {
+ const source = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
+ ...game.players.flatMap(p => p.hand)];
+ assert.equal(source.length, count);
+ assert.deepEqual(source.map(c => c.id).sort(),
+ [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
+ .map(c => c.id).sort(), 'The admitted original family retains its canonical physical faces, including pending Ix offers.');
+}
+function discardedOnce(result: OccupyCardsCase): void {
+ assert.equal(result.game.discard.filter(card => card.id === result.card).length, 1);
+ assert.ok(!seat(result.game, result.holder).hand.some(card => card.id === result.card));
+}
+function collection(state: Game): Game {
+ let game = state;
+ for (let attempt = 0; attempt < 40 && game.phase === 6; attempt++) {
+ const player = game.players.find(p => !game.ready.includes(p.id));
+ assert.ok(player, 'The actual Battle phase must offer its native continuation.');
+ game = applyAction(game, player.id, { type: 'ready' });
+ }
+ assert.equal(game.phase, 7, 'The original battle continues to native Spice Collection.');
+ return game;
+}
+function pendingIxSetup(state: Game): Game {
+ let game = state;
+ for (let attempt = 0; attempt < 100 && game.decision?.kind !== 'ixSetup'; attempt++) {
+ assert.equal(game.status, 'setup', 'Stop at the original starting-card offer, never redeal.');
+ let next: Game | undefined;
+ for (const player of game.players) {
+ const view = viewGame(game, player.id);
+ view.players.find(p => p.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) { next = applyAction(game, player.id, action); break; }
+ }
+ assert.ok(next, 'Original faction setup must expose an actual native choice.');
+ game = next;
+ }
+ assert.equal(game.decision?.kind, 'ixSetup');
+ assert.ok(game.ixSetupCards?.length, 'The physical starting cards are still in the pending offer.');
+ return game;
+}
+
+const sources: { name: string; count: number; options: EcazOccupyFixtureOptions }[] = [
+ { name: 'E3', count: 36, options: { allyFaction: 'guild', opponentFaction: 'emperor', expansions: ['ecaz'] } },
+ { name: 'Ix', count: 50, options: { allyFaction: 'ixians', opponentFaction: 'emperor', expansions: ['ecaz', 'ix'] } },
+ { name: 'CHOAM', count: 38, options: { allyFaction: 'choam', opponentFaction: 'emperor', expansions: ['ecaz', 'choam'] } },
+ { name: 'Ix and CHOAM', count: 50, options: { allyFaction: 'ixians', opponentFaction: 'choam', expansions: ['ecaz', 'ix', 'choam'] } },
+];
+for (const source of sources) {
+ void test(`Independent cards preserve the original ${source.name} physical source through native setup and battle`, () => {
+ let initial = createEcazOccupySetup({ ...source.options, ecazTreachery: true });
+ physicalSource(initial, source.count);
+ if (source.options.allyFaction === 'ixians') initial = pendingIxSetup(initial);
+ physicalSource(initial, source.count);
+ const result = ecazOccupyCardsCase({ ...source.options, initial,
+ card: 'ecaz-reinforcements', variableDial: 0, support: 0 });
+ physicalSource(result.fixture.afterSetup, source.count);
+ physicalSource(result.game, source.count);
+ assert.equal(result.game.lastBattleContext?.winner, result.actor);
+ discardedOnce(result);
+ collection(result.game);
+ });
+}
+
+for (const lead of ['ecaz', 'ally'] as const) for (const slot of ['weapon', 'defense'] as const) {
+ void test(`Reinforcements in ${slot}, ${lead} lead: own three reserves buy the decisive +2, not ally support or arrivals`, () => {
+ const result = ecazOccupyCardsCase({ lead, slot, card: 'ecaz-reinforcements',
+ equalLeaders: true, opponentDial: 6 });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor,
+ 'Equal leaders: five physical dial strength beats six only with the card’s +2.');
+ assert.equal(reserveChange(result, actor), -3);
+ assert.equal(tankChange(result, fixture.ecaz), lead === 'ecaz' ? 6 : 3);
+ assert.equal(tankChange(result, fixture.ally), lead === 'ally' ? 5 : 2);
+ assert.equal(reserveChange(result, lead === 'ecaz' ? fixture.ally : fixture.ecaz), 0);
+ assert.equal(forces(result, fixture.ecaz), 2);
+ assert.equal(forces(result, fixture.ally), 2);
+ assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
+ 'Three free fixed Ecaz fighters and the score modifier incur no additional support.');
+ discardedOnce(result);
+ collection(game);
+ });
+ void test(`Harass in ${slot}, ${lead} lead: only actual owner undialed forces return, original fixed loss stays three`, () => {
+ const result = ecazOccupyCardsCase({ lead, slot });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(reserveChange(result, actor), 2);
+ assert.equal(reserveChange(result, lead === 'ecaz' ? fixture.ally : fixture.ecaz), 0);
+ assert.equal(tankChange(result, fixture.ecaz), 3);
+ assert.equal(tankChange(result, fixture.ally), 2);
+ assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 0 : 2);
+ assert.equal(forces(result, fixture.ally), lead === 'ally' ? 0 : 2);
+ assert.equal(seat(before, actor).spice - seat(game, actor).spice, 2,
+ 'Support remains the selected payer’s variable ally payment, never an Ecaz fixed-pool charge.');
+ discardedOnce(result);
+ collection(game);
+ });
+}
+
+for (const lead of ['ecaz', 'ally'] as const) {
+ for (const outcome of ['loss', 'soleTraitor', 'opposingTraitor', 'mutualTraitors', 'explosion'] as const) {
+ void test(`Harass ${lead} lead, actual ${outcome}: returns precede losses except an opposing successful traitor`, () => {
+ const result = ecazOccupyCardsCase({ lead, outcome, slot: 'defense' });
+ const { fixture, actor, game } = result;
+ const canceledReturn = outcome === 'opposingTraitor' || outcome === 'mutualTraitors';
+ assert.equal(reserveChange(result, actor), canceledReturn ? 0 : 2);
+ assert.equal(reserveChange(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), 0);
+ assert.equal(game.lastBattleContext?.result,
+ outcome === 'loss' ? 'normal' : outcome === 'soleTraitor' || outcome === 'opposingTraitor' ? 'traitor' : outcome);
+ if (outcome === 'soleTraitor') {
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(tankChange(result, fixture.ecaz), 0);
+ assert.equal(tankChange(result, fixture.ally), 0);
+ assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 3 : 5);
+ assert.equal(forces(result, fixture.ally), lead === 'ally' ? 2 : 4);
+ } else {
+ assert.equal(game.lastBattleContext?.winner,
+ outcome === 'loss' || outcome === 'opposingTraitor' ? fixture.opponent : null);
+ assert.equal(forces(result, fixture.ecaz), 0);
+ assert.equal(forces(result, fixture.ally), 0);
+ assert.equal(tankChange(result, fixture.ecaz), !canceledReturn && lead === 'ecaz' ? 3 : 5);
+ assert.equal(tankChange(result, fixture.ally), !canceledReturn && lead === 'ally' ? 2 : 4);
+ }
+ discardedOnce(result);
+ collection(game);
+ });
+ void test(`Reinforcements ${lead} lead, actual ${outcome}: provisional own reserve cost and single disposal survive every reveal`, () => {
+ const result = ecazOccupyCardsCase({ lead, outcome, card: 'ecaz-reinforcements', slot: 'defense' });
+ const { fixture, actor, game } = result;
+ assert.equal(reserveChange(result, actor), -3);
+ assert.equal(reserveChange(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), 0);
+ const preserved = outcome === 'soleTraitor';
+ assert.equal(tankChange(result, fixture.ecaz), (preserved ? 0 : 5) + (lead === 'ecaz' ? 3 : 0));
+ assert.equal(tankChange(result, fixture.ally), (preserved ? 0 : 4) + (lead === 'ally' ? 3 : 0));
+ assert.equal(forces(result, fixture.ecaz), preserved ? 5 : 0);
+ assert.equal(forces(result, fixture.ally), preserved ? 4 : 0);
+ assert.equal(game.lastBattleContext?.winner,
+ preserved ? actor : outcome === 'loss' || outcome === 'opposingTraitor' ? fixture.opponent : null);
+ discardedOnce(result);
+ collection(game);
+ });
+ }
+ void test(`Karama-canceled Occupy with ${lead} Harass uses only its ordinary native own pool`, () => {
+ const result = ecazOccupyCardsCase({ lead, canceled: true });
+ const { fixture, actor, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(reserveChange(result, actor), lead === 'ecaz' ? 3 : 2);
+ assert.equal(tankChange(result, actor), 2);
+ assert.equal(forces(result, actor), 0);
+ assert.equal(forces(result, actor === fixture.ecaz ? fixture.ally : fixture.ecaz), lead === 'ecaz' ? 4 : 5);
+ discardedOnce(result);
+ collection(game);
+ });
+ for (const slot of ['weapon', 'defense'] as const) {
+ void test(`Opposing physical Reinforcements in ${slot} defeats the ${lead} coalition by its own +2`, () => {
+ const result = ecazOccupyCardsCase({ lead, slot, holder: 'opponent', card: 'ecaz-reinforcements',
+ equalLeaders: true, opponentDial: 4 });
+ assert.equal(result.game.lastBattleContext?.winner, result.fixture.opponent);
+ assert.equal(reserveChange(result, result.holder), -3);
+ assert.equal(tankChange(result, result.holder), 7);
+ assert.equal(forces(result, result.holder), 4);
+ assert.equal(reserveChange(result, result.actor), 0);
+ discardedOnce(result);
+ collection(result.game);
+ });
+ void test(`Opposing Harass in ${slot} withdraws only its six undialed forces against ${lead} Occupy`, () => {
+ const result = ecazOccupyCardsCase({ lead, slot, holder: 'opponent', opponentDial: 2 });
+ assert.equal(result.game.lastBattleContext?.winner, result.actor);
+ assert.equal(reserveChange(result, result.holder), 6);
+ assert.equal(tankChange(result, result.holder), 2);
+ assert.equal(forces(result, result.fixture.ecaz), 2);
+ assert.equal(forces(result, result.fixture.ally), 2);
+ discardedOnce(result);
+ collection(result.game);
+ });
+ }
+}
+
+void test('Ecaz Harass returns floor(E/2) even when no ally fighters are dialed or paid', () => {
+ const result = ecazOccupyCardsCase({ variableDial: 0, support: 0 });
+ assert.equal(reserveChange(result, result.fixture.ecaz), 2);
+ assert.equal(tankChange(result, result.fixture.ecaz), 3);
+ assert.equal(forces(result, result.fixture.ecaz), 0);
+ assert.equal(forces(result, result.fixture.ally), 4);
+ assert.equal(seat(result.game, result.actor).spice, seat(result.before, result.actor).spice);
+ discardedOnce(result);
+});
+
+void test('Even Ecaz army keeps its original two mandatory losses after returning its two undialed forces', () => {
+ const result = ecazOccupyCardsCase({ ecazForces: 4 });
+ assert.equal(result.game.lastBattleContext?.winner, result.actor);
+ assert.equal(reserveChange(result, result.fixture.ecaz), 2);
+ assert.equal(tankChange(result, result.fixture.ecaz), 2);
+ assert.equal(forces(result, result.fixture.ecaz), 0);
+ assert.equal(forces(result, result.fixture.ally), 2);
+ discardedOnce(result);
+});
+
+void test('Selected Ixian Harass returns a chosen typed undialed pool, never the fixed Ecaz fighters', () => {
+ const result = ecazOccupyCardsCase({ lead: 'ally', allyFaction: 'ixians', allyForces: 8, allyElite: 2,
+ variableDial: 3, support: 0, returnedForces: { normal: 2, elite: 1 },
+ losses: { normal: 4, elite: 1 } });
+ assert.equal(result.game.lastBattleContext?.winner, result.actor);
+ assert.equal(reserveChange(result, result.fixture.ally), 3);
+ assert.equal(seat(result.game, result.fixture.ally).elites!.reserves -
+ seat(result.before, result.fixture.ally).elites!.reserves, 1);
+ assert.equal(tankChange(result, result.fixture.ally), 5);
+ assert.equal(seat(result.game, result.fixture.ally).elites!.tanks -
+ seat(result.before, result.fixture.ally).elites!.tanks, 1);
+ assert.equal(forces(result, result.fixture.ally), 0);
+ assert.equal(forces(result, result.fixture.ecaz), 2);
+ assert.equal(tankChange(result, result.fixture.ecaz), 3);
+ discardedOnce(result);
+ collection(result.game);
+});
+
+for (const lead of ['ecaz', 'ally'] as const) {
+ void test(`Native Ix substitution and selected ${lead} cleanup follow physical Reinforcements`, () => {
+ const result = ecazOccupyCardsCase({ lead, allyFaction: 'ixians', allyForces: 8, allyElite: 2,
+ card: 'ecaz-reinforcements', variableDial: 4, support: 1,
+ losses: { normal: 2, elite: 2 }, substitute: 2, cleanupShield: true });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(reserveChange(result, actor), -3);
+ assert.equal(forces(result, fixture.ecaz), 2);
+ assert.equal(forces(result, fixture.ally), 4);
+ assert.equal(seat(game, fixture.ally).elites!.forces[fixture.location], 2);
+ assert.equal(tankChange(result, fixture.ally), 4 + (lead === 'ally' ? 3 : 0));
+ assert.equal(seat(game, fixture.ally).elites!.tanks, seat(before, fixture.ally).elites!.tanks);
+ assert.equal(seat(before, actor).spice - seat(game, actor).spice, 1);
+ assert.ok(game.discard.some(card => card.id === result.shield));
+ const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
+ for (const card of seat(before, nonLead).hand)
+ assert.ok(seat(game, nonLead).hand.some(held => held.id === card.id));
+ discardedOnce(result);
+ collection(game);
+ });
+ void test(`Native CHOAM and ${lead} card payer preserve free fixed strength and actual support income`, () => {
+ const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam', card: 'ecaz-reinforcements',
+ variableDial: 3, support: 3, opponentDial: 3 });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(seat(game, fixture.ally).spice - seat(before, fixture.ally).spice,
+ lead === 'ally' ? -2 : 2,
+ 'CHOAM receives only floor halves from actual other payers, never fixed strength or Reinforcements.');
+ if (lead === 'ecaz') assert.equal(seat(before, actor).spice - seat(game, actor).spice, 3);
+ assert.equal(reserveChange(result, actor), -3);
+ assert.equal(forces(result, fixture.ecaz), 2);
+ assert.equal(forces(result, fixture.ally), 1);
+ discardedOnce(result);
+ collection(game);
+ });
+ void test(`Native CHOAM fixed-only ${lead} Reinforcements creates no support payment or income`, () => {
+ const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam', card: 'ecaz-reinforcements',
+ variableDial: 0, support: 0 });
+ assert.equal(seat(result.game, result.fixture.ally).spice, seat(result.before, result.fixture.ally).spice);
+ assert.equal(seat(result.game, result.fixture.ecaz).spice, seat(result.before, result.fixture.ecaz).spice);
+ assert.equal(forces(result, result.fixture.ecaz), 2);
+ assert.equal(forces(result, result.fixture.ally), 4);
+ discardedOnce(result);
+ });
+ void test(`Actual Face Dance after ${lead} Harass cannot recapture returned own reserves`, () => {
+ const result = ecazOccupyCardsCase({ lead, allyFaction: 'guild', opponentFaction: 'emperor',
+ roster: ['ecaz', 'guild', 'emperor', 'tleilaxu'], expansions: ['ecaz', 'ix'],
+ faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'guild', stopAtFaceDance: true });
+ const { fixture, actor, before } = result;
+ const dancer = result.game.players.find(p => p.faction === 'tleilaxu');
+ assert.ok(dancer);
+ const decision = result.game.decision;
+ assert.ok(decision?.kind === 'faceDance');
+ assert.equal(decision.winner, actor);
+ assert.equal(decision.leader, result.actorLeader);
+ assert.equal(reserveChange(result, actor), 2);
+ const other = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
+ assert.equal(forces(result, actor), 0);
+ assert.equal(forces(result, other), 2);
+ assert.throws(() => applyAction(result.game, dancer.id, { type: 'decision', reveal: true,
+ sources: { reserves: 3 }, sector: splitLocation(fixture.location).sector }), /more forces/,
+ 'The two withdrawn forces are outside the surviving coalition replacement limit.');
+ let game = applyAction(result.game, dancer.id, { type: 'decision', reveal: true,
+ sources: { reserves: 2 }, sector: splitLocation(fixture.location).sector });
+ game = finishOccupyCardsAftermath(game);
+ assert.equal(seat(game, actor).reserves, seat(before, actor).reserves + 2);
+ assert.equal(seat(game, other).reserves, seat(before, other).reserves + 2);
+ assert.equal(ecazOccupyTerritoryForces(game, dancer.id, fixture.territory), 2);
+ assert.equal(seat(game, actor).leaders.find(l => l.id === result.actorLeader)!.dead, true);
+ assert.equal(game.discard.filter(card => card.id === result.card).length, 1);
+ collection(game);
+ });
+}
+
+for (const lead of ['ecaz', 'ally'] as const) {
+ void test(`Selected ${lead} Harass winner performs ordinary own-card cleanup after returning forces`, () => {
+ const result = ecazOccupyCardsCase({ lead, cleanupShield: true });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(reserveChange(result, actor), 2);
+ assert.ok(game.discard.some(card => card.id === result.shield));
+ assert.ok(!seat(game, actor).hand.some(card => card.id === result.shield));
+ const other = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
+ for (const card of seat(before, other).hand)
+ assert.ok(seat(game, other).hand.some(held => held.id === card.id));
+ discardedOnce(result);
+ collection(game);
+ });
+ void test(`Native CHOAM ${lead} Harass pays only variable support after its own withdrawal`, () => {
+ const result = ecazOccupyCardsCase({ lead, allyFaction: 'choam',
+ variableDial: 3, support: 3, opponentDial: 3 });
+ const { fixture, actor, before, game } = result;
+ assert.equal(game.lastBattleContext?.winner, actor);
+ assert.equal(seat(game, fixture.ally).spice - seat(before, fixture.ally).spice,
+ lead === 'ally' ? -2 : 2);
+ assert.equal(reserveChange(result, actor), lead === 'ecaz' ? 2 : 1);
+ assert.equal(tankChange(result, fixture.ecaz), 3);
+ assert.equal(tankChange(result, fixture.ally), 3);
+ assert.equal(forces(result, fixture.ecaz), lead === 'ecaz' ? 0 : 2);
+ assert.equal(forces(result, fixture.ally), lead === 'ally' ? 0 : 1);
+ discardedOnce(result);
+ collection(game);
+ });
+}
+
+void test('selected typed Harass losses survive an opposing mandatory Reinforcements discard without reopening allocation', () => {
+ const result = ecazOccupyCardsCase({
+ lead: 'ally', allyFaction: 'emperor', opponentFaction: 'guild', allyForces: 6, allyElite: 2,
+ variableDial: 3.5, support: 2,
+ returnedForces: { normal: 1, elite: 1 },
+ losses: { normal: 3, elite: 1 },
+ opponentCard: 'ecaz-reinforcements',
+ });
+ assert.equal(result.game.lastBattleContext?.winner, result.actor);
+ assert.equal(forces(result, result.fixture.ally), 0);
+ assert.equal(forces(result, result.fixture.ecaz), 2);
+ assert.equal(reserveChange(result, result.actor), 2);
+ assert.equal(tankChange(result, result.actor), 4);
+ assert.ok(result.game.discard.some(card => card.id === 'ecaz-reinforcements'));
+ collection(result.game);
+});
+
+void test('Basic co-present Ecaz card armies remain unsupported instead of admitting an ordinary single-army outcome', () => {
+ for (const card of ['ecaz-harass-withdraw', 'ecaz-reinforcements']) {
+ const game = harassWithdrawGame({ advanced: false, factions: ['guild', 'emperor', 'ecaz'] });
+ const guild = game.players[0], ecaz = game.players[2];
+ // Labelled co-side rule-unit staging on the genuine ordinary variant setup.
+ guild.ally = ecaz.id;
+ ecaz.ally = guild.id;
+ ecaz.forces = { 'arrakeen:10': 2 };
+ ecaz.reserves -= 2;
+ takeHarassCard(game, guild.id, card);
+ assert.throws(() => applyAction(game, guild.id,
+ { type: 'battlePlan', dial: 1, leader: 'guild-0', weapon: card }),
+ /supported Advanced Occupy battle profile/);
+ assert.equal(game.battle?.plans[guild.id], undefined);
+ }
+});
diff --git a/tests/ecaz-occupy-harass-quote.test.ts b/tests/ecaz-occupy-harass-quote.test.ts
new file mode 100644
index 00000000..60de3ee6
--- /dev/null
+++ b/tests/ecaz-occupy-harass-quote.test.ts
@@ -0,0 +1,195 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { casualtyOptions, type CombatForces } from '../game/combat';
+import {
+ quoteEcazOccupyBattle,
+ type EcazOccupyBattleProfile,
+} from '../game/ecaz-occupy-battle';
+import {
+ defaultHarassWithdrawAllocation,
+ harassWithdrawCommitments,
+ harassWithdrawNeedsAllocation,
+ quoteHarassWithdraw,
+ HarassWithdrawError,
+ type HarassWithdrawContext,
+ type HarassWithdrawSelection,
+} from '../game/harass-withdraw';
+
+const native = (normal: number): CombatForces => ({
+ normal,
+ elite: 0,
+ eliteStrength: 1,
+ freeSupport: false,
+});
+function profile(
+ ecazNormal = 5,
+ lead = 'ecaz-user',
+ canceled = false,
+ allyForces = native(4),
+): EcazOccupyBattleProfile {
+ return quoteEcazOccupyBattle({
+ battleOrderActor: 'ecaz-user',
+ ecaz: {
+ id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
+ forces: native(ecazNormal),
+ },
+ ally: {
+ id: 'ally-user', faction: 'ixians', ally: 'ecaz-user',
+ forces: allyForces,
+ },
+ lead,
+ canceled,
+ });
+}
+function contextFor(p: EcazOccupyBattleProfile): HarassWithdrawContext {
+ const forces = p.lead === p.ecaz ? p.ecazForces : p.allyForces;
+ return {
+ blocked: null,
+ forces,
+ locations: { 'imperial_basin:9': { normal: forces.normal, elite: forces.elite } },
+ occupy: {
+ cardUser: p.lead,
+ ecaz: p.ecaz,
+ ally: p.ally,
+ canceled: p.canceled,
+ fixedEcazDial: p.fixedEcazDial,
+ variableForces: p.forces,
+ },
+ };
+}
+
+void test('Ecaz card user returns only its own floor(E/2), while ally support never charges the free fixed fighters', () => {
+ for (const ecazNormal of [5, 6]) {
+ const p = profile(ecazNormal);
+ const context = contextFor(p);
+ const before = structuredClone(context);
+ for (const [variableDial, support] of [[0, 0], [0.5, 0], [2, 2]]) {
+ const dial = p.fixedEcazDial + variableDial;
+ assert.deepEqual(harassWithdrawCommitments(context, dial, support), [
+ { normal: Math.ceil(ecazNormal / 2), elite: 0 },
+ ]);
+ assert.equal(harassWithdrawNeedsAllocation(context, dial, support), false);
+ const quote = quoteHarassWithdraw(context, dial, support);
+ assert.deepEqual(quote.returned, { normal: Math.floor(ecazNormal / 2), elite: 0 });
+ assert.deepEqual(quote.remaining, {
+ ...context.forces, normal: Math.ceil(ecazNormal / 2), freeSupport: true,
+ });
+ assert.deepEqual(casualtyOptions(quote.remaining, p.fixedEcazDial, 0), [
+ { normal: p.fixedEcazDial, elite: 0, paidNormal: 0, paidElite: 0 },
+ ]);
+ }
+ assert.deepEqual(context, before);
+ }
+});
+
+void test('original mandatory Ecaz commitment survives a changed on-board pool after the physical return', () => {
+ const p = profile(5);
+ const context = contextFor(p);
+ const returned = quoteHarassWithdraw(context, 4, 1);
+ const afterReturn: HarassWithdrawContext = {
+ ...context,
+ forces: returned.remaining,
+ locations: { 'imperial_basin:9': { normal: 3, elite: 0 } },
+ };
+ const remaining = quoteHarassWithdraw(afterReturn, 4, 1);
+ assert.deepEqual(remaining.returned, { normal: 0, elite: 0 });
+ assert.equal(remaining.remaining.normal, 3);
+ assert.equal(remaining.remaining.freeSupport, true);
+});
+
+void test('Ecaz sector choices allocate only its own undialed counters, never ally counters', () => {
+ const context: HarassWithdrawContext = {
+ ...contextFor(profile()),
+ locations: {
+ 'imperial_basin:9': { normal: 2, elite: 0 },
+ 'imperial_basin:10': { normal: 3, elite: 0 },
+ },
+ };
+ assert.equal(harassWithdrawNeedsAllocation(context, 4, 1), true);
+ const selected: HarassWithdrawSelection = {
+ 'imperial_basin:10': { normal: 2, elite: 0 },
+ };
+ assert.deepEqual(quoteHarassWithdraw(context, 4, 1, selected).returned, { normal: 2, elite: 0 });
+ const fallback = defaultHarassWithdrawAllocation(context, 4, 1);
+ assert.deepEqual(quoteHarassWithdraw(context, 4, 1, fallback).returned, { normal: 2, elite: 0 });
+ assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
+ 'ally-location': { normal: 2, elite: 0 },
+ }), HarassWithdrawError);
+ assert.throws(() => quoteHarassWithdraw(context, 4, 1, {
+ 'imperial_basin:10': { normal: 1, elite: 0 },
+ }), HarassWithdrawError);
+});
+
+void test('ally card user subtracts fixed strength and uses native Ix half-normal/double-elite spice commitments', () => {
+ const ix: CombatForces = {
+ normal: 3, elite: 2, eliteStrength: 2,
+ normalFixedHalf: true, freeSupport: false,
+ };
+ const context = contextFor(profile(5, 'ally-user', false, ix));
+ // Three fixed Ecaz strength plus 2.5 variable strength: one half-normal
+ // and one supported double-elite, not 2.5 or 5.5 physical counters.
+ assert.deepEqual(harassWithdrawCommitments(context, 5.5, 1), [{ normal: 1, elite: 1 }]);
+ const quote = quoteHarassWithdraw(context, 5.5, 1);
+ assert.deepEqual(quote.returned, { normal: 2, elite: 1 });
+ assert.deepEqual(quote.remaining, { ...ix, normal: 1, elite: 1 });
+ // Without spice the same strength admits two genuinely different physical
+ // commitments; selecting the elite-heavy one returns only two own normals.
+ assert.equal(harassWithdrawNeedsAllocation(context, 5.5, 0), true);
+ const selected = defaultHarassWithdrawAllocation(context, 5.5, 0, { normal: 1, elite: 2 });
+ assert.deepEqual(quoteHarassWithdraw(context, 5.5, 0, selected).returned, { normal: 2, elite: 0 });
+});
+
+void test('ordinary Advanced ally returns physical undialed counters instead of numeric dial complement', () => {
+ const context = contextFor(profile(5, 'ally-user'));
+ assert.deepEqual(quoteHarassWithdraw(context, 4, 0).returned, { normal: 2, elite: 0 });
+ assert.deepEqual(quoteHarassWithdraw(context, 4, 1).returned, { normal: 3, elite: 0 });
+ assert.deepEqual(quoteHarassWithdraw(context, 3, 0).returned, { normal: 4, elite: 0 });
+});
+
+void test('canceled Occupy restores either selected lead own native commitment with zero fixed strength', () => {
+ for (const lead of ['ecaz-user', 'ally-user']) {
+ const p = profile(5, lead, true);
+ const context = contextFor(p);
+ const quote = quoteHarassWithdraw(context, 1, 0);
+ assert.deepEqual(quote.returned, { normal: context.forces.normal - 2, elite: 0 });
+ assert.deepEqual(quote.remaining, { ...context.forces, normal: 2 });
+ assert.equal(quote.remaining.freeSupport, false);
+ const all = quoteHarassWithdraw(context, 0, 0);
+ assert.equal(all.returned.normal, context.forces.normal);
+ }
+});
+
+void test('ordinary opponent keeps native dial/support semantics without Occupy metadata', () => {
+ const context: HarassWithdrawContext = {
+ blocked: null,
+ forces: native(5),
+ locations: { 'imperial_basin:9': { normal: 5, elite: 0 } },
+ };
+ assert.deepEqual(quoteHarassWithdraw(context, 1, 0).returned, { normal: 3, elite: 0 });
+ assert.deepEqual(quoteHarassWithdraw(context, 1, 1).returned, { normal: 4, elite: 0 });
+ assert.deepEqual(quoteHarassWithdraw(context, 0, 0).returned, { normal: 5, elite: 0 });
+});
+
+void test('owner and funding boundary rejects invalid total plans before quoting any return', () => {
+ const context = contextFor(profile());
+ for (const [dial, support] of [[2, 0], [3, 1], [8, 0], [3.25, 0], [4, -1]]) {
+ assert.throws(() => quoteHarassWithdraw(context, dial, support), HarassWithdrawError);
+ }
+ assert.ok(context.occupy);
+ const wrongOwner: HarassWithdrawContext = {
+ ...context, occupy: { ...context.occupy, cardUser: 'opponent' },
+ };
+ assert.throws(() => quoteHarassWithdraw(wrongOwner, 4, 1), HarassWithdrawError);
+ const missingFixed: HarassWithdrawContext = {
+ ...context, occupy: { ...context.occupy, fixedEcazDial: 0 },
+ };
+ assert.throws(() => quoteHarassWithdraw(missingFixed, 4, 1), HarassWithdrawError);
+ const mixedAlly: HarassWithdrawContext = {
+ ...context, occupy: { ...context.occupy, cardUser: 'ally-user' },
+ };
+ assert.throws(() => quoteHarassWithdraw(mixedAlly, 4, 1), HarassWithdrawError);
+ const canceledWithFixed: HarassWithdrawContext = {
+ ...context, occupy: { ...context.occupy, canceled: true },
+ };
+ assert.throws(() => quoteHarassWithdraw(canceledWithFixed, 4, 1), HarassWithdrawError);
+});
diff --git a/tests/fixture-ecaz-occupy-cards.ts b/tests/fixture-ecaz-occupy-cards.ts
new file mode 100644
index 00000000..80c57a60
--- /dev/null
+++ b/tests/fixture-ecaz-occupy-cards.ts
@@ -0,0 +1,231 @@
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game, type Player } from '../game/engine';
+import type { EcazTreacheryCardId } from '../game/ecaz-cards';
+import { defaultHarassWithdrawAllocation, type HarassWithdrawForces, type HarassWithdrawSelection } from '../game/harass-withdraw';
+import {
+ ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
+ allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture,
+ type EcazOccupyFixtureOptions,
+} from './fixture-ecaz-occupy';
+
+export const occupyCardsPlayer = (game: Game, id: string): Player => {
+ const player = game.players.find(p => p.id === id);
+ assert.ok(player, 'The original roster must contain the physical owner.');
+ return player;
+};
+
+function stageCard(game: Game, owner: string, predicate: (card: Player['hand'][number]) => boolean): string {
+ const id = stageEcazOccupyCard(game, owner, predicate);
+ const hand = occupyCardsPlayer(game, owner).hand;
+ // Keep the selected physical face when the second conserved slot is staged.
+ const index = hand.findIndex(card => card.id === id);
+ hand.push(hand.splice(index, 1)[0]);
+ return id;
+}
+
+/** Exchange one original held Traitor Card for a physical reserve/held identity. */
+export function holdOccupyCardsTraitor(game: Game, owner: string, identity: string): void {
+ const player = occupyCardsPlayer(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;
+ } else {
+ const donor = game.players.find(p => p.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;
+ }
+}
+
+function leader(game: Game, owner: string, strongest: boolean, traitorable = false): string {
+ const physical = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)]);
+ const available = occupyCardsPlayer(game, owner).leaders.filter(l =>
+ !l.dead && !l.usedAt && (!traitorable || physical.has(l.id)));
+ available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
+ assert.ok(available[0], 'Use an available original physical leader.');
+ return available[0].id;
+}
+
+export type OccupyCardsOutcome = 'win' | 'loss' | 'soleTraitor' | 'opposingTraitor' |
+ 'mutualTraitors' | 'explosion';
+export type OccupyCardsOptions = EcazOccupyFixtureOptions & {
+ lead?: 'ecaz' | 'ally';
+ holder?: 'lead' | 'opponent';
+ card?: Extract;
+ opponentCard?: Extract;
+ slot?: 'weapon' | 'defense';
+ outcome?: OccupyCardsOutcome;
+ canceled?: boolean;
+ variableDial?: number;
+ support?: number;
+ opponentDial?: number;
+ opponentSupport?: number;
+ /** Find real equal-strength leaders for a decisive +2 score comparison. */
+ equalLeaders?: boolean;
+ actorLeader?: string;
+ returns?: HarassWithdrawSelection;
+ /** One native battle location, selected only after the fixture chooses its storm-safe sector. */
+ returnedForces?: HarassWithdrawForces;
+ losses?: { normal: number; elite: number };
+ substitute?: number;
+ funding?: number;
+ cleanupShield?: boolean;
+ /** Return at the actual Face Dance offer, after ordinary cleanup. */
+ stopAtFaceDance?: boolean;
+};
+export type OccupyCardsCase = {
+ fixture: EcazOccupyFixture;
+ actor: string;
+ holder: string;
+ card: string;
+ shield?: string;
+ actorLeader: string;
+ opponentLeader: string;
+ before: Game;
+ revealed: Game;
+ afterVotes: Game;
+ game: Game;
+};
+
+/** Consume native aftermath offers only; never manufacture a phase or receipt. */
+export function finishOccupyCardsAftermath(state: Game, options: Pick = {}, discardCard?: string): Game {
+ let game = state;
+ for (let attempt = 0; attempt < 100; attempt++) {
+ const decision = game.decision;
+ if (game.response) game = allowEcazOccupyResponses(game);
+ else if (decision?.kind === 'battleLosses') {
+ const choice = options.losses ? decision.options.findIndex(loss =>
+ loss.normal === options.losses!.normal && loss.elite === options.losses!.elite) : 0;
+ assert.ok(choice >= 0, 'The original typed commitment must offer this physical loss allocation.');
+ game = applyAction(game, decision.player, { type: 'decision', choice });
+ } else if (decision?.kind === 'ixSubstitution') {
+ const count = options.substitute ?? 0;
+ const key = Object.keys(decision.losses).find(location => decision.losses[location] >= count);
+ if (count) {
+ assert.ok(key, 'A real lost Cyborg sector must fund substitution.');
+ game = applyAction(game, decision.player, { type: 'decision',
+ sources: { [key]: count }, recover: { [key]: count } });
+ } else game = applyAction(game, decision.player, { type: 'decision', decline: true });
+ } else if (decision?.kind === 'battleCards') {
+ const discard = discardCard && decision.cards.includes(discardCard) ? [discardCard] : [];
+ game = applyAction(game, decision.player, { type: 'decision', discard });
+ } else if (decision?.kind === 'choamMarket') {
+ game = applyAction(game, decision.player, { type: 'decision', done: true });
+ } else if (game.pendingTreacheryDiscard) {
+ game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
+ } else if (decision?.kind === 'faceDance' && options.stopAtFaceDance) return game;
+ else {
+ assert.equal(game.battle, null, 'The original sealed plans and votes must resolve.');
+ assert.equal(game.decision, null, 'Every required native aftermath offer must finish.');
+ return game;
+ }
+ }
+ throw new Error('The original card battle aftermath did not finish.');
+}
+
+/** Original family deal, real Nexus alliance, shared entry and actual lead choice.
+ * Only conserved physical hand/Traitor Card exchanges expose each printed rule. */
+export function ecazOccupyCardsCase(options: OccupyCardsOptions = {}): OccupyCardsCase {
+ const lead = options.lead ?? 'ecaz';
+ const outcome = options.outcome ?? 'win';
+ const slot = options.slot ?? 'weapon';
+ const cardId = options.card ?? 'ecaz-harass-withdraw';
+ const fixture = ecazOccupyFixture({ ...options,
+ ecazTreachery: options.initial ? options.initial.ecazTreachery === true : true,
+ ecazForces: options.ecazForces ?? 5, allyFaction: options.allyFaction ?? 'guild',
+ opponentFaction: options.opponentFaction ?? 'emperor' });
+ const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
+ const holder = options.holder === 'opponent' ? fixture.opponent : actor;
+ assert.equal(fixture.initial.ecazTreachery, true, 'This case requires an originally selected physical card variant.');
+ const card = stageCard(fixture.game, holder, c => c.id === cardId);
+ let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
+ game = allowEcazOccupyResponses(game);
+ if (game.decision?.kind === 'choamBattleFunding')
+ game = applyAction(game, game.decision.player, { type: 'decision', amount: options.funding ?? 0 });
+ else assert.equal(options.funding ?? 0, 0, 'Funding requires the genuine CHOAM offer.');
+ game = openEcazOccupyPlans(game);
+ const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
+ assert.ok(profile);
+ let actorLeader = options.actorLeader ?? leader(game, actor, outcome !== 'loss',
+ outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
+ let opponentLeader = leader(game, fixture.opponent, outcome === 'loss',
+ outcome === 'soleTraitor' || outcome === 'mutualTraitors');
+ if (options.stopAtFaceDance && !options.actorLeader) {
+ const dancers = game.players.find(p => p.faction === 'tleilaxu')?.faceDancers;
+ const candidate = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
+ dancers?.some(c => !c.revealed && c.leader === l.id));
+ assert.ok(candidate, 'Use the original first-draw Face Dancer of the real selected leader.');
+ actorLeader = candidate.id;
+ }
+ if (options.equalLeaders) {
+ const own = occupyCardsPlayer(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
+ occupyCardsPlayer(game, fixture.opponent).leaders.some(other =>
+ !other.dead && !other.usedAt && other.strength === l.strength));
+ assert.ok(own, 'The actual faction rosters must supply equal-strength leaders.');
+ actorLeader = own.id;
+ opponentLeader = occupyCardsPlayer(game, fixture.opponent).leaders.find(l =>
+ !l.dead && !l.usedAt && l.strength === own.strength)!.id;
+ }
+ const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
+ if (outcome === 'soleTraitor' || outcome === 'mutualTraitors')
+ holdOccupyCardsTraitor(game, voter, opponentLeader);
+ if (outcome === 'opposingTraitor' || outcome === 'mutualTraitors')
+ holdOccupyCardsTraitor(game, fixture.opponent, actorLeader);
+ const ownCards: { weapon?: string; defense?: string } = {};
+ const enemyCards: { weapon?: string; defense?: string } = {};
+ (holder === actor ? ownCards : enemyCards)[slot] = card;
+ if (options.opponentCard)
+ enemyCards.weapon = stageCard(game, fixture.opponent, c => c.id === options.opponentCard);
+ let shield: string | undefined;
+ if (options.cleanupShield) {
+ assert.equal(slot, 'weapon', 'The actual shield occupies the other legal slot.');
+ shield = stageCard(game, actor, c => c.kind === 'shield');
+ ownCards.defense = shield;
+ }
+ if (outcome === 'explosion') {
+ const lasgunOwner = slot === 'defense' ? holder : holder === actor ? fixture.opponent : actor;
+ const shieldOwner = lasgunOwner === actor ? fixture.opponent : actor;
+ (lasgunOwner === actor ? ownCards : enemyCards).weapon =
+ stageCard(game, lasgunOwner, c => c.kind === 'lasgun');
+ (shieldOwner === actor ? ownCards : enemyCards).defense =
+ stageCard(game, shieldOwner, c => c.kind === 'shield');
+ }
+ const variable = options.variableDial ?? 2;
+ const free = occupyCardsPlayer(game, profile.forceOwner).faction === 'fremen';
+ const support = options.support ?? (free ? 0 : variable);
+ const opponentDial = options.opponentDial ?? (outcome === 'loss' ? 8 : 0);
+ const before = structuredClone(game);
+ game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
+ support, allyPayment: options.funding ?? 0, leader: actorLeader, ...ownCards });
+ game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
+ support: options.opponentSupport ?? opponentDial, leader: opponentLeader, ...enemyCards });
+ const revealed = structuredClone(game);
+ if (game.decision?.kind === 'harassWithdraw') {
+ const decision = game.decision;
+ const offer = viewGame(game, decision.player).battle?.harassAllocation;
+ assert.ok(offer, 'Use only the actual revealed physical allocation offer.');
+ const returns = options.returns ?? (options.returnedForces ?
+ { [fixture.location]: options.returnedForces } :
+ defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support));
+ game = applyAction(game, decision.player, { type: 'decision', event: decision.event, returns });
+ }
+ for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
+ const battle = viewGame(game, actor).battle;
+ assert.ok(battle);
+ const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
+ if (!next) break;
+ const call = next === voter && (outcome === 'soleTraitor' || outcome === 'mutualTraitors') ||
+ next === fixture.opponent && (outcome === 'opposingTraitor' || outcome === 'mutualTraitors');
+ game = applyAction(game, next, { type: 'traitorCall', call });
+ }
+ const afterVotes = structuredClone(game);
+ game = finishOccupyCardsAftermath(game, options, shield);
+ return { fixture, actor, holder, card, shield, actorLeader, opponentLeader,
+ before, revealed, afterVotes, game };
+}
diff --git a/tests/fixture-ecaz-occupy.ts b/tests/fixture-ecaz-occupy.ts
index 5ccdff6f..a9577953 100644
--- a/tests/fixture-ecaz-occupy.ts
+++ b/tests/fixture-ecaz-occupy.ts
@@ -6,12 +6,15 @@ import { TERRITORIES, location as locationKey, splitLocation } from '../game/boa
import { finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
export type EcazOccupyFixtureFaction = Exclude;
export type EcazOccupyFixtureOptions = {
initial?: Game;
+ /** Independent physical three-card variant, chosen before the original deal. */
+ ecazTreachery?: boolean;
ecazForces?: number;
allyFaction?: EcazOccupyFixtureFaction;
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
@@ -105,7 +108,7 @@ export function finishEcazOccupySetup(state: Game, faceDancerFaction?: EcazOccup
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
let game = state;
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
- if (accompany && game.decision?.kind === 'advisor')
+ if (accompany && !game.response && game.decision?.kind === 'advisor')
game = applyAction(game, game.decision.player,
{ type: 'decision', accept: true, accompany: true, amount: 1 });
else game = step(game);
@@ -178,14 +181,17 @@ export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): G
if (options.expansions) assert.deepEqual(setup.expansions, options.expansions);
assert.ok(setup.expansions.includes('ecaz') && new Set(setup.expansions).size === setup.expansions.length);
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
- assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards &&
+ if (options.ecazTreachery !== undefined)
+ assert.equal(setup.ecazTreachery === true, options.ecazTreachery);
+ assert.ok(!setup.homeworlds && !setup.nexusCards &&
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
!setup.greatMaker && !setup.moritaniAssassinatePreview,
- 'Captured Occupy inputs admit the explicit ordinary Advanced profile only.');
+ 'Captured Occupy inputs admit only the selected original Advanced family and independent card variant.');
assert.deepEqual([...setup.deck, ...setup.discard, ...(setup.ixSetupCards ?? []),
...setup.players.flatMap(p => p.hand)].map(card => card.id).sort(),
- treacheryDeck(setup.expansions).map(card => card.id).sort(),
+ [...treacheryDeck(setup.expansions), ...(setup.ecazTreachery ? ecazTreacheryCards() : [])]
+ .map(card => card.id).sort(),
'The original captured deal must retain every selected physical card exactly once.');
} else {
const roster: EcazOccupyFixtureFaction[] = options.roster ?? ['ecaz', allyFaction, opponentFaction];
@@ -206,6 +212,9 @@ export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): G
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + firstCircle)
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + firstCircle });
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
+ // Label the independent variant in this fresh audit configuration only,
+ // immediately before its one original setup/deal; never retrofit a capture.
+ if (options.ecazTreachery) setup.ecazTreachery = true;
setup = initializeEcazOccupyGameForAudit(setup);
}
assert.ok(setup.players.some(p => p.faction === 'ecaz') &&
diff --git a/tests/harass-withdraw.test.ts b/tests/harass-withdraw.test.ts
index bd8643b3..bc847cdc 100644
--- a/tests/harass-withdraw.test.ts
+++ b/tests/harass-withdraw.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
import { quoteHarassWithdraw } from '../game/harass-withdraw';
import {
quoteBattleResolution,
@@ -421,11 +421,7 @@ void test('co-present reciprocal Ecaz allies are publicly guarded before sealing
g.players[2].ally = g.players[side].id;
g.players[2].forces = { 'arrakeen:10': 3 };
g.players[2].reserves = 17;
- assert.match(
- viewGame(g, 'a').battle!.harassWithdraw!.blocked!,
- /combined-army/,
- );
- reject(g, 'a', ownPlan(g), /combined-army/);
+ assert.throws(() => applyAction(g, 'a', ownPlan(g)), RuleError);
// The guard depends on the visible coalition, not its cards or leader choice.
const hidden = structuredClone(g);
takeHarassCard(
@@ -440,7 +436,8 @@ void test('co-present reciprocal Ecaz allies are publicly guarded before sealing
g.players[2].forces = {};
g.players[2].reserves = 20;
assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null);
- assert.doesNotThrow(() => applyAction(g, 'a', ownPlan(g)));
+ const sealed = applyAction(g, 'a', ownPlan(g));
+ assert.ok(viewGame(sealed, 'a').battle!.submitted.includes('a'));
harassCustody(g);
}
});
diff --git a/tests/reinforcements-engine.test.ts b/tests/reinforcements-engine.test.ts
index c415d008..14f42181 100644
--- a/tests/reinforcements-engine.test.ts
+++ b/tests/reinforcements-engine.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const cardId = 'ecaz-reinforcements';
@@ -56,15 +56,12 @@ void test('Reinforcements consumes exactly three own normal reserves in either c
type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
leader: 'atreides-0', weapon: null, defense: null,
});
- assert.equal(viewGame(sealed, 'a').battle?.reinforcements?.blocked,
- 'Choose Reinforcements before sealing your Battle Plan.');
const done = resolve(sealed);
assert.equal(done.battle, null);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.players[0].tanks >= 3, true);
assert.equal(done.players[0].battleLosses >= 3, true);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
- assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, 'a'), viewGame(done, 'a'));
}
});
@@ -93,7 +90,7 @@ void test('paired Ecaz/Moritani battles spend one physical Reinforcements cost w
}
});
-void test('co-present Ecaz allied armies do not open the unintegrated Reinforcements battle path', () => {
+void test('co-present Ecaz card armies outside the opted-in Advanced profile remain unsupported', () => {
const g = harassWithdrawGame({ advanced: true, factions: ['moritani', 'atreides', 'ecaz'] });
g.players[0].ally = 't';
g.players[2].ally = 'a';
@@ -101,10 +98,9 @@ void test('co-present Ecaz allied armies do not open the unintegrated Reinforcem
g.players[2].reserves = 19;
takeHarassCard(g, 'a', cardId);
harassCustody(g);
- assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /co-present Ecaz allies/);
- reject(g, 'a', {
+ assert.throws(() => applyAction(g, 'a', {
type: 'battlePlan', dial: 1, support: 1, leader: 'moritani-0', weapon: cardId,
- }, /co-present Ecaz allies/);
+ }), RuleError);
});
void test('opposing physical Harass plan resolves with Reinforcements instead of trapping both revealed plans', () => {
@@ -145,7 +141,6 @@ void test('Reinforcements normal-first cost preserves elite subpool and is paid
assert.equal(done.players[0].elites!.reserves, 0);
assert.equal(done.players[0].elites!.tanks, 5);
assert.equal(done.discard.filter(c => c.id === cardId).length, 1);
- assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces (2 elite)')).length, 1);
});
void test('insufficient reserves, unsupported module, and a paired special reject without mutating the original game', () => {
@@ -165,16 +160,16 @@ void test('insufficient reserves, unsupported module, and a paired special rejec
reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId,
defense: 'ecaz-harass-withdraw' }, /cannot share/);
g.sandtrout = {} as Game['sandtrout'];
- reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /standalone Ecaz card variant/);
+ assert.throws(() => applyAction(g, 'a',
+ { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);
});
void test('other expansion factions remain ineligible for the paired Ecaz card prototype', () => {
for (const owner of ['ixians', 'choam'] as const) {
const g = harassWithdrawGame({ advanced: true, factions: [owner, 'atreides', 'harkonnen'] });
takeHarassCard(g, 'a', cardId);
- assert.match(viewGame(g, 'a').battle?.reinforcements?.blocked ?? '', /classic or Ecaz\/Moritani/);
- reject(g, 'a', {
+ assert.throws(() => applyAction(g, 'a', {
type: 'battlePlan', dial: 0, support: 0, leader: `${owner}-0`, weapon: cardId,
- }, /classic or Ecaz\/Moritani/);
+ }), RuleError);
}
});
diff --git a/tools/faction-games.ts b/tools/faction-games.ts
index 2682b444..6bd68fd3 100644
--- a/tools/faction-games.ts
+++ b/tools/faction-games.ts
@@ -45,6 +45,7 @@ type Scenario = {
rules: Rules;
expansions: string[];
roster: FactionId[];
+ ecazTreachery?: boolean;
};
const SCENARIOS: readonly Scenario[] = [
@@ -157,8 +158,8 @@ const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.map(scenario => ({
...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58,
}));
-/** Ordinary Advanced Ecaz Occupy, without the independent three-card variant. */
-const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
+/** Advanced Occupy samples retain explicit independent card-variant selection. */
+const ECAZ_OCCUPY_BASE_SCENARIOS: readonly Scenario[] = [
...[2, 3, 4, 5, 6].map((players): Scenario => ({
ordinal: 169 + players - 2, profile: 'ecaz-occupy', rules: 'advanced',
expansions: ['ecaz'],
@@ -176,6 +177,12 @@ const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
roster: (['ecaz', 'ixians', 'tleilaxu', 'choam', 'guild', 'fremen'] as FactionId[]).slice(0, players),
})),
];
+const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [
+ ...ECAZ_OCCUPY_BASE_SCENARIOS,
+ ...ECAZ_OCCUPY_BASE_SCENARIOS.map((scenario, index): Scenario => ({
+ ...scenario, ordinal: 192 + index, ecazTreachery: true,
+ })),
+];
const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
.map(scenario => ({
...scenario,
@@ -314,7 +321,7 @@ function usage() {
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
- '\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. No Richese mixed planning, independent three-card variant or optional overlays.'
+ '\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. No Richese mixed planning or other optional overlays.'
);
}
@@ -352,7 +359,8 @@ function scenarioName(scenario: Scenario) {
}
if (scenario.profile === 'ecaz-occupy') {
const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam'].includes(faction));
- if (native.length) return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`;
+ if (native.length || scenario.ecazTreachery)
+ return `${scenario.profile}${scenario.ecazTreachery ? '-treachery' : ''}${native.length ? '-' + native.join('-') : ''}-${scenario.roster.length}-${scenario.rules}`;
}
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
@@ -407,6 +415,7 @@ function freshGame(scenario: Scenario) {
player.bot = DIFFICULTIES[index % DIFFICULTIES.length];
player.ready = true;
}
+ if (scenario.ecazTreachery) game.ecazTreachery = true;
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.homeworlds = { custody: null };
if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
@@ -481,7 +490,7 @@ function resumedGame(path: string) {
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'stronghold-factions') === !!game.strongholdCards &&
- (candidate.profile === 'ecaz-treachery') === !!game.ecazTreachery &&
+ (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview &&
(candidate.profile === 'moritani-assassinate') === !!game.moritaniAssassinate &&
@@ -829,9 +838,11 @@ async function main() {
families: selected.filter(scenario => scenario.profile === 'ecaz-occupy').map(scenario => ({
roster: scenario.roster,
expansions: scenario.expansions,
- treacheryCards: scenario.expansions.includes('ix') ? 47 : scenario.expansions.includes('choam') ? 35 : 33,
+ treacheryCards: (scenario.expansions.includes('ix') ? 47 : scenario.expansions.includes('choam') ? 35 : 33) +
+ (scenario.ecazTreachery ? 3 : 0),
+ ecazTreachery: !!scenario.ecazTreachery,
})),
- scope: 'Advanced native Ecaz/classic/Ixian/Tleilaxu/CHOAM paired or mixed families, two through six seats. Selected E3/E1/E2 ordinary decks only; no Richese mixed planning, independent Ecaz three-card variant or optional overlays. Authorized revised Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.',
+ scope: 'Advanced native Ecaz/classic/Ixian/Tleilaxu/CHOAM paired or mixed families, two through six seats. Selected E3/E1/E2 decks and explicit independent three-card variant samples; no Richese mixed planning or other optional overlays. Authorized revised Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.',
},
} : {}),
options: {
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index f43c1ce4..37d00763 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -44,6 +44,10 @@ export const PROTOTYPE_PROFILES = [
'harkonnen-betrayal',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
+export type PrototypeOptions = {
+ /** Explicit independent card variant for the fresh combined-army profile. */
+ ecazTreachery?: boolean;
+};
export function isPrototypeProfile(value: string): value is PrototypeProfile {
return PROTOTYPE_PROFILES.some((profile) => profile === value);
}
@@ -62,6 +66,7 @@ export function startPrototypeRoom(
code: string,
expectedVersion: number,
profile: PrototypeProfile,
+ options: PrototypeOptions = {},
) {
if (
!/^[A-Z0-9]{8}$/.test(code) ||
@@ -81,6 +86,9 @@ export function startPrototypeRoom(
throw new Error('The room identity is inconsistent.');
if (!isPrototypeProfile(profile))
throw new Error('Unknown prototype profile.');
+ if (options.ecazTreachery && profile !== 'ecaz-occupy')
+ throw new Error('--ecaz-treachery requires the ecaz-occupy profile.');
+ if (options.ecazTreachery) initial.ecazTreachery = true;
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index bd074e70..898536ae 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -15,16 +15,19 @@ async function main() {
room: { type: 'string' },
version: { type: 'string' },
out: { type: 'string' },
+ 'ecaz-treachery': { type: 'boolean' },
help: { type: 'boolean' },
},
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
- '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original E3/E1/E2 offers/HMS/Auditor/Face Dancers. No Richese mixed planning, independent Ecaz three-card variant, modules or overlays.',
+ '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original E3/E1/E2 offers/HMS/Auditor/Face Dancers. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. No Richese mixed planning, modules or overlays.',
);
return;
}
+ if (values['ecaz-treachery'] && values.profile !== 'ecaz-occupy')
+ throw new Error('--ecaz-treachery requires --profile ecaz-occupy.');
if (
!isPrototypeProfile(values.profile) ||
!values.db ||
@@ -47,7 +50,8 @@ async function main() {
throw new Error('The requested lobby version is absent from the backup.');
const db = new DatabaseSync(values.db);
try {
- const result = startPrototypeRoom(db, values.room, version, values.profile);
+ const result = startPrototypeRoom(db, values.room, version, values.profile,
+ { ecazTreachery: values['ecaz-treachery'] });
writeFileSync(
resolve(values.out, 'prototype.json'),
JSON.stringify(
@@ -55,6 +59,7 @@ async function main() {
format: 1,
startedAt: new Date().toISOString(),
profile: values.profile,
+ ecazTreachery: !!values['ecaz-treachery'],
source,
beforeVersion: version,
...result,