Compose independent cards with original phase modules

This commit is contained in:
Quintine committed 2026-10-16 15:07:44 +11:00
1 parent 4313983a5c
commit 4a67e8c7be
17 files changed
+1005 -26

No files matched your search

+22
View File
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented. remain deferred until all rules are implemented.
**Independent cards with original phase modules — 16 October.** Fresh clean
classic/selected Ix/CHOAM profiles retain original unused Tech3–6 Basic/Advanced
and/or Strongholds2–6 Advanced, with independent cards before genuine setup.
Original first-Storm assignment, phase triggers/delayed income and earned
end-Mentat custody remain. R keeps normal-first/all-outcome fee before Harass;
returned counters are not free Revival/shipment, and +2 is not supported force.
No Homeworlds, Skills, Nexus, Discovery, E3 mixture or unrelated overlay is added;
Stone/Mirror guards, public starts and played-save conversion remain separate.
Genuine battles fill Revival Tanks and earn actual held cards. Five mobile
journeys/35 actions prove original Storm/HMS setup, bank2/personal1 Arrakeen,
native mixed-fee Tabr return and two doubled-rate Revivals. Axlotl holds2
through both free returns, then pays exactly2 at phase end. Four policies
complete20 battles/52 continuations and9564 validated R-or-H proposals plus16
useful Recruits programmes/120 actions. No Face Dance or special-counter revival
is claimed. Original first-Storm randomness is controlled in Revival fixtures;
no force/wallet/module-owner/phase/history is assigned. A genuine two-token loser
also completes its mandatory winner selection through four policies without
replaying fee/return. Frozen related30-file union passes325/325, including nine
new behavioral cases; types/lint, build and actual prototype help pass.
Existing build deprecation/chunk/route warnings remain. No deployment ran.
**Clean selected-family Reinforcements — 16 October.** Plain selected Ix and/or **Clean selected-family Reinforcements — 16 October.** Plain selected Ix and/or
CHOAM native/base rosters now reuse original independent38/50-card setup in CHOAM native/base rosters now reuse original independent38/50-card setup in
Basic/Advanced. Original normal-first typed/all-outcome payment, score-only +2, Basic/Advanced. Original normal-first typed/all-outcome payment, score-only +2,
+45
View File
@@ -146,3 +146,48 @@ optional modules remain gated. The [runtime boundary](REINFORCEMENTS_RUNTIME.md)
and [checkpoint](IMPLEMENTATION_STATUS.md#29-september-2026--reinforcements-in-paired-ecazmoritani-battles) and [checkpoint](IMPLEMENTATION_STATUS.md#29-september-2026--reinforcements-in-paired-ecazmoritani-battles)
record the exact evidence; the 27 September classic-only statement above is record the exact evidence; the 27 September classic-only statement above is
historical. historical.
## Original phase-module composition — 16 October 2026
The printed shared rule above permits the independent cards with other variants.
The fresh `ecaz-treachery` entry now retains original unused Tech Tokens at3–6
Basic/Advanced seats and/or Stronghold Cards at2–6 Advanced seats, with clean
classic or selected Ix/CHOAM native/base rosters. Both modules requires3–6
Advanced. Base33, CHOAM35 or Ix/union47 gains exactly three physical cards;
the original Richese cache remains separate.
Use original setup and native ownership: remaining Tech after first Storm,
Stronghold Cards only at actual end-Mentat. No owned module receipt, bank spice,
forces, phase or history is injected. This does not compose Homeworlds, Nexus,
Skills, Discovery, Sandtrout, mixed E3 or unrelated previews, convert played
games, or open public starts. Existing Stone/Mirror profile and allocation
guards are not widened.
Recruits changes current free rates/ordinary allowance, not the original Axlotl
once-per-phase trigger or delayed income. Eligible actual free revival triggers
that token once; Harass reserve deposits and the Reinforcements fee are not
free revival or off-planet shipment. Original Heighliners payment stays at
Shipment/Movement end, never available as anticipated spending money.
An earned held Stronghold still affects its original holder's actual plan.
Arrakeen pays up to two actual support from the bank, not two personal spice
or support for the score-only bonus. Tabr uses the actual opposing dial.
Reinforcements keeps the existing provisional normal-first/all-outcome fee
before Harass returns; typed fee and board casualties remain separate.
Original winner cleanup/Tech reward and native aftercare keep their order.
These inherited payment/order interpretations do not become publisher rulings.
Accepted runtime evidence and limits are recorded in the
[current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work); detailed
[Revival](RECRUITS_RUNTIME.md), [withdrawal](HARASS_WITHDRAW_RUNTIME.md) and
[reserve-fee](REINFORCEMENTS_RUNTIME.md) contracts remain applicable.
Bounded local proof: nine new behavioral cases and the frozen related30-file
union pass325/325; types/lint, build and actual prototype help pass. Five mobile
journeys/35 actions cover original Storm/HMS, earned Arrakeen bank2/personal1,
native Tabr fee/return and two doubled-rate Revivals with one delayed Axlotl
payment. Four policies complete20 battles/52 continuations/9564 R-or-H proposals,
16 useful Recruits programmes/120 actions, and four additional mandatory
two-token selections. No Face Dance route, new elite revival, complete mode,
calibration, broad assurance or deployed acceptance is claimed.
+4 -3
View File
@@ -222,9 +222,10 @@ route warnings remain; no HTTP or database boundary changed.
after reveal. Unique returns are automatic; previously valid legacy battles after reveal. Unique returns are automatic; previously valid legacy battles
retain unique-only derivation. See the [allocation contract](HARASS_ALLOCATION.md). retain unique-only derivation. See the [allocation contract](HARASS_ALLOCATION.md).
- Clean classic Homeworlds use the [native reserve-routing contract](#classic-homeworld-reserve-routing--16-october-2026). - Clean classic Homeworlds use the [native reserve-routing contract](#classic-homeworld-reserve-routing--16-october-2026).
Other optional modules remain gated, including Nexus, Leader Skills, Tech Without Homeworlds, clean classic/selected Ix/CHOAM rosters may separately retain
Tokens, Stronghold Cards and Discoveries; broader World/faction composition [original Tech3+ and Advanced Strongholds2+](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026).
and full-mode acceptance stay open. Nexus, Skills, Discoveries, broader World/faction composition and full-mode
acceptance remain guarded.
- Richese card-family configurations now admit Harass under the - Richese card-family configurations now admit Harass under the
[provisional post-withdrawal policy](#provisional-stone-burner-timing--16-october-2026). [provisional post-withdrawal policy](#provisional-stone-burner-timing--16-october-2026).
The ordinary invariant-allocation guard remains. Clean selected Ix follows The ordinary invariant-allocation guard remains. Clean selected Ix follows
+45
View File
@@ -1,5 +1,50 @@
# Dune implementation status # Dune implementation status
## 16 October 2026 — Independent cards with original phase modules
Original valid three-seat native ready lobbies rejected independent cards plus
Tech, Strongholds or both before setup. Compose only clean classic/selected
Ix/CHOAM native/base profiles with original unused Tech3–6 Basic/Advanced and/or
Strongholds2–6 Advanced. Original first-Storm token assignment, phase accrual,
delayed income and actual end-Mentat card custody remain. No Homeworlds,
Skills, Nexus, Discovery, mixed E3, other previews, public starts or played-save
cutover. Existing original/copied Stone profile/allocation guards stay.
One private profile gates original setup and all three card handlers; Harass
reuses the common mode check instead of maintaining a second module list.
Printed E3 variant independence supplies combination authority. Existing
normal-first/all-outcome fee-before-Harass remains explicitly provisional.
No new state/action, fake owner, wallet, force, phase or history is introduced.
Real paid battles fill Revival Tanks and earn held cards. Original remaining
Tech randomness is fixed only at first Storm for deterministic Revival proof;
custody still comes from the original engine. Physical card/identity positioning
is explicit conserved staging. Consolidated four Revival regressions retain
economic transitions, removing fixture/DTO/wording assertions; five battle
regressions retain earned subsidy, typed fee/return, Traitors and Tech rewards.
Five actual390×844 journeys/35 actions reach original continuation: Storm/HMS,
Arrakeen bank2/personal1, native mixed-fee Tabr, and two doubled-rate Revivals.
Axlotl holds2 through two free returns, then pays exactly2 at the phase boundary.
QA-only header wrapping/client keys isolate the harness; no production workaround.
Final browser error query is empty and all five accepted surfaces have no
horizontal overflow. Owned tab/host/scaffolds removed; artifacts under
`/tmp/dune-independent-card-modules-*-proof.json`.
Four policies finish20 battle programmes/52 after-seal continuations and validate
9564 R-or-H proposals, plus16 useful Recruits programmes/120 actions. Human
normal outcomes and policy sole/mutual-Traitor outcomes are separate evidence.
No native Face Dance route, new elite revival, complete mode, strategy,
calibration or deployed acceptance is claimed. Actual rooms/DBs remain untouched.
An additional genuine prior-win programme gives the next loser two actual Tech
tokens; all four policies select one and finish Collection at reserves14/Tanks6
without replaying fee/return. This is Node continuation proof, not mobile proof.
Frozen related30-file union passes325/325. Types/lint, build and actual prototype
help pass. Repair two fixture type errors/remove an unused import; delete the
incorrect blanket pending-Guild assertion (Basic commits immediately) and replace
the malformed decisive-Basic setup with an actual without-R loss/with-R victory.
Existing Node deprecation, large-chunk and dynamic-route build warnings remain.
## 16 October 2026 — Clean selected-family Reinforcements ## 16 October 2026 — Clean selected-family Reinforcements
Original independent setup supplied the physical card, but plain native Original independent setup supplied the physical card, but plain native
+4 -2
View File
@@ -98,8 +98,10 @@ source review. No newly retrieved late-paid ruling is claimed.
The scope permits activation before returns or after consistently recorded The scope permits activation before returns or after consistently recorded
free/paid normal returns, once priority windows and transactions finish. free/paid normal returns, once priority windows and transactions finish.
Unknown legacy usage, later Fremen grants, same-turn replay after recovery Unknown legacy usage, later Fremen grants, same-turn replay after recovery
and other optional modules remain guarded, apart from the clean classic and other optional modules remain guarded, apart from clean classic Homeworlds
Homeworld composition above. These are boundaries, not printed timing rules. above or the separate clean classic/Ix/CHOAM
[Tech/Advanced Stronghold composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026).
These are boundaries, not printed timing rules.
Already-ready players retain their Revival opportunity while phase four is open. Already-ready players retain their Revival opportunity while phase four is open.
Atreides doubles two to four, Fremen three to six, and Guild one to two. Native Atreides doubles two to four, Fremen three to six, and Guild one to two. Native
+6 -4
View File
@@ -4,7 +4,8 @@ The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements
card independently of choosing the Ecaz faction. Its bounded handler supports card independently of choosing the Ecaz faction. Its bounded handler supports
Basic/Advanced classic-faction tables, clean selected Ix and/or CHOAM native/base Basic/Advanced classic-faction tables, clean selected Ix and/or CHOAM native/base
rosters, and clean Ecaz/Moritani expansion tables. Fresh clean classic Homeworlds rosters, and clean Ecaz/Moritani expansion tables. Fresh clean classic Homeworlds
use the native-source composition below; other optional modules retain guards. use native sources below. Original Tech/Advanced Strongholds separately use the
[phase-module contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026).
The selected Advanced Occupy profile separately composes co-present armies. The selected Advanced Occupy profile separately composes co-present armies.
The holder may commit it in either Battle Plan card slot. It is neither a weapon 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 nor a defense: Prescience of a category receives **None**, while the physical
@@ -21,9 +22,10 @@ rosters in Basic and Advanced. This closes a profile-admission gap: original
independent pre-setup cards were present, but the plan rejected the profile. independent pre-setup cards were present, but the plan rejected the profile.
Original setup keeps selected35/47 plus three physical cards (38/50 total), Original setup keeps selected35/47 plus three physical cards (38/50 total),
native HMS/cyborgs, Face Dancers, Auditor and the separate ten-card Richese cache. native HMS/cyborgs, Face Dancers, Auditor and the separate ten-card Richese cache.
No Homeworlds, Nexus, Skills, Tech, Strongholds, Discovery, Sandtrout, mixed E3 No Homeworlds, Nexus, Skills, Discovery, Sandtrout, mixed E3 or unrelated preview
or unrelated preview is added. Existing classic, paired E3 and selected Advanced is added. Original Tech/Advanced Strongholds separately use the phase-module
Occupy branches remain separate; public starts and played-save conversion stay gated. contract above. Classic, paired E3 and selected Advanced Occupy remain separate;
public starts and played-save conversion stay gated.
Reuse the existing explicitly provisional normal-first, all-outcome fee and Reuse the existing explicitly provisional normal-first, all-outcome fee and
fee-before-Harass order, not a new publisher ruling. Native casualty origins fee-before-Harass order, not a new publisher ruling. Native casualty origins
+1
View File
@@ -666,6 +666,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) | | Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
| Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) | | Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) |
| Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) | | Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) |
| Independent Treachery / original phase modules | Printed E3 shared variant independence permits original unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters. Keep original setup, actual phase triggers/delayed income and earned end-Mentat custody. Reuse provisional R fee-before-Harass; returned counters are not free Revival/shipment and +2 is not supported force. No Homeworlds, Skills, Nexus, Discovery, E3 mixture or unrelated preview; existing Stone/Mirror guards remain. | [Source and composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026) |
| Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) | | Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) | | Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) | | Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
+11
View File
@@ -2,6 +2,17 @@
Historical development module checkpoint, 7 September 2026: the six printed effects, public card custody, mobile declaration, support accounting, AI choices and readable faces are integrated. The later user-authorized [classic Advanced preview](ADVANCED_PREVIEW.md) already permits Stronghold Cards; preserve that access. Complete Advanced/expansion release and arbitrary combinations remain gated. Historical development module checkpoint, 7 September 2026: the six printed effects, public card custody, mobile declaration, support accounting, AI choices and readable faces are integrated. The later user-authorized [classic Advanced preview](ADVANCED_PREVIEW.md) already permits Stronghold Cards; preserve that access. Complete Advanced/expansion release and arbitrary combinations remain gated.
## Independent Treachery composition — 16 October 2026
The fresh independent-card entry retains original unused Advanced Strongholds
at2–6 seats and optional Tech3–6, with clean classic/selected Ix/CHOAM native/base
rosters and no Homeworlds. Actual end-Mentat earns ownership; no card is assigned
by a battle fixture. Earned Arrakeen funds only actual support (bank2/personal1
for support3); native Ix earns Tabr before a real next-turn attack and mixed
Reinforcements fee/Harass return. Original token rewards/held effects stay.
No Stone/Mirror profile guard, other overlay, public start or played-save
conversion is widened. See the [shared source-bound contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026).
## Semuta composition — 11 October 2026 ## Semuta composition — 11 October 2026
The fresh `semuta` entry with native Richese, CHOAM and optional Ix decks now The fresh `semuta` entry with native Richese, CHOAM and optional Ix decks now
+3 -3
View File
@@ -118,10 +118,10 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
guidance: ecaz.summary, guidance: ecaz.summary,
gameplay: ecaz.gameplay, gameplay: ecaz.gameplay,
availability: ecaz.card.effect === 'recruits' availability: ecaz.card.effect === 'recruits'
? 'Recruits has development controls during Revival, including clean classic Basic/Advanced Homeworlds. Double the current native free rate, including low-population bonuses, within the seven-force total; separate requests recheck thresholds. Native typed deposits and special-force caps stay. Recorded prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and same-turn replay remain guarded.' ? 'Recruits has development controls during Revival, including clean classic Basic/Advanced Homeworlds. Separately, clean classic/Ix/CHOAM rosters support original Tech3+ and Advanced Strongholds2+ without Homeworlds. Double the current native free rate within the seven-force total; typed deposits, special-force caps and original once-per-phase Axlotl income stay. Recorded prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and replay remain guarded.'
: ecaz.card.effect === 'harassWithdraw' : ecaz.card.effect === 'harassWithdraw'
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Classic Homeworlds returns actual typed counters into native reserve custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic preserves its original primary World. The card is forbidden on any own Homeworld. Original sealed commitment/support remains. An own concealed No-Field separately follows provisional materialization first without resetting history. Stone compares post-withdrawal counters provisionally. Other profile/module limits remain guarded.' ? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Clean classic/Ix/CHOAM rosters compose original Tech3+ and Advanced Strongholds2+ without Homeworlds. Separately, classic Homeworlds returns typed counters through native custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic to its primary World. Never use this card on an own Homeworld. Original commitment/support stays. Own No-Field provisionally materializes first without resetting history. Stone compares post-withdrawal counters provisionally; its own profile/allocation guards remain.'
: 'Reinforcements works in either slot in clean selected Ix/CHOAM Basic/Advanced native/base rosters, classic tables including Homeworlds, and the selected Advanced Occupy variant. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. Native World sources seal explicitly when ambiguous; a fee here cannot also fund your own-World dial. Original face/support stays fixed. No-Field must leave three before this cost; future Harass returns cannot fund it. Original/copied Stone ignores +2 but still charges reserves. Cost/order interpretations are provisional; other profiles/modules remain guarded.', : 'Reinforcements works in either slot in clean selected Ix/CHOAM Basic/Advanced native/base rosters, classic tables including Homeworlds, and selected Advanced Occupy. Clean classic/Ix/CHOAM separately supports original Tech3+ and Advanced Strongholds2+ without Homeworlds. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. World sources seal when ambiguous; a fee here cannot also fund its dial. Original face/support stays. No-Field must leave three before payment; future Harass cannot fund it. Stone ignores +2 but charges reserves; its own profile guards stay. Cost/order interpretations remain provisional.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')), topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
}; };
const topicIds = const topicIds =
+28 -9
View File
@@ -10704,6 +10704,24 @@ function classicEcazHomeworldProfile(g: Game): boolean {
!g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout; !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout;
} }
/** Independent cards compose original phase modules, not unrelated previews. */
function independentCardModulesProfile(g: Game): boolean {
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) ||
g.players.length < (g.techTokens ? 3 : 2) || g.players.length > 6 ||
(g.strongholdCards && !g.advanced) || g.expansions.length > 2 ||
(g.expansions.length === 2 && g.expansions[0] === g.expansions[1]) ||
g.expansions.some(expansion => expansion !== 'ix' && expansion !== 'choam') ||
g.homeworlds || g.nexusCards || g.leaderSkills ||
g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.sandtrout ||
g.ecazOccupyPreview || g.semutaPreview || g.kullPreview || g.nexusKullPreview ||
g.richeseBetrayalPreview || g.guildBetrayalPreview || g.moritaniAssassinatePreview ||
g.spiceBankerIncomePreview || g.nexusIxianReplacementPreview ||
g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview) return false;
return g.players.every(player => {
const family = faction(player.faction).expansion;
return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family));
});
}
/** Gated development setup for the independent three-card Ecaz variant. */ /** Gated development setup for the independent three-card Ecaz variant. */
export function initializeEcazTreacheryGameForAudit(state: Game): Game { export function initializeEcazTreacheryGameForAudit(state: Game): Game {
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.'); requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
@@ -10736,10 +10754,11 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) ||
mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
pairedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); pairedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && const independentModules = ecazTreachery && independentCardModulesProfile(g);
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || homeworldSkills || nexusModules || discoveryComposition) && requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules) &&
JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())), JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())),
'Stronghold Cards require a fresh supported Advanced lobby with unused cards.'); 'Stronghold Cards require a fresh supported Advanced lobby with unused cards.');
requireRule( requireRule(
@@ -10750,8 +10769,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(discovery || !g.discoveryEnabled) && (discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) && (nexus || !g.nexusCards) &&
(ecazTreachery || !g.ecazTreachery) && (ecazTreachery || !g.ecazTreachery) &&
(!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition) && (!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
(strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || !g.strongholdCards) && (strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules || !g.strongholdCards) &&
(homeworlds || !g.homeworlds) && (homeworlds || !g.homeworlds) &&
g.players.every((p) => g.players.every((p) =>
FACTIONS.some( FACTIONS.some(
@@ -13755,12 +13774,13 @@ function ecazTreacheryIntegrity(g: Game) {
} }
} }
function recruitsModeSupported(g: Game) { function recruitsModeSupported(g: Game) {
const modules = independentCardModulesProfile(g);
return ( return (
(!g.homeworlds || classicEcazHomeworldProfile(g)) && (!g.homeworlds || classicEcazHomeworldProfile(g)) &&
!g.nexusCards && !g.nexusCards &&
!g.leaderSkills && !g.leaderSkills &&
!g.techTokens && (!g.techTokens || modules) &&
!g.strongholdCards && (!g.strongholdCards || modules) &&
!g.discoveryEnabled !g.discoveryEnabled
); );
} }
@@ -13867,7 +13887,7 @@ function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null {
if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID) if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID)
return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.'; return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.';
if (!recruitsModeSupported(g)) if (!recruitsModeSupported(g))
return 'This Recruits prototype requires a faction-only game or clean classic Homeworlds, without other optional modules.'; return 'Recruits requires a supported independent-card profile: faction-only, clean classic Homeworlds, or clean classic/Ix/CHOAM with original Tech and Advanced Strongholds.';
if (g.status !== 'playing' || g.phase !== 4) if (g.status !== 'playing' || g.phase !== 4)
return 'Play Recruits during Revival.'; return 'Play Recruits during Revival.';
if (g.recruits?.turn === g.turn) if (g.recruits?.turn === g.turn)
@@ -20762,8 +20782,7 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
function harassModuleBlock(g: Game): string | null { function harassModuleBlock(g: Game): string | null {
if (!g.ecazTreachery) if (!g.ecazTreachery)
return 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.'; return 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
if ((g.homeworlds && !classicEcazHomeworldProfile(g)) || if (!recruitsModeSupported(g))
g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
return 'Harass & Withdraw with these other optional modules is still being implemented.'; return 'Harass & Withdraw with these other optional modules is still being implemented.';
return combinedCardBattleBlock(g); return combinedCardBattleBlock(g);
} }
+8 -4
View File
@@ -3414,6 +3414,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.', 'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.',
'Fresh Advanced local development tables may combine all fourteen Leader Skills with Stronghold Cards in classic or supported Ixian, Tleilaxu, CHOAM and Richese skill rosters, two through six players; optional Tech Tokens needs three through six. Original deck/cache and end-Mentat claims remain. Arrakeen pays bank support, not personal income; only the remaining actual payer debit qualifies separate normal Banker income. Skill rescue preserves the original support charge, then card cleanup and Tech reward finish before Face Dance. Modified/copied Diplomat defenses and active skill/Stone interpretations stay guarded; public activation, E3/other overlays and save conversion remain separate.', 'Fresh Advanced local development tables may combine all fourteen Leader Skills with Stronghold Cards in classic or supported Ixian, Tleilaxu, CHOAM and Richese skill rosters, two through six players; optional Tech Tokens needs three through six. Original deck/cache and end-Mentat claims remain. Arrakeen pays bank support, not personal income; only the remaining actual payer debit qualifies separate normal Banker income. Skill rescue preserves the original support charge, then card cleanup and Tech reward finish before Face Dance. Modified/copied Diplomat defenses and active skill/Stone interpretations stay guarded; public activation, E3/other overlays and save conversion remain separate.',
'The original standalone Ecaz or Moritani skill roster additionally supports these Advanced combinations with the exact ecaz deck, original five-disc training and native exclusions. Normal Warmaster one on a temporary Duke composes Carthag Shield without granting trained three; normal winner Suk precedes unused-disc Moritani assassination, printed bounty, retained winner Tech and one private Mentat replacement. E3 pairs, mixtures and allied Occupy skills stay separate.', 'The original standalone Ecaz or Moritani skill roster additionally supports these Advanced combinations with the exact ecaz deck, original five-disc training and native exclusions. Normal Warmaster one on a temporary Duke composes Carthag Shield without granting trained three; normal winner Suk precedes unused-disc Moritani assassination, printed bounty, retained winner Tech and one private Mentat replacement. E3 pairs, mixtures and allied Occupy skills stay separate.',
'Fresh independent Treachery previews separately compose clean classic or selected Ix/CHOAM native/base rosters with Advanced Strongholds2–6 and optional Tech3–6, without Homeworlds. Actual end-Mentat earns custody. Reinforcements adds no supported counters; Harass vacates control without transferring a retained card early. Original card effects, costs and winner Tech order remain, while Stone/Mirror profile guards and unrelated modules stay.',
'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.', 'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.',
], ],
example: example:
@@ -3521,6 +3522,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.', 'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
'Token ownership and pending income are public and persist with the room. Fresh supported classic, E1/E2 native or standalone Ecaz/Moritani Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, mixed E3/other combinations and full combined acceptance remain unfinished.', 'Token ownership and pending income are public and persist with the room. Fresh supported classic, E1/E2 native or standalone Ecaz/Moritani Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, mixed E3/other combinations and full combined acceptance remain unfinished.',
'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.', 'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.',
'Clean classic or selected Ix/CHOAM native/base independent-card previews retain unused Tech3–6 Basic/Advanced, optionally Advanced Strongholds2–6. Recruits changes free rates without repeating Axlotl or advancing its payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income and winner custody stay; Homeworlds and other overlays remain separate.',
], ],
example: example:
'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.', 'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.',
@@ -4977,6 +4979,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Missing/inconsistent ledgers, later Fremen grants, pending transactions and same-turn replay remain guarded.', 'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Missing/inconsistent ledgers, later Fremen grants, pending transactions and same-turn replay remain guarded.',
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
'Without Homeworlds, clean classic or selected Ix/CHOAM native/base rosters may retain original unused Tech Tokens at3–6 and/or Stronghold Cards at2–6 Advanced. Setup, first-Storm Tech assignment and actual end-Mentat Stronghold custody remain original. Recruits changes free rates, not the once-per-phase Axlotl trigger or delayed payout; Harass returns are not free Revival.',
], ],
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'], related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
checklist: [ checklist: [
@@ -4984,7 +4987,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] }, { area: 'Documentation', status: 'Implemented', detail: 'The guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Ten new native regressions cover bulk7 versus split6 at a population crossing, Basic/Advanced typed caps, already-ready paid returns and Emperor destinations. Four actual mobile journeys and16 fresh four-policy programmes/104 accepted card/Revival actions reach usable native continuation. Existing authenticated recovery evidence is separate; full variant/mode/calibration/final assurance stays open.', evidence: ['tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] }, { area: 'Verification', status: 'Partial', detail: 'Earlier World proof: ten native cases, four mobile journeys and16 four-policy programmes/104 actions cover rates, typed caps, prior paid returns and destinations. Separate original phase-module proof adds genuine battle-filled Tanks, two mobile Revival journeys and16 useful four-policy programmes/120 actions: doubled rates, paid remainder and one delayed Axlotl payout after two free revivals. No new special-counter revival or complete-mode/calibration claim.', evidence: ['tests/recruits-modules.test.ts', 'tests/fixture-recruits-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
], ],
}, },
{ {
@@ -5002,7 +5005,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.', 'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.',
'Clean classic2–6 Basic/Advanced Homeworlds use exact native normal-first fee sources, sealed in the plan. Ambiguous omissions reject; unique sources may derive. An own-World fee cannot also fund physical dial/casualties, but original printed face/support remains. Original high-face explosion losses survive a fee crossing low. Native-only Traitors, typed Tanks and cost-before-Harass remain; this is explicit provisional composition, not a new card FAQ.', 'Clean classic2–6 Basic/Advanced Homeworlds use exact native normal-first fee sources, sealed in the plan. Ambiguous omissions reject; unique sources may derive. An own-World fee cannot also fund physical dial/casualties, but original printed face/support remains. Original high-face explosion losses survive a fee crossing low. Native-only Traitors, typed Tanks and cost-before-Harass remain; this is explicit provisional composition, not a new card FAQ.',
'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.', 'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.',
'Clean selected Ix and/or CHOAM families retain original Basic/Advanced setup and independent cards, with no Homeworlds or other optional modules/overlays. Original cyborg substitution and Face Dance apply to their own native casualties, not a second copy of the reserve fee. Richese No-Field reveal precedes the fee; physical original/copied Stone ignores the score bonus. Other family/module combinations and public starts remain guarded.', 'Clean selected Ix and/or CHOAM families retain original Basic/Advanced setup and independent cards. Native cyborg substitution and Face Dance use their original physical pools, not a second copy of the reserve fee. Richese No-Field reveal precedes payment; physical Stone ignores +2. Separately retain original Tech3+ and/or Advanced Strongholds2+ without Homeworlds, using actual phase income/earned custody; other family/module and Stone profile guards stay.',
], ],
related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'], related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'],
checklist: [ checklist: [
@@ -5010,7 +5013,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.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: '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: '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: 'Native actual setup/shipment/movement covers both slots, typed fees, deficient funding, exact World sources, disjointness, frozen face/explosion, Traitors, Harass and named promises. Earlier World proof: six mobile journeys and32 four-policy programmes/114 continuations/1682 valid R proposals. Clean selected-family proof adds seven mobile journeys and48 programmes/194 continuations/3022 proposals, including genuine cyborg, Face Dance, Auditor and marker/Stone paths. Bounded proof, not complete modes or calibration.', evidence: ['tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] }, { area: 'Verification', status: 'Partial', detail: 'Native setup/shipment covers typed fees, deficient funding, exact World sources, disjointness, frozen face/explosion, Traitors, Harass and named promises. Earlier World proof: six mobile/32 programmes/114 continuations/1682 R proposals. Clean families: seven mobile/48 programmes/194 continuations/3022 proposals. Phase modules: two mobile battles/20 programmes/52 continuations/9564 R-or-H proposals with earned bank support, Tabr and Tech rewards. Bounded legal proof, not complete modes or calibration.', evidence: ['tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
], ],
}, },
{ {
@@ -5030,6 +5033,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.', 'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.', 'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters remain fixed across thresholds. Native-only Traitors, late defense/explosion and disposal stay. Recruits has native current rates; Reinforcements seals exact original native fees before these returns. Other overlays/rosters remain guarded.', 'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters remain fixed across thresholds. Native-only Traitors, late defense/explosion and disposal stay. Recruits has native current rates; Reinforcements seals exact original native fees before these returns. Other overlays/rosters remain guarded.',
'Without Homeworlds, clean classic or selected Ix/CHOAM native/base rosters compose original Tech3+ and/or Advanced Strongholds2+. An earned held Stronghold still affects only its original eligible plan/actual support; +2 Reinforcements adds no supported counters. Harass deposits are not an off-planet shipment or free Revival. Original winner card cleanup/Tech reward precedes native Face Dance.',
], ],
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'], related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
checklist: [ checklist: [
@@ -5037,7 +5041,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Either slot and native typed allocation remain. World names, Basic versus Advanced reserve destinations and fixed-commitment timing are explicit. Invaders no longer receive forbidden Traitor controls. Four actual390px journeys complete Basic foreign loss, Advanced foreign victory, Salusa threshold return and own-Salusa ban/plain alternative into Collection.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx', 'components/harass-withdraw-choice.tsx'] }, { area: 'Player controls', status: 'Partial', detail: 'Either slot and native typed allocation remain. World names, Basic versus Advanced reserve destinations and fixed-commitment timing are explicit. Invaders no longer receive forbidden Traitor controls. Four actual390px journeys complete Basic foreign loss, Advanced foreign victory, Salusa threshold return and own-Salusa ban/plain alternative into Collection.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx', 'components/harass-withdraw-choice.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use legal shared precommit quotes and exact after-reveal allocations without an invader vote. Sixteen native World programs accept36 continuation actions and validate original precommit proposals through normal/explosion/own-World alternatives. Retreat strategy and combined strength remain deferred.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use legal shared precommit quotes and exact after-reveal allocations without an invader vote. Sixteen native World programs accept36 continuation actions and validate original precommit proposals through normal/explosion/own-World alternatives. Retreat strategy and combined strength remain deferred.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed own-World restriction and explicit native destination/fixed-commitment composition are separated from original materialization/Stone policies and remaining faction/module/final gates.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] }, { area: 'Documentation', status: 'Partial', detail: 'Printed own-World restriction and explicit native destination/fixed-commitment composition are separated from original materialization/Stone policies and remaining faction/module/final gates.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Native Homeworld regressions cover real typed invasion/Arrakis shipments, both slots/modes, selected full commitment, Basic primary/Advanced split reserves, native-only Traitors, winner disposal, explosion exception and Salusa threshold follow-on. Four mobile and16 policy programs reach original Collection. Earlier No-Field/Stone/recovery evidence remains historical, not complete-mode/new assurance or deployed acceptance.', evidence: ['tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] }, { area: 'Verification', status: 'Partial', detail: 'Earlier native World cases cover real typed invasion/Arrakis shipments, both slots/modes, selected commitment, reserve destinations, Traitors, disposal, explosion and thresholds; four mobile/16 policy programmes reach Collection. Separate phase-module proof adds earned Arrakeen/Tabr, typed fee-before-return and actual Tech awards: two mobile battles and20 four-policy programmes/52 continuations/9564 R-or-H proposals. No Face Dance route or complete-mode/deployed/calibration claim.', evidence: ['tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
], ],
}, },
{ {
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
MODULE_HARASS, MODULE_REINFORCEMENTS, battleCardModulesPlayer,
callBattleCardModules, createBattleCardModulesFixture, finishBattleCardModules,
sealBattleCardModules, type BattleCardModulesFixture,
} from './fixture-battle-card-modules';
const physicalCards = (game: Game) => [
...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
].map(card => card.id).sort();
function assertUtilitiesDiscarded(game: Game) {
for (const id of [MODULE_REINFORCEMENTS, MODULE_HARASS]) {
assert.equal(game.discard.filter(card => card.id === id).length, 1);
assert.ok(game.players.every(player => player.hand.every(card => card.id !== id)));
}
assert.equal(game.battle, null);
assert.equal(game.decision, null);
}
function assertMandatoryTechTransfer(fixture: BattleCardModulesFixture, done: Game,
winner: string, loser: string) {
const lost = TECH_TOKENS.filter(token => fixture.game.techTokens![token.id].owner === loser);
assert.equal(lost.length, 1, 'The original three-seat distribution gives this loser one actual token');
for (const token of TECH_TOKENS) {
assert.equal(done.techTokens![token.id].owner,
lost.includes(token) ? winner : fixture.game.techTokens![token.id].owner);
assert.equal(done.techTokens![token.id].spice, 0);
}
}
function assertOriginalShipmentIncome(fixture: BattleCardModulesFixture) {
const { beforeShipment: before, beforeBattle: settled } = fixture;
const recipient = before.techTokens!.heighliners.owner;
assert.ok(recipient);
for (const player of before.players) {
const expected: number = player.spice - (player.id === fixture.opponent ? 4 : 0) +
(player.faction === 'guild' ? 4 : 0) + (player.id === recipient ? 1 : 0);
assert.equal(battleCardModulesPlayer(settled, player.id).spice, expected,
'Paid native shipment, Guild income and exactly one original Heighliners phase-end payment');
}
assert.equal(settled.techTokens!.heighliners.triggeredTurn, 2);
assert.equal(settled.techTokens!.heighliners.spice, 0);
assert.equal(battleCardModulesPlayer(settled, fixture.opponent).forces[fixture.location], 4);
}
void test('earned Arrakeen pays only actual support: bank-only two versus bank two plus personal one, with genuine R+H losses', () => {
for (const dial of [2, 3]) {
const fixture = createBattleCardModulesFixture({ dial,
slot: dial === 2 ? 'defense' : 'weapon' });
const { game, owner, opponent } = fixture;
assert.equal(fixture.beforeMentat.turn, 1);
assert.equal(fixture.beforeMentat.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(fixture.beforeMentat.strongholdCards!.owners).every(id => id === null));
const earned = applyAction(fixture.beforeMentat, fixture.mentatStep.actor, fixture.mentatStep.action);
assert.equal(earned.strongholdCards!.claimedTurn, 1);
assert.equal(earned.strongholdCards!.owners.arrakeen, owner);
assert.equal(fixture.afterMentat.strongholdCards!.owners.arrakeen, owner);
assert.equal(viewGame(game, owner).battle!.strongholdEffects[owner], 'arrakeen');
assertOriginalShipmentIncome(fixture);
assert.deepEqual(physicalCards(game), physicalCards(fixture.afterSetup));
const before = battleCardModulesPlayer(game, owner);
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
const after = battleCardModulesPlayer(done, owner);
assert.equal(done.lastBattleContext?.winner, owner);
assert.equal(after.spice, before.spice - Math.max(0, dial - 2),
'The subsidy is not a personal two-spice grant and R adds no support cost');
assert.equal(after.reserves, before.reserves - 3 + 10 - dial);
assert.equal(after.tanks, before.tanks + 3 + dial);
assert.equal(after.forces[fixture.location] ?? 0, 0);
assert.equal(done.strongholdCards!.owners.arrakeen, owner,
'Harass vacates physical control without fabricating a mid-turn card transfer');
assert.equal(done.strongholdCards!.claimedTurn, 1);
assertMandatoryTechTransfer(fixture, done, owner, opponent);
assertUtilitiesDiscarded(done);
assert.equal(done.lastBattleContext?.winnerDiscards?.completed, true);
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
}
});
void test('native Ix earns Sietch Tabr, spends original mixed reserves before returning suboids, then receives real victory income and Tech', () => {
const fixture = createBattleCardModulesFixture({ route: 'ix-sietch' });
const { game, owner, opponent } = fixture;
assert.equal(fixture.afterSetup.players.find(player => player.id === owner)!.forces[fixture.location] ?? 0, 0);
assert.equal(fixture.afterMentat.strongholdCards!.owners.sietch_tabr, owner);
assert.equal(battleCardModulesPlayer(fixture.afterMentat, owner).forces[fixture.location], 8);
assertOriginalShipmentIncome(fixture);
const before = battleCardModulesPlayer(game, owner);
const preview = viewGame(game, owner).battle!;
assert.equal(preview.strongholdEffects[owner], 'sietch_tabr');
assert.equal(preview.reinforcements!.normal, 2);
assert.equal(preview.reinforcements!.elite, 1);
assert.equal(before.reserves, 6);
assert.equal(before.elites!.reserves, 4);
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
const after = battleCardModulesPlayer(done, owner);
assert.equal(done.lastBattleContext?.winner, owner);
assert.equal(after.reserves, 9, 'Six original reserves minus fee three plus six undialed suboids');
assert.equal(after.elites!.reserves, 3,
'Returned normal suboids cannot replace the original cyborg in the normal-first fee');
assert.equal(after.tanks, 5, 'Three reserve-fee counters plus two dialed suboids');
assert.equal(after.elites!.tanks, 1);
assert.equal(after.forces[fixture.location] ?? 0, 0);
assert.equal(after.forces['hidden_mobile_stronghold:0'], 6, 'Original unrelated mobile army stays untouched');
assert.equal(after.spice, before.spice + 2, 'Earned Sietch Tabr pays the actual opposing dial');
assertMandatoryTechTransfer(fixture, done, owner, opponent);
assertUtilitiesDiscarded(done);
assert.equal(done.lastBattleContext?.winnerDiscards?.completed, true);
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
});
void test('Basic Tech battle gets the decisive score-only R bonus while Harass returns only the seven undialed counters', () => {
const fixture = createBattleCardModulesFixture({ advanced: false, ownerLeader: 'weakest', opponentDial: 3 });
const { owner, opponent, game } = fixture;
assert.equal(game.strongholdCards, undefined);
assertOriginalShipmentIncome(fixture);
let withoutR = applyAction(game, owner, { ...fixture.plans.both.action, weapon: null });
withoutR = applyAction(withoutR, opponent, fixture.plans.opponent.action);
const plain = finishBattleCardModules(callBattleCardModules(withoutR));
assert.equal(plain.lastBattleContext?.winner, opponent);
assert.equal(battleCardModulesPlayer(plain, owner).reserves, 17);
assert.equal(battleCardModulesPlayer(plain, owner).tanks, 3);
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
const after = battleCardModulesPlayer(done, owner);
assert.equal(done.lastBattleContext?.winner, owner);
assert.equal(after.reserves, 14);
assert.equal(after.tanks, 6);
assert.equal(after.spice, battleCardModulesPlayer(game, owner).spice);
assertMandatoryTechTransfer(fixture, done, owner, opponent);
assertUtilitiesDiscarded(done);
});
void test('an actual conserved opposing traitor cancels Harass, not the Basic reserve fee or the actual winner Tech award', () => {
const fixture = createBattleCardModulesFixture({ advanced: false, opposingTraitor: true });
const { owner, opponent } = fixture;
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture), [opponent]));
const after = battleCardModulesPlayer(done, owner);
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, opponent);
assert.equal(after.reserves, 7, 'The original fee is paid even on the traitor outcome');
assert.equal(after.tanks, 13, 'All ten on-board counters die when the opposing traitor cancels the return');
assert.equal(after.forces[fixture.location] ?? 0, 0);
assertMandatoryTechTransfer(fixture, done, opponent, owner);
assertUtilitiesDiscarded(done);
assert.deepEqual(physicalCards(done), physicalCards(fixture.afterSetup));
});
void test('the genuine two-seat Advanced Strongholds-only battle earns Arrakeen without requiring or manufacturing Tech rewards', () => {
const fixture = createBattleCardModulesFixture({ players: 2, tech: false });
const { owner } = fixture;
assert.equal(fixture.game.techTokens, undefined);
assert.equal(fixture.afterMentat.strongholdCards!.owners.arrakeen, owner);
const done = finishBattleCardModules(callBattleCardModules(sealBattleCardModules(fixture)));
const after = battleCardModulesPlayer(done, owner);
assert.equal(done.lastBattleContext?.winner, owner);
assert.equal(after.spice, battleCardModulesPlayer(fixture.game, owner).spice - 1);
assert.equal(after.reserves, 14);
assert.equal(after.tanks, 6);
assert.equal(done.techTokens, undefined);
assertUtilitiesDiscarded(done);
});
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import {
advanceIndependentCardModules, createIndependentCardModulesFixture,
nextIndependentCardModulesStep, type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
export const MODULE_REINFORCEMENTS = 'ecaz-reinforcements';
export const MODULE_HARASS = 'ecaz-harass-withdraw';
export type BattleCardModulesStep = IndependentCardModulesStep;
export type BattleCardModulesOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Native Ixian normal-first mixed fee plus earned Sietch Tabr income. */
route?: 'arrakeen' | 'ix-sietch';
dial?: number;
opponentDial?: number;
ownerLeader?: 'strongest' | 'weakest';
/** Conserved physical identity exchange, not a synthetic traitor receipt. */
opposingTraitor?: boolean;
slot?: 'weapon' | 'defense';
/** Two-seat Advanced Strongholds without Tech uses the same original route. */
players?: 2 | 3;
};
export type BattleCardModulesFixture = {
initial: Game;
afterSetup: Game;
beforeMentat: Game;
mentatStep: BattleCardModulesStep;
afterMentat: Game;
beforeShipment: Game;
shipmentStep: BattleCardModulesStep;
afterShipment: Game;
beforeBattle: Game;
battleStep: BattleCardModulesStep;
/** Both original combatants may now seal their plans. */
game: Game;
owner: string;
opponent: string;
location: string;
route: 'arrakeen' | 'ix-sietch';
plans: { both: BattleCardModulesStep; opponent: BattleCardModulesStep };
nativeSteps: BattleCardModulesStep[];
staging: string[];
};
export function battleCardModulesPlayer(game: Game, actor: string) {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original ${actor} seat`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Only actual native decisions; unsealed plans deliberately remain human work. */
export function nextBattleCardModulesStep(game: Game): BattleCardModulesStep | null {
return nextIndependentCardModulesStep(game);
}
export function advanceBattleCardModules(game: Game, until: (state: Game) => boolean,
steps: BattleCardModulesStep[] = []): Game {
return advanceIndependentCardModules(game, until, steps);
}
/** Explicit physical staging only. All original cards retain exactly one zone. */
function positionCard(game: Game, actor: string, id: string, staging: string[]) {
const player = battleCardModulesPlayer(game, actor);
if (player.hand.some(card => card.id === id)) return;
const zones: { label: string; cards: Card[] }[] = [
{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
...game.players.filter(p => p.id !== actor).map(p => ({ label: `${p.id}'s hand`, cards: p.hand })),
];
const source = zones.find(zone => zone.cards.some(card => card.id === id));
assert.ok(source, `Original physical ${id} is missing`);
if (player.hand.length >= 4) {
const index = player.hand.findIndex(card => ![MODULE_HARASS, MODULE_REINFORCEMENTS].includes(card.id));
assert.ok(index >= 0);
const [returned] = player.hand.splice(index, 1);
game.deck.push(returned);
staging.push(`Physical staging: ${returned.id}, ${actor}'s hand → remaining original deck (printed hand limit).`);
}
player.hand.push(source.cards.splice(source.cards.findIndex(card => card.id === id), 1)[0]);
staging.push(`Physical staging: conserved ${id}, ${source.label} → ${actor}'s hand; no purchase or replacement face.`);
}
function positionTraitor(game: Game, actor: string, identity: string, staging: string[]) {
const player = battleCardModulesPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const reserve = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(reserve >= 0 || donor, 'Requested original identity must still have physical custody');
if (reserve >= 0) game.traitorReserve![reserve] = returned;
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Physical staging: conserved traitor ${identity} from ${donor?.id ?? 'original reserve'} exchanged for ${actor}'s ${returned}.`);
}
/**
* Original setup → paid first-turn Ix shipment (when selected) → original END
* Mentat ownership → paid next-turn attack → original Heighliners settlement →
* native battle/preparation. No phase, wallet, force, owner, control or history
* assignment. Original Storm Cards remain unmodified; every possible turn-two
* distance leaves these printed strongholds safe after zero opening dials.
*/
export function createBattleCardModulesFixture(options: BattleCardModulesOptions = {}): BattleCardModulesFixture {
const route = options.route ?? 'arrakeen';
const advanced = options.advanced ?? true;
const tech = options.tech ?? true;
const strongholds = options.strongholds ?? advanced;
const players = options.players ?? 3;
assert.ok(!strongholds || advanced, 'Stronghold Cards require Advanced');
assert.ok(!tech || players === 3, 'Original Tech setup requires three seats');
assert.ok(route !== 'ix-sietch' || advanced, 'The mixed native fee route is Advanced');
assert.ok(route !== 'ix-sietch' || players === 3, 'The native Ix route retains its original three-seat roster');
const source = createIndependentCardModulesFixture({ advanced, tech, strongholds,
factions: route === 'ix-sietch' ? ['ixians', 'atreides', 'guild'] :
players === 2 ? ['emperor', 'atreides'] : ['emperor', 'atreides', 'guild'],
expansions: route === 'ix-sietch' ? ['ix'] : [],
});
let game = source.game;
const afterSetup = structuredClone(game);
const nativeSteps = source.steps;
const staging: string[] = [];
const owner = route === 'ix-sietch' ? 'ixians' : 'atreides';
const opponent = route === 'ix-sietch' ? 'atreides' : 'emperor';
const territory = route === 'ix-sietch' ? 'sietch_tabr' : 'arrakeen';
const sector = route === 'ix-sietch' ? 14 : 10;
const location = `${territory}:${sector}`;
const act = (step: BattleCardModulesStep): Game => {
nativeSteps.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
return game;
};
const advance = (until: (state: Game) => boolean) => {
game = advanceBattleCardModules(game, until, nativeSteps);
};
if (route === 'ix-sietch') {
advance(g => g.turn === 1 && g.phase === 5 && g.active === owner && clean(g));
// The original fourteen reserves contain ten normal and four cyborgs.
// Shipping eight normal leaves precisely two normal for the three-force fee.
act({ actor: owner, action: { type: 'ship', territory, sector, amount: 8, elite: 0 } });
}
advance(g => g.turn === 1 && g.phase === 8 && clean(g));
if (strongholds) {
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(id => id === null));
}
let beforeMentat: Game | undefined;
let mentatStep: BattleCardModulesStep | undefined;
while (game.turn === 1) {
const next = nextBattleCardModulesStep(game);
assert.ok(next);
const before = structuredClone(game);
const after = act(next);
if (after.turn === 2) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
if (strongholds) {
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners[territory], owner);
}
const afterMentat = structuredClone(game);
advance(g => g.turn === 2 && g.phase === 5 && g.active === opponent && clean(g));
const beforeShipment = structuredClone(game);
const shipmentStep: BattleCardModulesStep = { actor: opponent,
action: { type: 'ship', territory, sector, amount: 4, elite: 0 } };
act(shipmentStep);
const afterShipment = structuredClone(game);
advance(g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === territory);
assert.ok(choice, 'Only the actual paid native armies may open this battle');
const battleStep: BattleCardModulesStep = { actor: choice.chooser,
action: { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
advance(g => !!g.battle && !g.battle.preparation &&
(!g.battle.preLeader || g.battle.preLeader.closed) && clean(g));
positionCard(game, owner, MODULE_REINFORCEMENTS, staging);
positionCard(game, owner, MODULE_HARASS, staging);
const available = (actor: string) => battleCardModulesPlayer(game, actor).leaders
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === territory));
const ownLeader = available(owner).sort((a, b) =>
(options.ownerLeader === 'weakest' ? a.strength - b.strength : b.strength - a.strength) ||
a.id.localeCompare(b.id))[0];
const otherLeader = available(opponent).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && otherLeader);
if (options.opposingTraitor) positionTraitor(game, opponent, ownLeader.id, staging);
const dial = options.dial ?? (route === 'ix-sietch' ? 1 : 3);
const opponentDial = options.opponentDial ?? (route === 'ix-sietch' ? 2 : 0);
const slot = options.slot ?? 'weapon';
const plans = {
both: { actor: owner, action: { type: 'battlePlan', dial, support: advanced && route !== 'ix-sietch' ? dial : 0,
leader: ownLeader.id, weapon: slot === 'weapon' ? MODULE_REINFORCEMENTS : MODULE_HARASS,
defense: slot === 'weapon' ? MODULE_HARASS : MODULE_REINFORCEMENTS } },
opponent: { actor: opponent, action: { type: 'battlePlan', dial: opponentDial,
support: advanced ? opponentDial : 0, leader: otherLeader.id } },
} satisfies Record<string, BattleCardModulesStep>;
return { initial: source.initial, afterSetup, beforeMentat, mentatStep, afterMentat,
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, game,
owner, opponent, location, route, plans, nativeSteps, staging };
}
export function sealBattleCardModules(fixture: BattleCardModulesFixture,
steps: BattleCardModulesStep[] = []): Game {
let game = fixture.game;
for (const step of [fixture.plans.both, fixture.plans.opponent]) {
steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
return game;
}
export function callBattleCardModules(game: Game, calls: readonly string[] = [],
steps: BattleCardModulesStep[] = []): Game {
for (const actor of viewGame(game, game.host).battle!.traitorVoters) {
const step: BattleCardModulesStep = { actor, action: { type: 'traitorCall', call: calls.includes(actor) } };
steps.push(step); game = applyAction(game, actor, step.action);
}
return game;
}
/** Completes original mandatory Tech/cleanup/aftercare, including automatic phase income. */
export function finishBattleCardModules(game: Game, steps: BattleCardModulesStep[] = []): Game {
return advanceBattleCardModules(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard, steps);
}
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type IndependentCardModulesStep = { actor: string; action: Action };
export type IndependentCardModulesOptions = {
advanced?: boolean;
factions?: readonly FactionId[];
expansions?: Game['expansions'];
tech?: boolean;
strongholds?: boolean;
};
/** Select original modules and independent cards before the undealt native setup.
* No armies, wallets, module owners, phase or history are assigned afterward. */
export function createIndependentCardModulesFixture(options: IndependentCardModulesOptions = {}) {
const factions = options.factions ?? ['emperor', 'atreides', 'guild'];
const advanced = options.advanced ?? false;
assert.ok(factions.length >= 2);
let game = createGame('INDEPENDENTCARDMODULES', newPlayer(factions[0], factions[0], factions[0]),
advanced, options.expansions ?? []);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
if (options.tech ?? true) game.techTokens = createTechTokens();
if (options.strongholds) game.strongholdCards = createStrongholdCards();
const steps: IndependentCardModulesStep[] = [];
for (const player of game.players) {
const step: IndependentCardModulesStep = { actor: player.id, action: { type: 'ready' } };
steps.push(step); game = applyAction(game, step.actor, step.action);
}
const initial = structuredClone(game);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { game = initializeEcazTreacheryGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
game = advanceIndependentCardModules(game, state => state.status === 'playing', steps);
return { initial, game, steps };
}
/** Reuse original native choices, including all phase/module windows. */
export function nextIndependentCardModulesStep(game: Game): IndependentCardModulesStep | null {
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIndependentCardModules(game: Game, until: (state: Game) => boolean,
steps: IndependentCardModulesStep[] = []): Game {
let state = game;
for (let count = 0; count < 1800; count++) {
if (until(state)) return state;
assert.notEqual(state.status, 'finished', 'Programme ended before its requested native boundary');
const step = nextIndependentCardModulesStep(state);
assert.ok(step, 'Supply the actual human Battle Plans before continuing');
steps.push(structuredClone(step)); state = applyAction(state, step.actor, step.action);
}
throw Error('Independent module programme did not reach its requested boundary');
}
+304
View File
@@ -0,0 +1,304 @@
import assert from 'node:assert/strict';
import {
applyAction, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { ownedTech } from '../game/tech-tokens';
import { RECRUITS_CARD_ID } from '../game/recruits';
import type { Card } from '../game/cards';
import {
advanceIndependentCardModules, createIndependentCardModulesFixture,
nextIndependentCardModulesStep, type IndependentCardModulesStep,
} from './fixture-independent-card-modules';
export type RecruitsModulesStep = IndependentCardModulesStep;
export type RecruitsModulesRoster = 'classic' | 'ix-native';
export type RecruitsModulesOptions = {
/** Original Advanced rules; the independent profile still refuses other overlays. */
advanced?: boolean;
/** Original Advanced Stronghold Cards; requires Advanced. */
strongholds?: boolean;
/** Clean classic roster, or the native Ixian three-seat pool with its own token. */
roster?: RecruitsModulesRoster;
};
export type RecruitsModulesFixture = {
initial: Game;
afterSetup: Game;
afterBattle: Game;
/** Quiet turn-two Revival before any Recruits activation, with the card held. */
game: Game;
/** After the actual Recruits activation and before any revival. */
beforeRevival: Game;
/** After the holder's real revival; Axlotl income is accrued but unpaid. */
afterRevival: Game;
/** After the opponent's real revival in the same phase; income must not repeat. */
afterSecondRevival: Game;
/** Turn-two Shipment after the original Revival phase settled its tokens. */
afterPhaseEnd: Game;
/** Actual Axlotl custodian chosen by the original first Storm. */
owner: string;
opponent: string;
location: { territory: string; sector: number };
/** Ordinary rate/limit before the activation, measured on the real clone. */
base: { freeRate: number; limit: number };
/** Rate/limit the actual activation produced. */
doubled: { freeRate: number; limit: number };
tanks: { reviver: number; opponent: number };
amount: number;
/** Tokens the custodian actually owns when the free revival accrues income. */
accrual: number;
playAction: Action;
revivalAction: Action;
secondRevivalAction: Action;
battle: {
attacker: string; defender: string; territory: string;
reviverPlan: RecruitsModulesStep; opponentPlan: RecruitsModulesStep;
};
steps: RecruitsModulesStep[];
staging: string[];
};
const CLASSIC_ROSTER: FactionId[] = ['emperor', 'atreides', 'guild'];
const IX_NATIVE_ROSTER: FactionId[] = ['ixians', 'atreides', 'guild'];
/** The printed native stronghold positions that start occupied on the board. */
const NATIVE_STRONGHOLD: Readonly<Record<string, { territory: string; sector: number }>> = {
atreides: { territory: 'arrakeen', sector: 10 },
guild: { territory: 'tueks_sietch', sector: 5 },
};
export const recruitsModulesPlayer = (game: Game, id: string): Player => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
};
export const recruitsModulesClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingRevival;
const boardCount = (player: Player): number =>
Object.values(player.forces).reduce((sum, n) => sum + n, 0);
const eliteCount = (player: Player): number =>
player.elites
? player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
: 0;
/** Reuse the shared original native continuation unchanged. */
export function advanceRecruitsModules(
game: Game, until: (state: Game) => boolean, steps: RecruitsModulesStep[] = [],
): Game {
return advanceIndependentCardModules(game, until, steps);
}
/** Physically conserve one existing face between original zones; no minted copy. */
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
const player = recruitsModulesPlayer(game, actor);
if (player.hand.some(card => card.id === id)) return player.hand.find(card => card.id === id)!;
const zones: { label: string; cards: Card[] }[] = [
{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
...game.players.filter(other => other.id !== actor)
.map(other => ({ label: `${other.id}'s hand`, cards: other.hand })),
];
const source = zones.find(zone => zone.cards.some(card => card.id === id));
assert.ok(source, `Original physical ${id} is missing from every retained zone`);
const limit = player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4;
if (player.hand.length >= limit) {
const index = player.hand.findIndex(card => card.id !== id);
assert.ok(index >= 0, `${actor} must have a non-Recruits card to return`);
const [returned] = player.hand.splice(index, 1);
game.deck.push(returned);
staging.push(`Physical staging: ${returned.id}, ${actor}'s hand → remaining original deck (printed hand limit).`);
}
const [card] = source.cards.splice(source.cards.findIndex(candidate => candidate.id === id), 1);
player.hand.push(card);
staging.push(`Physical staging: conserved ${id}, ${source.label} → ${actor}'s hand; no purchase, deal or replacement face.`);
return card;
}
/** Original counters, tokens and Treachery faces stay conserved against the baseline. */
export function assertRecruitsModulesInventory(game: Game, baseline: Game): void {
for (const expected of baseline.players) {
const player = recruitsModulesPlayer(game, expected.id);
assert.equal(player.reserves + player.tanks + boardCount(player),
expected.reserves + expected.tanks + boardCount(expected),
`${player.id} must retain its physical normal counters`);
assert.equal(eliteCount(player), eliteCount(expected),
`${player.id} must retain its physical special counters`);
}
const physical = (state: Game) => allCardZones(state).map(card => card.id).sort();
assert.deepEqual(physical(game), physical(baseline),
'Every original Treachery face must retain exactly its one physical zone');
assert.equal(allCardZones(game).filter(card => card.id === RECRUITS_CARD_ID).length, 1,
'The independent Recruits face must stay a single physical card');
for (const [id, token] of Object.entries(game.techTokens ?? {}))
assert.ok(token.owner === null || game.players.some(player => player.id === token.owner),
`${id} must keep seated token custody`);
}
/** Every original Treachery face that is still held: hands, deck, discard and unsold auction. */
function allCardZones(state: Game): Card[] {
return [
...state.deck,
...state.discard,
...state.players.flatMap(player => player.hand),
...(state.auction?.cards.slice(state.auction.index) ?? []),
];
}
/**
* Genuine independent Recruits + original Tech programme. The original first
* Storm decides Axlotl custody; that custodian commits real counters in a won
* battle during the same turn, putting its actual dialed casualties in the
* Tanks. Next turn's real Revival plays the physical Recruits and returns them.
* Advanced Strongholds and the native Ixian pool are selected through the
* shared setup factory; no wallet, owner, phase, receipt or history is assigned.
*/
export function createRecruitsModulesFixture(
options: RecruitsModulesOptions = {},
): RecruitsModulesFixture {
const roster = options.roster ?? 'classic';
const advanced = options.advanced ?? false;
const strongholds = options.strongholds ?? false;
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
const source = createIndependentCardModulesFixture({
advanced, strongholds, tech: true,
factions: roster === 'classic' ? [...CLASSIC_ROSTER] : [...IX_NATIVE_ROSTER],
expansions: roster === 'classic' ? [] : ['ix'],
});
const steps: RecruitsModulesStep[] = source.steps;
const staging: string[] = [];
const initial = source.initial;
const afterSetup = structuredClone(source.game);
let game = source.game;
const act = (actor: string, action: Action) => {
steps.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
const advance = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1800; count++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished', 'The original programme ended before its requested boundary');
const step = nextIndependentCardModulesStep(game);
assert.ok(step, 'The fixture must supply the actual human Battle Plans before continuing');
act(step.actor, step.action);
}
throw Error('The original independent-card programme did not reach its requested boundary');
};
// Fix only original first-Storm randomness; the engine still assigns custody.
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { advance(state => state.turn === 1 && state.phase === 1 && recruitsModulesClean(state)); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
const owner = game.techTokens?.axlotl.owner;
assert.ok(owner, 'The original first Storm must decide Axlotl custody');
conserveCard(game, owner, RECRUITS_CARD_ID, staging);
staging.push(`Conserved the physical Recruits after setup: ${owner} is the actual Axlotl custodian the original first Storm selected.`);
const reviver = recruitsModulesPlayer(game, owner);
const others = game.players.filter(player => player.id !== owner).map(player => player.id);
const ownStronghold = NATIVE_STRONGHOLD[reviver.faction];
// Only real board positions with at least four printed counters can absorb the
// winner's dialed losses; everyone else must ship an attacking force instead.
const defends = !!ownStronghold && boardCount(reviver) >= 4;
const target = defends
? reviver.faction
: (others.some(id => NATIVE_STRONGHOLD[recruitsModulesPlayer(game, id).faction]?.territory === 'tueks_sietch')
? 'guild' : 'atreides');
const opponent = defends
? (roster === 'ix-native'
? others.find(id => recruitsModulesPlayer(game, id).faction === 'ixians')!
: others.find(id => !!NATIVE_STRONGHOLD[recruitsModulesPlayer(game, id).faction])!)
: others.find(id => recruitsModulesPlayer(game, id).faction === target)!;
const location = NATIVE_STRONGHOLD[target];
assert.ok(location, 'The selected battle needs an original stronghold position');
const base = viewGame(game, owner).revival;
const baseFreeRate = base.freeRemaining;
const baseLimit = base.limit;
const doubledFreeRate = Math.min(baseFreeRate * 2, 7);
const wallet = reviver.spice;
let amount = 0;
for (let candidate = Math.min(6, defends ? boardCount(reviver) : 6); candidate >= 4; candidate--) {
const paid = Math.max(0, candidate - doubledFreeRate);
// The attacker also pays the original one-spice stronghold transport per force.
if (wallet >= (defends ? 0 : candidate) + 2 * paid + 1) { amount = candidate; break; }
}
assert.ok(amount >= 4,
`The actual ${owner} wallet and board must support a four-force revival beyond the ordinary limit`);
const aggressor = defends ? opponent : owner;
advance(state => state.turn === 1 && state.phase === 5 && state.active === aggressor && recruitsModulesClean(state));
act(aggressor, {
type: 'ship', territory: location.territory, sector: location.sector,
amount: defends ? 1 : amount, elite: 0,
});
advance(state => state.phase === 6 && recruitsModulesClean(state));
const choice = viewGame(game, aggressor).battleChoices
.find(candidate => candidate.territory === location.territory);
assert.ok(choice, 'The actual shipment must open a real battle in that stronghold');
assert.deepEqual([choice.attacker, choice.defender].sort(), [owner, opponent].sort(),
'Only the actual Axlotl custodian and its chosen opponent may be in this battle');
act(choice.chooser, { type: 'chooseBattle', territory: location.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
advance(state => !!state.battle && !state.battle.revealed && !state.battle.preparation &&
(!state.battle.preLeader || state.battle.preLeader.closed) && recruitsModulesClean(state));
const available = (id: string) => recruitsModulesPlayer(game, id).leaders
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === location.territory));
const strongest = available(owner).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const weakest = available(opponent).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(strongest && weakest, 'Both real combatants need an available leader');
// Typed original pools count the dial in half-strength steps; the winner's
// losses still equal the committed physical counters it actually dialed.
const typedDial = advanced || reviver.faction === 'ixians' ? amount / 2 : amount;
const reviverPlan: RecruitsModulesStep = { actor: owner,
action: { type: 'battlePlan', dial: typedDial, support: 0, leader: strongest.id } };
const opponentPlan: RecruitsModulesStep = { actor: opponent,
action: { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id } };
act(reviverPlan.actor, reviverPlan.action);
act(opponentPlan.actor, opponentPlan.action);
advance(state => !state.battle && !state.decision && state.phase >= 7 && recruitsModulesClean(state));
const afterBattle = structuredClone(game);
const tanks = { reviver: recruitsModulesPlayer(game, owner).tanks,
opponent: recruitsModulesPlayer(game, opponent).tanks };
assert.equal(tanks.reviver, amount, 'The real battle must send exactly the committed counters to the Tanks');
assert.ok(tanks.opponent >= 1, 'The actual losing force must reach the Tanks');
// Next turn's real Revival phase; the original first-Storm and battle history
// are untouched, so the token assignment the collision produced is real.
advance(state => state.turn === 2 && state.phase === 4 && recruitsModulesClean(state));
const gameAtRevival = structuredClone(game);
const accrual = game.techTokens ? ownedTech(game.techTokens, owner).length : 0;
assert.ok(accrual >= 1, 'The actual Axlotl custodian must own its physical tokens');
const playAction: Action = { type: 'card', card: RECRUITS_CARD_ID };
act(owner, playAction);
const beforeRevival = structuredClone(game);
assert.equal(beforeRevival.recruits?.player, owner, 'The physical Recruits activation must be recorded');
const revivalAction: Action = { type: 'revive', amount, elite: 0 };
act(owner, revivalAction);
const afterRevival = structuredClone(game);
const secondRevivalAction: Action = { type: 'revive', amount: 1, elite: 0 };
act(opponent, secondRevivalAction);
const afterSecondRevival = structuredClone(game);
assert.equal(game.techTokens?.axlotl.spice, accrual,
'The accrued income must still be held by the token immediately before the phase ends');
advance(state => state.phase === 5 && recruitsModulesClean(state));
const afterPhaseEnd = structuredClone(game);
assertRecruitsModulesInventory(gameAtRevival, afterSetup);
assertRecruitsModulesInventory(afterBattle, afterSetup);
assertRecruitsModulesInventory(afterRevival, afterSetup);
assertRecruitsModulesInventory(afterSecondRevival, afterSetup);
assertRecruitsModulesInventory(afterPhaseEnd, afterSetup);
return {
initial, afterSetup, afterBattle, game: gameAtRevival, beforeRevival, afterRevival,
afterSecondRevival, afterPhaseEnd, owner, opponent, location,
base: { freeRate: baseFreeRate, limit: baseLimit },
doubled: { freeRate: doubledFreeRate, limit: 7 },
tanks, amount, accrual, playAction, revivalAction, secondRevivalAction,
battle: {
attacker: choice.attacker, defender: choice.defender,
territory: location.territory, reviverPlan, opponentPlan,
},
steps, staging,
};
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { ownedTech } from '../game/tech-tokens';
import {
advanceRecruitsModules, assertRecruitsModulesInventory, createRecruitsModulesFixture,
recruitsModulesClean, recruitsModulesPlayer, type RecruitsModulesOptions,
} from './fixture-recruits-modules';
/**
* GF9 Ecaz & Moritani pp11,16: Recruits doubles every player's current free
* revival rate and raises the ordinary force-revival limit to seven. The
* independent-profile programme uses the real original first-Storm Axlotl
* custodian, real battle Tanks and a real Revival; Tech Tokens stay original
* and the Axlotl payout waits for that Revival phase to end.
*/
const CONFIGURATIONS: { label: string; options: RecruitsModulesOptions }[] = [
{ label: 'Basic classic', options: {} },
{ label: 'Advanced classic with Stronghold Cards', options: { advanced: true, strongholds: true } },
{ label: 'Basic native Ixian', options: { roster: 'ix-native' } },
{ label: 'Advanced native Ixian with Stronghold Cards', options: { roster: 'ix-native', advanced: true, strongholds: true } },
];
for (const { label, options } of CONFIGURATIONS) {
void test(`${label}: doubled physical revival retains its price and pays Axlotl once at phase end`, () => {
const fixture = createRecruitsModulesFixture(options);
const ordinary = new Map(fixture.game.players.map(player =>
[player.id, viewGame(fixture.game, player.id).revival.freeRemaining]));
const original = structuredClone(fixture.game);
assert.throws(() => applyAction(original, fixture.owner, fixture.revivalAction));
assert.deepEqual(original, fixture.game, 'The old three-force allowance cannot consume these counters');
const activated = applyAction(fixture.game, fixture.owner, fixture.playAction);
assert.equal(activated.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
for (const player of activated.players) {
const preview = viewGame(activated, player.id).recruitsPreview;
const rate = preview?.rates.find(entry => entry.player === player.id);
assert.equal(rate?.limit, 7);
assert.equal(rate?.freeRate, Math.min(2 * ordinary.get(player.id)!, 7),
'Every seat retains its own doubled current rate');
}
const before = recruitsModulesPlayer(activated, fixture.owner);
const returned = applyAction(activated, fixture.owner, fixture.revivalAction);
const owner = recruitsModulesPlayer(returned, fixture.owner);
assert.equal(owner.revived, fixture.amount);
assert.equal(owner.freeForcesRevived, fixture.doubled.freeRate);
assert.equal(owner.reserves, before.reserves + fixture.amount);
assert.equal(owner.tanks, 0);
assert.equal(owner.spice, before.spice - 2 * (fixture.amount - fixture.doubled.freeRate),
'Only paid counters cost spice; pending token income cannot fund this request');
const accrual = ownedTech(returned.techTokens!, fixture.owner).length;
assert.equal(returned.techTokens!.axlotl.spice, accrual);
const second = applyAction(returned, fixture.opponent, fixture.secondRevivalAction);
assert.equal(recruitsModulesPlayer(second, fixture.opponent).freeForcesRevived, 1);
assert.equal(second.techTokens!.axlotl.spice, accrual,
'A second actual free revival does not repeat income');
assert.equal(recruitsModulesPlayer(second, fixture.owner).spice, owner.spice,
'Neither free return collects the pending income');
const settled = advanceRecruitsModules(second, state => state.phase === 5 && recruitsModulesClean(state));
assert.equal(recruitsModulesPlayer(settled, fixture.owner).spice, owner.spice + accrual,
'The phase boundary pays the original held income exactly once');
assert.equal(settled.techTokens!.axlotl.spice, 0);
assertRecruitsModulesInventory(settled, fixture.afterSetup);
});
}
+1 -1
View File
@@ -37,7 +37,7 @@ async function main() {
'\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.', '\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.',
'\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.', '\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.',
'\n--profile ecaz-treachery --homeworlds selects independent36-card classic2–6 Basic/Advanced Homeworld setup. Recruits doubles current native rates within seven total, provisionally rechecking separate-request thresholds/prior free use with native typed deposits/caps. Harass returns exact undialed counters through native reserves, never on an own World. Reinforcements seals exact normal-first native sources when ambiguous, pays before Harass and excludes an on-World fee from physical dial/casualties while preserving original face/support. Named-card Truthtrance and source-aware legal completion use canonical cards/locations. These are bounded provisional compositions, not full variant certification. No expansions, other overlays, public start or played-save conversion.', '\n--profile ecaz-treachery --homeworlds selects independent36-card classic2–6 Basic/Advanced Homeworld setup. Recruits doubles current native rates within seven total, provisionally rechecking separate-request thresholds/prior free use with native typed deposits/caps. Harass returns exact undialed counters through native reserves, never on an own World. Reinforcements seals exact normal-first native sources when ambiguous, pays before Harass and excludes an on-World fee from physical dial/casualties while preserving original face/support. Named-card Truthtrance and source-aware legal completion use canonical cards/locations. These are bounded provisional compositions, not full variant certification. No expansions, other overlays, public start or played-save conversion.',
'\nWithout Homeworlds, ecaz-treachery also supports clean selected Ix and/or CHOAM native/base Basic/Advanced rosters. Original selected35/47-card decks gain the three independent physical cards; Richese cache stays separate. Reinforcements keeps normal-first typed payment, original No-Field funding, Harass and original/copied Stone behavior. Nexus, Skills, Tech, Strongholds, Discovery, Sandtrout, mixed E3 and unrelated overlays remain outside this entry.', '\nWithout Homeworlds, ecaz-treachery supports clean classic or selected Ix and/or CHOAM native/base Basic/Advanced rosters with original unused Tech3+ and/or Advanced Strongholds2+; both requires Advanced3+. Original selected33/35/47 decks gain the three independent cards; Richese cache stays separate. First-Storm Tech owners/income and actual end-Mentat Stronghold ownership remain original. Recruits keeps original Axlotl triggers; Reinforcements/Harass keep physical costs/returns and earned held-card effects. Original Stone/Mirror allocation/profile guards stay. Nexus, Skills, Discovery, Sandtrout, mixed E3 and unrelated overlays remain outside this entry.',
'\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' + '\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' +
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' + '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +