Connect clean native family Reinforcements battles
This commit is contained in:
1 parent
c64c08af89
commit
4313983a5c
14 files changed
+1306
-44
No files matched your search
@@ -29,6 +29,22 @@ 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.
|
||||||
|
|
||||||
|
**Clean selected-family Reinforcements — 16 October.** Plain selected Ix and/or
|
||||||
|
CHOAM native/base rosters now reuse original independent38/50-card setup in
|
||||||
|
Basic/Advanced. Original normal-first typed/all-outcome payment, score-only +2,
|
||||||
|
No-Field funding and fee-before-Harass order remain explicitly provisional.
|
||||||
|
Other overlays, mixed E3 and public/played-save gates stay separate.
|
||||||
|
Genuine paid-shipment programmes exercise Ixian normal/mixed fees, native
|
||||||
|
Face Dance and Auditor aftercare; Richese Basic four-turn collection/shipment
|
||||||
|
programmes reach funded/short marker budgets without invented wallets/armies.
|
||||||
|
Advanced markers0/3/5 use funded original turn-one reserves, not a later-storm
|
||||||
|
matrix. Conserved card/identity positioning remains labeled staging.
|
||||||
|
Seven actual mobile journeys reach Collection;48 four-policy programmes
|
||||||
|
validate3022 R candidates and194 continuations. Frozen related20-file union
|
||||||
|
passes262/262 after removing two obsolete profile-rejection tests; types/lint,
|
||||||
|
build and actual prototype help pass. This is not complete faction/mode
|
||||||
|
acceptance, calibration or deployment.
|
||||||
|
|
||||||
**Exact native Homeworld Reinforcements — 16 October.** Clean classic2–6
|
**Exact native Homeworld Reinforcements — 16 October.** Clean classic2–6
|
||||||
Basic/Advanced independent36-card setup now connects the native fee on Arrakis,
|
Basic/Advanced independent36-card setup now connects the native fee on Arrakis,
|
||||||
foreign Worlds and own Worlds. Exact normal-first source allocations seal in
|
foreign Worlds and own Worlds. Exact normal-first source allocations seal in
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 16 October 2026 — Clean selected-family Reinforcements
|
||||||
|
|
||||||
|
Original independent setup supplied the physical card, but plain native
|
||||||
|
Ix/Tleilaxu/CHOAM/Richese plans rejected its profile. Admit only clean selected
|
||||||
|
Ix and/or CHOAM native/base Basic/Advanced rosters. Keep exact original
|
||||||
|
38/50-card decks, separate Richese cache, native force pools and setup choices.
|
||||||
|
No mixed E3, optional modules/overlays, public starts or played-save cutover.
|
||||||
|
Classic/paired E3, classic Homeworlds and selected Advanced Occupy stay separate.
|
||||||
|
|
||||||
|
Reuse explicitly provisional normal-first/all-outcome payment, score-only +2,
|
||||||
|
materialize-before-fee and fee-before-Harass. Native casualty origins, not a
|
||||||
|
duplicate fee, feed cyborg substitution; native Face Dance/Auditor/market
|
||||||
|
continuations remain. Original/copied Stone compares physical counters.
|
||||||
|
|
||||||
|
New genuine programmes retain actual setup, paid shipment and native aftercare.
|
||||||
|
Basic Richese reaches low/capped reserves through four turns of original
|
||||||
|
collection and shipment. Reject and replace the initial contributed board-reset
|
||||||
|
fixture before accepting evidence. Conserved card/identity positioning remains
|
||||||
|
labeled staging; do not claim every Stone was purchased naturally.
|
||||||
|
Advanced marker0/3/5 uses funded original turn-one reserves; no later-storm
|
||||||
|
low-budget matrix or full expansion acceptance is claimed.
|
||||||
|
|
||||||
|
Seven actual390×844 control journeys finish Collection, including mixed
|
||||||
|
cyborg fee, Face Dance decline, Auditor inspection, short-marker Harass,
|
||||||
|
copy-first Stone and Advanced union-family fee/return. No app exception or
|
||||||
|
horizontal overflow; eight startup Vite dependency aborts across two hosts.
|
||||||
|
Four policies complete32 native programmes/1594 validated R proposals/136
|
||||||
|
continuations plus16 Richese programmes/1428 proposals/58 continuations.
|
||||||
|
Artifacts live under `/tmp/dune-*-reinforcements-*-proof.json`; owned QA hosts,
|
||||||
|
tabs and scaffolds removed. No actual room/database changed or deployment ran.
|
||||||
|
Frozen related20-file union passes262/262 after deleting two obsolete native
|
||||||
|
profile-rejection tests rather than re-pinning implementation behavior.
|
||||||
|
`npm run check:quick`, build and actual prototype `--help` pass. Build retains
|
||||||
|
existing Node deprecation, large-chunk and dynamic-route classification warnings.
|
||||||
|
|
||||||
## 16 October 2026 — Exact native Homeworld Reinforcements fees
|
## 16 October 2026 — Exact native Homeworld Reinforcements fees
|
||||||
|
|
||||||
Original fresh own-Salusa play rejects the blanket R module gate before a
|
Original fresh own-Salusa play rejects the blanket R module gate before a
|
||||||
|
|||||||
@@ -2,17 +2,60 @@
|
|||||||
|
|
||||||
The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements
|
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 and clean Ecaz/Moritani expansion tables.
|
Basic/Advanced classic-faction tables, clean selected Ix and/or CHOAM native/base
|
||||||
Fresh clean classic Homeworlds use the native-source composition below; other
|
rosters, and clean Ecaz/Moritani expansion tables. Fresh clean classic Homeworlds
|
||||||
optional modules retain their guards. The selected Advanced Occupy profile also
|
use the native-source composition below; other optional modules retain guards.
|
||||||
composes co-present armies and native Ixian/Tleilaxu/CHOAM families.
|
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
|
||||||
slot stays occupied and hidden until reveal. A named-card inspection still
|
slot stays occupied and hidden until reveal. A named-card inspection still
|
||||||
fixes the exact card. Harass & Withdraw may share the plan under the
|
fixes the exact card. Harass & Withdraw may share the plan under the
|
||||||
[provisional original-cost-first policy](#combined-utility-plan--16-october-2026).
|
[provisional original-cost-first policy](#combined-utility-plan--16-october-2026).
|
||||||
Original or copied Stone Burner composes in the existing native Advanced Occupy
|
Original or copied Stone Burner composes in clean selected Ix/CHOAM families
|
||||||
envelope. Other roster/profile/optional-module boundaries remain.
|
and the existing native Advanced Occupy envelope. Other profile boundaries remain.
|
||||||
|
|
||||||
|
## Clean selected Ix/CHOAM families — 16 October 2026
|
||||||
|
|
||||||
|
Plain selected `ix`, `choam`, or both now admit Reinforcements with native/base
|
||||||
|
rosters in Basic and Advanced. This closes a profile-admission gap: original
|
||||||
|
independent pre-setup cards were present, but the plan rejected the profile.
|
||||||
|
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.
|
||||||
|
No Homeworlds, Nexus, Skills, Tech, Strongholds, Discovery, Sandtrout, mixed E3
|
||||||
|
or unrelated preview is added. Existing classic, paired E3 and selected Advanced
|
||||||
|
Occupy branches remain separate; public starts and played-save conversion stay gated.
|
||||||
|
|
||||||
|
Reuse the existing explicitly provisional normal-first, all-outcome fee and
|
||||||
|
fee-before-Harass order, not a new publisher ruling. Native casualty origins
|
||||||
|
alone feed Ixian substitution; do not substitute a second copy of the fee.
|
||||||
|
Tleilaxu Face Dance and Advanced Auditor/CHOAM phase opening remain native.
|
||||||
|
Richese first materializes its own No-Field, then pays three original remaining
|
||||||
|
reserves, then returns successful Harass counters. Marker0/3/5, insufficient
|
||||||
|
funding and physical original/copied Stone reuse those same rules.
|
||||||
|
|
||||||
|
Two fixture programmes retain genuine setup, paid shipments, physical armies,
|
||||||
|
wallets and native continuations. The Richese Basic route funds actual tuning
|
||||||
|
shipments through original collection over four turns, not assigned spice or
|
||||||
|
board counters. An initial contributed board-reset fixture was rejected and
|
||||||
|
rewritten before accepted proof. Physical card/identity positioning is explicit,
|
||||||
|
conserved staging; a Stone cache-to-hand move is not claimed as a natural purchase.
|
||||||
|
Advanced Richese uses its funded turn-one route at20 reserves for markers0/3/5;
|
||||||
|
the low/capped multi-turn tuning evidence is Basic, not an Advanced storm matrix.
|
||||||
|
|
||||||
|
Changed-path runtime proof: seven actual390×844 control journeys reach Collection
|
||||||
|
with native aftercare: mixed Ixian payment, Tleilaxu loss/Face Dance decline,
|
||||||
|
Advanced Auditor inspection, funded Richese marker, short-budget Harass fallback,
|
||||||
|
copy-first Mirror/Stone, and Advanced union-family fee/return. No horizontal
|
||||||
|
overflow or app exception was observed; initial Vite dependency aborts are separate.
|
||||||
|
Four legal policies exercise32 native programmes/1594 engine-validated reserve
|
||||||
|
proposals/136 continuations and16 Richese programmes/1428 proposals/58 continuations.
|
||||||
|
Policy proof is bounded legal participation, not strategy or calibration.
|
||||||
|
Frozen related20-file union passes262/262 after removing two obsolete profile
|
||||||
|
rejections; types/lint, build and actual prototype help pass. Build retains
|
||||||
|
Node deprecation, large-chunk and dynamic-route classification warnings.
|
||||||
|
No HTTP/database boundary changed or deployed evidence is claimed.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
|
The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
|
||||||
retains own-holder reserve cost and selected lead card/payment ownership.
|
retains own-holder reserve cost and selected lead card/payment ownership.
|
||||||
@@ -65,7 +108,8 @@ and then failed the first Traitor action. The new first-plan check rejects that
|
|||||||
unfunded commitment without reserving cards or revealing a token; Harass alone
|
unfunded commitment without reserving cards or revealing a token; Harass alone
|
||||||
remains legal. Token0/reserves3 remains funded. Native actual revelation,
|
remains legal. Token0/reserves3 remains funded. Native actual revelation,
|
||||||
reserve-cap, token identity/history, support and fixed/variable losses stay.
|
reserve-cap, token identity/history, support and fixed/variable losses stay.
|
||||||
No Basic Richese Reinforcements or other new mode is admitted.
|
That earlier checkpoint did not admit Basic Richese. The clean selected-family
|
||||||
|
composition above now admits it without changing this funding order.
|
||||||
|
|
||||||
|
|
||||||
### Scoped proof
|
### Scoped proof
|
||||||
|
|||||||
@@ -665,6 +665,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
|||||||
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits and Reinforcements have their separate native compositions below; other faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) |
|
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits and Reinforcements have their separate native compositions below; other faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--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) |
|
| 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) |
|
||||||
| 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) |
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
|||||||
? '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. 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.'
|
||||||
: 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. 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.'
|
||||||
: 'Reinforcements works in either slot, including clean classic Basic/Advanced 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 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.',
|
||||||
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 =
|
||||||
|
|||||||
+8
-1
@@ -13779,8 +13779,15 @@ function combinedCardBattleBlock(g: Game): string | null {
|
|||||||
}
|
}
|
||||||
function reinforcementsModeSupported(g: Game) {
|
function reinforcementsModeSupported(g: Game) {
|
||||||
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
|
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
|
||||||
|
const nativeFamilies = !g.homeworlds && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
|
||||||
|
!g.semutaPreview && !g.kullPreview && !g.ecazOccupyPreview &&
|
||||||
|
g.expansions.length > 0 && g.expansions.every(expansion => expansion === 'ix' || expansion === 'choam') &&
|
||||||
|
g.players.every(player => {
|
||||||
|
const family = faction(player.faction).expansion;
|
||||||
|
return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family));
|
||||||
|
});
|
||||||
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
|
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
|
||||||
((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
|
(nativeFamilies || (g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
|
||||||
((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
|
((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
|
||||||
const expansion = faction(player.faction).expansion;
|
const expansion = faction(player.faction).expansion;
|
||||||
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
|
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
|
||||||
|
|||||||
+4
-3
@@ -4996,20 +4996,21 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.',
|
summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.',
|
||||||
steps: [
|
steps: [
|
||||||
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
|
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
|
||||||
'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
|
'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced, clean selected Ix/CHOAM native/base rosters and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
|
||||||
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
|
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
|
||||||
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module limits remain guarded.',
|
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module limits remain guarded.',
|
||||||
'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.',
|
||||||
],
|
],
|
||||||
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: [
|
||||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical slots, typed reserve cost, score-only bonus and discard connect. Clean classic World plans seal exact native sources, excluding own-World fees from the physical pool while keeping original traits/printed face. Shared legality and named questions retain source-bearing completions. Other profile/module gates remain.', evidence: ['game/reinforcements.ts', 'game/reinforcements-homeworld.ts', 'game/homeworld-native-reserves.ts', 'game/battle-resolution-quote.ts', 'game/battle-promises.ts', 'game/truthtrance-card-names.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
{ area: 'Implementation', status: 'Partial', detail: 'Physical slots, typed reserve cost, score-only bonus and discard connect. Clean selected Ix/CHOAM native/base Basic/Advanced rosters reuse original setup, No-Field funding and native aftercare. Classic World plans seal exact native sources, excluding own-World fees from the physical pool while keeping original traits/printed face. Shared legality and named questions retain source-bearing completions. Other profile/module gates remain.', evidence: ['game/reinforcements.ts', 'game/reinforcements-homeworld.ts', 'game/homeworld-native-reserves.ts', 'game/battle-resolution-quote.ts', 'game/battle-promises.ts', 'game/truthtrance-card-names.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||||
{ 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, normal/mixed/star fees, deficient funding, each source allocation, own-World disjointness, frozen face/explosion, native Traitors, Harass and named promises. Six actual mobile journeys reach Collection;32 four-policy programmes/114 continuations and1682 engine-validated R candidates are separate bounded proof, not complete modes or calibration.', evidence: ['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 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'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
|
||||||
|
viewGame, type Action, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { MOBILE_LOCATION, territory } from '../game/board';
|
||||||
|
import { isAuditorLeader, treacheryDeck } from '../game/cards';
|
||||||
|
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||||
|
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||||
|
import { traitorDeck } from '../game/traitors';
|
||||||
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||||
|
|
||||||
|
export type NativeFamilyReinforcementsStep = { actor: string; action: Action };
|
||||||
|
export type NativeFamilyReinforcementsOptions = {
|
||||||
|
faction?: 'ixians' | 'tleilaxu' | 'choam';
|
||||||
|
advanced?: boolean;
|
||||||
|
expansions?: ('ix' | 'choam')[];
|
||||||
|
slot?: 'weapon' | 'defense';
|
||||||
|
outcome?: 'winner' | 'loser' | 'traitor' | 'explosion';
|
||||||
|
/** Nine legally paid suboids leave one suboid and four cyborgs in reserve. */
|
||||||
|
mixedFee?: boolean;
|
||||||
|
initial?: Game;
|
||||||
|
seatIds?: [string, string];
|
||||||
|
};
|
||||||
|
export type NativeFamilyReinforcementsFixture = {
|
||||||
|
initial: Game;
|
||||||
|
afterSetup: Game;
|
||||||
|
beforeBattle: Game;
|
||||||
|
game: Game;
|
||||||
|
owner: string;
|
||||||
|
opponent: string;
|
||||||
|
territory: string;
|
||||||
|
location: string;
|
||||||
|
shipments: { before: Game; step: NativeFamilyReinforcementsStep; after: Game }[];
|
||||||
|
battleStep: NativeFamilyReinforcementsStep;
|
||||||
|
planActions: [NativeFamilyReinforcementsStep, NativeFamilyReinforcementsStep];
|
||||||
|
nativeSteps: NativeFamilyReinforcementsStep[];
|
||||||
|
staging: string[];
|
||||||
|
fee: { normal: number; elite: number };
|
||||||
|
};
|
||||||
|
export function nativeFamilyReinforcementsPlayer(game: Game, actor: string) {
|
||||||
|
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||||
|
}
|
||||||
|
export const nativeFamilyReinforcementsClean = (game: Game) =>
|
||||||
|
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||||
|
|
||||||
|
/** Original native controls are consumed, not removed: HMS placement, Face
|
||||||
|
* Dancers, cyborg substitution and Auditor inspection remain ordinary decisions. */
|
||||||
|
export function nextNativeFamilyReinforcementsStep(game: Game): NativeFamilyReinforcementsStep | null {
|
||||||
|
if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance')
|
||||||
|
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||||||
|
return nextStrongholdFactionsNativeStep(game);
|
||||||
|
}
|
||||||
|
export function advanceNativeFamilyReinforcements(state: Game, until: (game: Game) => boolean,
|
||||||
|
actions: NativeFamilyReinforcementsStep[] = []): Game {
|
||||||
|
let game = state;
|
||||||
|
for (let n = 0; n < 600; n++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished');
|
||||||
|
const step = nextNativeFamilyReinforcementsStep(game);
|
||||||
|
assert.ok(step, 'Seal the actual human Battle Plans before continuing');
|
||||||
|
actions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||||
|
}
|
||||||
|
throw Error('Native family programme did not reach its requested boundary');
|
||||||
|
}
|
||||||
|
function initialize(game: Game): 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 { return initializeEcazTreacheryGameForAudit(game); }
|
||||||
|
finally {
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function exchangeTraitor(game: Game, actor: string, leader: string, staging: string[]) {
|
||||||
|
const player = nativeFamilyReinforcementsPlayer(game, actor);
|
||||||
|
if (player.traitors.includes(leader)) return;
|
||||||
|
const returned = player.traitors[0]; assert.ok(returned);
|
||||||
|
const at = game.traitorReserve!.indexOf(leader);
|
||||||
|
const donor = game.players.find(p => p.traitors.includes(leader));
|
||||||
|
const dancer = game.players.flatMap(p => p.faceDancers ?? []).find(c => c.leader === leader && !c.revealed);
|
||||||
|
assert.ok(at >= 0 || donor || dancer);
|
||||||
|
if (at >= 0) game.traitorReserve![at] = returned;
|
||||||
|
else if (donor) donor.traitors[donor.traitors.indexOf(leader)] = returned;
|
||||||
|
else dancer!.leader = returned;
|
||||||
|
player.traitors[0] = leader;
|
||||||
|
staging.push(`Conserved original Traitor ${leader} exchanged for ${returned} into ${actor}'s hand.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh selected-family lobby → independent-card initializer → every original
|
||||||
|
* setup choice → real phase readiness → paid native shipments → chooseBattle.
|
||||||
|
* Only conserved hands/Traitors and unopened first-Blow card positions are
|
||||||
|
* staged. No force, wallet, phase, history or preview marker is assigned. */
|
||||||
|
export function createNativeFamilyReinforcementsFixture(
|
||||||
|
options: NativeFamilyReinforcementsOptions = {},
|
||||||
|
): NativeFamilyReinforcementsFixture {
|
||||||
|
const faction = options.faction ?? 'ixians', advanced = options.advanced ?? options.initial?.advanced ?? false;
|
||||||
|
const expansions = options.expansions ?? options.initial?.expansions ?? [faction === 'choam' ? 'choam' : 'ix'];
|
||||||
|
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam'));
|
||||||
|
assert.equal(new Set(expansions).size, expansions.length);
|
||||||
|
assert.ok(expansions.includes(faction === 'choam' ? 'choam' : 'ix'));
|
||||||
|
assert.ok(!options.mixedFee || faction === 'ixians');
|
||||||
|
const ids = options.seatIds ?? [faction, 'guild'];
|
||||||
|
let game = options.initial ? structuredClone(options.initial) :
|
||||||
|
createGame('NATIVEFAMILYREINFORCEMENTS', newPlayer(ids[0], faction, faction), advanced, expansions);
|
||||||
|
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||||
|
assert.equal(game.status, 'lobby', 'Supply the original undealt native lobby');
|
||||||
|
assert.equal(game.advanced, advanced);
|
||||||
|
assert.deepEqual([...game.expansions].sort(), [...expansions].sort());
|
||||||
|
assert.deepEqual(game.players.map(p => p.faction).sort(), [faction, 'guild'].sort());
|
||||||
|
assert.ok(!game.ecazTreachery && !game.homeworlds && !game.nexusCards && !game.leaderSkills &&
|
||||||
|
!game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.discoveries);
|
||||||
|
const owner = game.players.find(p => p.faction === faction)!.id;
|
||||||
|
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||||
|
const nativeSteps: NativeFamilyReinforcementsStep[] = [];
|
||||||
|
const act = (step: NativeFamilyReinforcementsStep) => {
|
||||||
|
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||||
|
};
|
||||||
|
for (const p of game.players) if (!p.ready) act({ actor: p.id, action: { type: 'ready' } });
|
||||||
|
const initial = structuredClone(game);
|
||||||
|
// This initializer selects independent cards before native setup and deals
|
||||||
|
// exactly the selected physical inventory; never retrofit a module flag.
|
||||||
|
game = initialize(game);
|
||||||
|
game = advanceNativeFamilyReinforcements(game, g => g.status === 'playing', nativeSteps);
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
const staging = ['Independent cards selected by the original initializer before native setup; original HMS army, Face Dancers and Advanced Auditor retained.'];
|
||||||
|
for (let i = 0; i < (advanced ? 4 : 2); i++) {
|
||||||
|
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
|
||||||
|
assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||||
|
}
|
||||||
|
staging.push('Position unopened original non-worm first-Blow cards; no phase or draw is assigned.');
|
||||||
|
const reach = (until: (g: Game) => boolean) => {
|
||||||
|
game = advanceNativeFamilyReinforcements(game, until, nativeSteps);
|
||||||
|
};
|
||||||
|
const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
|
||||||
|
const shipments: NativeFamilyReinforcementsFixture['shipments'] = [];
|
||||||
|
const waiting = new Set([owner, opponent]);
|
||||||
|
reach(g => g.phase === 5 && nativeFamilyReinforcementsClean(g));
|
||||||
|
while (waiting.size) {
|
||||||
|
const actor = game.active!; assert.ok(waiting.has(actor));
|
||||||
|
const amount = actor === owner && options.mixedFee ? 9 : 3;
|
||||||
|
const elite = actor === owner && faction === 'ixians' && !options.mixedFee ? 2 : 0;
|
||||||
|
const step: NativeFamilyReinforcementsStep = { actor, action: {
|
||||||
|
type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
|
||||||
|
} };
|
||||||
|
const before = structuredClone(game); act(step); reach(nativeFamilyReinforcementsClean);
|
||||||
|
shipments.push({ before, step, after: structuredClone(game) });
|
||||||
|
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
|
||||||
|
reach(g => (g.phase === 5 || g.phase === 6) && nativeFamilyReinforcementsClean(g));
|
||||||
|
}
|
||||||
|
reach(g => g.phase === 6 && nativeFamilyReinforcementsClean(g));
|
||||||
|
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||||
|
const take = (actor: string, id: string) => {
|
||||||
|
const at = game.deck.findIndex(c => c.id === id); assert.ok(at >= 0);
|
||||||
|
const player = nativeFamilyReinforcementsPlayer(game, actor);
|
||||||
|
assert.ok(player.hand.length < (player.faction === 'choam' ? 5 : 4));
|
||||||
|
player.hand.push(game.deck.splice(at, 1)[0]); return id;
|
||||||
|
};
|
||||||
|
take(owner, REINFORCEMENTS_CARD);
|
||||||
|
if (faction === 'choam' && advanced) {
|
||||||
|
take(opponent, game.deck.find(c => c.kind === 'projectile')!.id);
|
||||||
|
staging.push('Conserve one unused opposing projectile card so the original Advanced Auditor inspection control has a real target.');
|
||||||
|
}
|
||||||
|
const outcome = options.outcome ?? 'winner';
|
||||||
|
let laser: string | null = null, shield: string | null = null;
|
||||||
|
if (outcome === 'explosion') {
|
||||||
|
laser = take(opponent, game.deck.find(c => c.kind === 'lasgun')!.id);
|
||||||
|
shield = take(opponent, game.deck.find(c => c.kind === 'shield')!.id);
|
||||||
|
}
|
||||||
|
staging.push('Return original hands to undealt stock and conserve physical Reinforcements (plus opposing Lasgun/Shield only for explosion) into legal hands.');
|
||||||
|
const own = nativeFamilyReinforcementsPlayer(game, owner);
|
||||||
|
const ownLeader = faction === 'ixians' ? own.leaders.find(l => l.strength === (options.mixedFee ? 4 : 1))!
|
||||||
|
: faction === 'choam' && advanced ? own.leaders.find(isAuditorLeader)!
|
||||||
|
: own.leaders.find(l => l.strength === 2)!;
|
||||||
|
const otherLeader = nativeFamilyReinforcementsPlayer(game, opponent).leaders.find(l => l.strength === 5)!;
|
||||||
|
assert.ok(ownLeader && otherLeader);
|
||||||
|
if (faction === 'tleilaxu' && outcome === 'loser' &&
|
||||||
|
!own.faceDancers!.some(c => c.leader === otherLeader.id)) {
|
||||||
|
const dancer = own.faceDancers![0], returned = dancer.leader;
|
||||||
|
const at = game.traitorReserve!.indexOf(otherLeader.id);
|
||||||
|
const donor = game.players.find(p => p.traitors.includes(otherLeader.id));
|
||||||
|
assert.ok(at >= 0 || donor);
|
||||||
|
if (at >= 0) game.traitorReserve![at] = returned;
|
||||||
|
else donor!.traitors[donor!.traitors.indexOf(otherLeader.id)] = returned;
|
||||||
|
dancer.leader = otherLeader.id;
|
||||||
|
staging.push(`Conserved original Face Dancer ${otherLeader.id} exchanged for ${returned}; the actual post-victory native window remains available.`);
|
||||||
|
}
|
||||||
|
if (outcome === 'traitor') exchangeTraitor(game, opponent, ownLeader.id, staging);
|
||||||
|
const beforeBattle = structuredClone(game);
|
||||||
|
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === kind); assert.ok(choice);
|
||||||
|
const battleStep: NativeFamilyReinforcementsStep = { actor: choice.chooser, action: {
|
||||||
|
type: 'chooseBattle', territory: kind,
|
||||||
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker,
|
||||||
|
} };
|
||||||
|
act(battleStep);
|
||||||
|
reach(g => !!g.battle && nativeFamilyReinforcementsClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||||
|
const slot = options.slot ?? 'weapon';
|
||||||
|
const dial = faction === 'ixians' ? options.mixedFee ? 0.5 : 2.5 : 2;
|
||||||
|
const support = advanced ? faction === 'ixians' ? options.mixedFee ? 0 : 1 : 2 : 0;
|
||||||
|
const planActions: NativeFamilyReinforcementsFixture['planActions'] = [
|
||||||
|
{ actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
|
||||||
|
weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : null,
|
||||||
|
defense: slot === 'defense' ? REINFORCEMENTS_CARD : null } },
|
||||||
|
{ actor: opponent, action: { type: 'battlePlan', dial: outcome === 'loser' ? 3 : 0,
|
||||||
|
support: outcome === 'loser' && advanced ? 3 : 0, leader: otherLeader.id,
|
||||||
|
weapon: laser, defense: shield } },
|
||||||
|
];
|
||||||
|
const normal = Math.min(3, own.reserves - (own.elites?.reserves ?? 0));
|
||||||
|
const fee = { normal, elite: 3 - normal };
|
||||||
|
assertNativeFamilyReinforcementsInventory(game);
|
||||||
|
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: kind, location,
|
||||||
|
shipments, battleStep, planActions, nativeSteps, staging, fee };
|
||||||
|
}
|
||||||
|
export function sealNativeFamilyReinforcementsPlan(state: Game, step: NativeFamilyReinforcementsStep): Game {
|
||||||
|
return advanceNativeFamilyReinforcements(applyAction(state, step.actor, step.action), g =>
|
||||||
|
!g.response && !g.phaseOpening && (!g.decision || g.decision.kind !== 'fullPlanOffer'));
|
||||||
|
}
|
||||||
|
export function revealNativeFamilyReinforcementsBattle(fixture: NativeFamilyReinforcementsFixture): Game {
|
||||||
|
let game = structuredClone(fixture.game);
|
||||||
|
for (const step of fixture.planActions) game = sealNativeFamilyReinforcementsPlan(game, step);
|
||||||
|
assert.ok(game.battle?.revealed); return game;
|
||||||
|
}
|
||||||
|
export function finishNativeFamilyReinforcementsBattle(state: Game, options: {
|
||||||
|
callers?: string[];
|
||||||
|
actions?: NativeFamilyReinforcementsStep[];
|
||||||
|
} = {}): Game {
|
||||||
|
let game = state;
|
||||||
|
for (let n = 0; n < 240; n++) {
|
||||||
|
if (!game.battle && nativeFamilyReinforcementsClean(game)) return game;
|
||||||
|
const step = nextNativeFamilyReinforcementsStep(game); assert.ok(step);
|
||||||
|
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||||
|
options.actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||||
|
}
|
||||||
|
throw Error('Native Reinforcements aftermath did not finish');
|
||||||
|
}
|
||||||
|
export function assertNativeFamilyReinforcementsInventory(game: Game): void {
|
||||||
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||||
|
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? [])];
|
||||||
|
assert.deepEqual(cards.map(c => c.id).sort(),
|
||||||
|
[...treacheryDeck(game.expansions), ...ecazTreacheryCards()].map(c => c.id).sort());
|
||||||
|
assert.equal(cards.length, game.expansions.includes('ix') ? 50 : 38);
|
||||||
|
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p =>
|
||||||
|
[...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader)])].sort(),
|
||||||
|
traitorDeck(game.players, game.expansions.includes('ix')).sort());
|
||||||
|
for (const p of game.players) {
|
||||||
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
|
||||||
|
assert.ok(p.hand.length <= (p.faction === 'choam' ? 5 : 4));
|
||||||
|
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
|
||||||
|
Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
|
||||||
|
if (p.faction === 'ixians') {
|
||||||
|
assert.equal(p.forces[MOBILE_LOCATION], 6, 'Original HMS army is never repositioned by the fixture');
|
||||||
|
assert.equal(p.elites!.forces[MOBILE_LOCATION], 3);
|
||||||
|
}
|
||||||
|
if (p.faction === 'tleilaxu') assert.equal(p.faceDancers!.length, 3);
|
||||||
|
if (p.faction === 'choam' && game.advanced) assert.ok(p.leaders.some(isAuditorLeader));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
|
||||||
|
viewGame, type Action, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import type { Card } from '../game/cards';
|
||||||
|
import type { FactionId } from '../game/catalog';
|
||||||
|
import type { NoFieldValue } from '../game/richese-no-field';
|
||||||
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||||
|
|
||||||
|
export const RICHESE_REINFORCEMENTS = 'ecaz-reinforcements';
|
||||||
|
export const RICHESE_HARASS = 'ecaz-harass-withdraw';
|
||||||
|
export const RICHESE_STONE_BURNER = 'richese-stone-burner';
|
||||||
|
export const RICHESE_MIRROR_WEAPON = 'richese-mirror-weapon';
|
||||||
|
|
||||||
|
/** Independent battle machines a caller may prepare in the machine user's hand. */
|
||||||
|
export type RicheseMachine = 'reinforcements' | 'harass' | 'stone' | 'mirror';
|
||||||
|
/** Clean selected native families; classic opponents come from the base set. */
|
||||||
|
export type RicheseFamily = 'choam' | 'ix+choam';
|
||||||
|
export type RicheseReinforcementsStep = { actor: string; action: Action };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This programme parks its first token on the initializer's opening Spice Blow.
|
||||||
|
* Actual collection funds its later tuning shipment without granting bank spice.
|
||||||
|
*/
|
||||||
|
export const RICHESE_BLOW = { territory: 'broken_land', sector: 12 } as const;
|
||||||
|
/** Empty strongholds: cheapest to ship into and free of any starting army. */
|
||||||
|
export const RICHESE_CITIES = [
|
||||||
|
{ territory: 'carthag', sector: 11 },
|
||||||
|
{ territory: 'sietch_tabr', sector: 14 },
|
||||||
|
{ territory: 'habbanya_ridge_sietch', sector: 17 },
|
||||||
|
] as const;
|
||||||
|
/** The untouched starting armies a real attack can meet. */
|
||||||
|
export const RICHESE_ARMIES = [
|
||||||
|
{ faction: 'atreides', territory: 'arrakeen', sector: 10 },
|
||||||
|
{ faction: 'guild', territory: 'tueks_sietch', sector: 5 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RicheseReinforcementsOptions = {
|
||||||
|
advanced?: boolean;
|
||||||
|
family?: RicheseFamily;
|
||||||
|
/**
|
||||||
|
* Natural multi-turn route. 'tuning' is the deterministic Basic route: two
|
||||||
|
* real No-Field reveals plus one real tuning shipment reach a low reserve
|
||||||
|
* budget off the first Spice Blow's collection. 'immediate' is the turn-one
|
||||||
|
* route used for Advanced, where the original storm card makes later turns
|
||||||
|
* nondeterministic: it ships the final token straight from twenty reserves.
|
||||||
|
*/
|
||||||
|
route?: 'tuning' | 'immediate';
|
||||||
|
/** The final concealed token shipped into the actual battle. */
|
||||||
|
marker?: NoFieldValue;
|
||||||
|
/** Own physical reserves the 'tuning' route must leave before that shipment. */
|
||||||
|
reserves?: number;
|
||||||
|
/** Own counters dialed at the final battle; derived from the physical army. */
|
||||||
|
dial?: number;
|
||||||
|
opponentDial?: number;
|
||||||
|
/** Independent machines positioned in the machine user's hand after shipment. */
|
||||||
|
machines?: readonly RicheseMachine[];
|
||||||
|
/** Conserve the real Richese Stone Burner into the opponent's hand for Mirror Weapon. */
|
||||||
|
opponentStone?: boolean;
|
||||||
|
seatIds?: [string, string, string];
|
||||||
|
/** Continue one fresh original initializer snapshot; never a redeal. */
|
||||||
|
initial?: Game;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RicheseReinforcementsFixture = {
|
||||||
|
initial: Game;
|
||||||
|
afterSetup: Game;
|
||||||
|
/** Immediately before Richese declares the final concealed No-Field shipment. */
|
||||||
|
beforeShipment: Game;
|
||||||
|
shipmentStep: RicheseReinforcementsStep;
|
||||||
|
afterShipment: Game;
|
||||||
|
/** Immediately before the actual ordinary battle admission. */
|
||||||
|
beforeBattle: Game;
|
||||||
|
battleStep: RicheseReinforcementsStep;
|
||||||
|
/** Open original battle: native preparation complete, neither plan sealed. */
|
||||||
|
game: Game;
|
||||||
|
richese: string;
|
||||||
|
opponent: string;
|
||||||
|
observer: string;
|
||||||
|
family: RicheseFamily;
|
||||||
|
advanced: boolean;
|
||||||
|
route: 'tuning' | 'immediate';
|
||||||
|
/** Stronghold the final concealed token was shipped into. */
|
||||||
|
location: string;
|
||||||
|
/** Empty stronghold the interleaved reveal/tuning shipments used, if any. */
|
||||||
|
city: string | null;
|
||||||
|
token: { id: string; value: NoFieldValue };
|
||||||
|
/** Capped physical counters the concealed marker materializes on reveal. */
|
||||||
|
materializes: number;
|
||||||
|
/** Own counters shipped into the empty stronghold on the tuning turn. */
|
||||||
|
tuning: number;
|
||||||
|
/** Each seat's own physical reserves immediately before the final shipment. */
|
||||||
|
reservesBefore: Record<string, number>;
|
||||||
|
machines: RicheseMachine[];
|
||||||
|
/** Physical machine faces positioned into the machine user's hand. */
|
||||||
|
hand: Partial<Record<RicheseMachine, string>>;
|
||||||
|
plans: {
|
||||||
|
both: RicheseReinforcementsStep;
|
||||||
|
reinforcements: RicheseReinforcementsStep;
|
||||||
|
harass: RicheseReinforcementsStep;
|
||||||
|
plain: RicheseReinforcementsStep;
|
||||||
|
opponent: RicheseReinforcementsStep;
|
||||||
|
};
|
||||||
|
/** Every real native action the programme took, in order. */
|
||||||
|
nativeSteps: RicheseReinforcementsStep[];
|
||||||
|
/** Every conserved card exchange the programme staged, in order. */
|
||||||
|
staging: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function richeseReinforcementsPlayer(game: Game, actor: string) {
|
||||||
|
const player = game.players.find(p => p.id === actor);
|
||||||
|
assert.ok(player, `Missing original seat ${actor}`);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function richeseReinforcementsSeat(game: Game, faction: FactionId) {
|
||||||
|
const player = game.players.find(p => p.faction === faction);
|
||||||
|
assert.ok(player, `Missing native ${faction} seat`);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const richeseReinforcementsClean = (game: Game) =>
|
||||||
|
!game.phaseOpening && !game.response && !game.decision;
|
||||||
|
|
||||||
|
/** Only consumes live native windows; a null result with a live battle means a
|
||||||
|
* human must seal a plan. The optional Stone mode never invents a decision. */
|
||||||
|
export function nextRicheseReinforcementsStep(
|
||||||
|
game: Game,
|
||||||
|
stoneMode: 'kill' | 'ignore' = 'ignore',
|
||||||
|
): RicheseReinforcementsStep | null {
|
||||||
|
const step = nextRicheseStrongholdsNativeStep(game);
|
||||||
|
if (step && game.decision?.kind === 'stoneBurner' &&
|
||||||
|
step.action.type === 'decision' && step.action.mode !== undefined)
|
||||||
|
return { actor: step.actor, action: { ...step.action, mode: stoneMode } };
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determinism is scoped to the original native initializer only. */
|
||||||
|
function initialize(game: Game): 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 { return initializeEcazTreacheryGameForAudit(game); }
|
||||||
|
finally {
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Richese cache faces must never enter the general deck; every other original
|
||||||
|
* face may wait there. Keeps a real hand inside its printed limit. */
|
||||||
|
function makeRoom(game: Game, actor: string, staging: string[],
|
||||||
|
protect: readonly string[] = []): void {
|
||||||
|
const player = richeseReinforcementsPlayer(game, actor);
|
||||||
|
const limit = player.faction === 'harkonnen' ? 8 : 4;
|
||||||
|
while (player.hand.length >= limit) {
|
||||||
|
const index = player.hand.findIndex(card =>
|
||||||
|
!card.id.startsWith('richese-') && !protect.includes(card.id));
|
||||||
|
assert.ok(index >= 0, `${actor}'s conserved cache faces leave no deckable hand room`);
|
||||||
|
const [card] = player.hand.splice(index, 1);
|
||||||
|
game.deck.push(card);
|
||||||
|
staging.push(`Conserved ${card.id}: ${actor}'s hand → remaining deck to keep the printed hand limit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move one conserved physical face from wherever it still lives. No face is
|
||||||
|
* ever minted, purchased or duplicated. */
|
||||||
|
function positionCard(game: Game, actor: string, cardId: string, staging: string[],
|
||||||
|
protect: readonly string[] = []): void {
|
||||||
|
const player = richeseReinforcementsPlayer(game, actor);
|
||||||
|
if (player.hand.some(card => card.id === cardId)) return;
|
||||||
|
const zones: { label: string; zone: Card[] }[] = [
|
||||||
|
{ label: 'remaining deck', zone: game.deck },
|
||||||
|
{ label: 'Richese cache', zone: game.richeseCache ?? [] },
|
||||||
|
{ label: 'discard pile', zone: game.discard },
|
||||||
|
];
|
||||||
|
for (const { label, zone } of zones) {
|
||||||
|
const index = zone.findIndex(card => card.id === cardId);
|
||||||
|
if (index < 0) continue;
|
||||||
|
makeRoom(game, actor, staging, protect);
|
||||||
|
player.hand.push(zone.splice(index, 1)[0]);
|
||||||
|
staging.push(`Conserved ${cardId}: ${label} → ${actor}'s hand (no purchase, payment or replacement)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.fail(`Conserved physical ${cardId} is missing for ${actor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The conserved Stone Burner may be held by the real cache acquisition first. */
|
||||||
|
function transferCard(game: Game, actor: string, cardId: string, staging: string[],
|
||||||
|
protect: readonly string[] = []): void {
|
||||||
|
const holder = game.players.find(player => player.hand.some(card => card.id === cardId));
|
||||||
|
if (!holder) { positionCard(game, actor, cardId, staging, protect); return; }
|
||||||
|
if (holder.id === actor) return;
|
||||||
|
const index = holder.hand.findIndex(card => card.id === cardId);
|
||||||
|
const [card] = holder.hand.splice(index, 1);
|
||||||
|
makeRoom(game, actor, staging, protect);
|
||||||
|
richeseReinforcementsPlayer(game, actor).hand.push(card);
|
||||||
|
staging.push(`Conserved ${cardId}: ${holder.id}'s hand → ${actor}'s hand (no purchase, payment or replacement)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableLeaders(game: Game, actor: string, battleTerritory: string) {
|
||||||
|
return richeseReinforcementsPlayer(game, actor).leaders
|
||||||
|
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory))
|
||||||
|
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRicheseReinforcementsFixture(
|
||||||
|
options: RicheseReinforcementsOptions = {},
|
||||||
|
): RicheseReinforcementsFixture {
|
||||||
|
const family = options.family ?? 'choam';
|
||||||
|
const advanced = options.advanced ?? false;
|
||||||
|
const route = options.route ?? (advanced ? 'immediate' : 'tuning');
|
||||||
|
if (route === 'tuning')
|
||||||
|
assert.equal(advanced, false,
|
||||||
|
'The Advanced storm card makes every turn after the first nondeterministic; use the immediate route');
|
||||||
|
assert.ok(route !== 'immediate' || options.reserves === undefined || options.reserves === 20,
|
||||||
|
'The immediate native route starts with twenty real reserves; tuning is a separate timeline');
|
||||||
|
const staging: string[] = [];
|
||||||
|
let game: Game;
|
||||||
|
if (options.initial) {
|
||||||
|
game = structuredClone(options.initial);
|
||||||
|
assert.ok(game.status === 'lobby' ||
|
||||||
|
(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||||
|
game.players.every(player => player.hand.length === 0)),
|
||||||
|
'Continue only the original fresh undealt independent-card setup');
|
||||||
|
assert.equal(game.advanced, advanced);
|
||||||
|
assert.deepEqual([...game.expansions].sort(), family === 'choam' ? ['choam'] : ['choam', 'ix']);
|
||||||
|
assert.ok(game.players.some(player => player.faction === 'richese'));
|
||||||
|
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
|
||||||
|
} else {
|
||||||
|
const roster: FactionId[] = family === 'choam'
|
||||||
|
? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'];
|
||||||
|
const ids = options.seatIds ?? roster;
|
||||||
|
game = createGame('RICHESEREINFORCEMENTS', newPlayer(ids[0], 'Richese', roster[0]),
|
||||||
|
advanced, family === 'choam' ? ['choam'] : ['ix', 'choam']);
|
||||||
|
joinGame(game, newPlayer(ids[1], roster[1], roster[1]));
|
||||||
|
joinGame(game, newPlayer(ids[2], roster[2], roster[2]));
|
||||||
|
}
|
||||||
|
if (game.status === 'lobby') {
|
||||||
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
game = initialize(game);
|
||||||
|
}
|
||||||
|
const initial = structuredClone(game);
|
||||||
|
for (let count = 0; game.status === 'setup' && count < 300; count++) {
|
||||||
|
let next: RicheseReinforcementsStep | undefined;
|
||||||
|
for (const player of game.players) {
|
||||||
|
const view = viewGame(game, player.id);
|
||||||
|
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||||
|
const action = botActions(view)[0];
|
||||||
|
if (action) { next = { actor: player.id, action }; break; }
|
||||||
|
}
|
||||||
|
assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`);
|
||||||
|
game = applyAction(game, next.actor, next.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
const richese = richeseReinforcementsSeat(game, 'richese').id;
|
||||||
|
const marker = options.marker ?? 3;
|
||||||
|
// Order A keeps the zero token for the reuse turn; otherwise the three token
|
||||||
|
// is the reuse turn and the final hidden token is zero or five.
|
||||||
|
const reuseValue: NoFieldValue = marker === 3 ? 0 : 3;
|
||||||
|
const baseline = marker === 3 ? 15 : 12;
|
||||||
|
const target = options.reserves ?? (marker === 3 ? 6 : 8);
|
||||||
|
const tuning = route === 'tuning' ? baseline - target : 0;
|
||||||
|
assert.ok(route === 'immediate' || (target >= 0 && target <= baseline),
|
||||||
|
'The tuning route can only leave these reserve budgets');
|
||||||
|
assert.ok(tuning <= 10, 'The tuning shipment must stay inside the spice the real route collected');
|
||||||
|
const nativeSteps: RicheseReinforcementsStep[] = [];
|
||||||
|
const act = (step: RicheseReinforcementsStep) => {
|
||||||
|
nativeSteps.push(structuredClone(step));
|
||||||
|
game = applyAction(game, step.actor, step.action);
|
||||||
|
};
|
||||||
|
const rich = () => richeseReinforcementsPlayer(game, richese);
|
||||||
|
const tokenOf = (value: NoFieldValue) => rich().noField!.tokens.find(token => token.value === value)!;
|
||||||
|
const stormFreeCity = () => {
|
||||||
|
const city = RICHESE_CITIES.find(candidate => candidate.sector !== game.storm);
|
||||||
|
assert.ok(city, 'The natural route keeps one empty stronghold outside the storm');
|
||||||
|
return city;
|
||||||
|
};
|
||||||
|
const battleTarget = () => {
|
||||||
|
for (const army of RICHESE_ARMIES) {
|
||||||
|
const seat = game.players.find(player => player.faction === army.faction);
|
||||||
|
if (!seat || army.sector === game.storm) continue;
|
||||||
|
if ((seat.forces[`${army.territory}:${army.sector}`] ?? 0) > 0) return { ...army, seat: seat.id };
|
||||||
|
}
|
||||||
|
assert.fail('The timeline needs one untouched starting army outside the storm');
|
||||||
|
};
|
||||||
|
const done = { shipment: {} as Record<number, boolean>, reveal: {} as Record<number, boolean> };
|
||||||
|
let open = false;
|
||||||
|
let beforeShipment: Game | undefined;
|
||||||
|
let afterShipment: Game | undefined;
|
||||||
|
let shipmentStep: RicheseReinforcementsStep | undefined;
|
||||||
|
let beforeBattle: Game | undefined;
|
||||||
|
let battleStep: RicheseReinforcementsStep | undefined;
|
||||||
|
let reservesBefore: Record<string, number> | undefined;
|
||||||
|
let city: string | null = null;
|
||||||
|
let army: { faction: string; territory: string; sector: number; seat: string } | undefined;
|
||||||
|
const clear = () => richeseReinforcementsClean(game);
|
||||||
|
// Native actions only: no counter is placed by hand and no wallet is set.
|
||||||
|
// Richese's first real concealed shipment parks the token on the one
|
||||||
|
// deterministic opening Spice Blow (Broken Land, sector 12). Its real reveal
|
||||||
|
// materializes five counters that then collect that blow over two Mentat
|
||||||
|
// pauses (5, then the remaining 3). A second real reveal plus one real paid
|
||||||
|
// tuning shipment into an empty stronghold leave the requested reserve
|
||||||
|
// budget, with a real charity claim when a low budget needs the top-up.
|
||||||
|
for (let count = 0; count < 2400 && !open; count++) {
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
if (done.shipment[4] && !afterShipment && clear()) afterShipment = structuredClone(game);
|
||||||
|
const choices = game.phase === 6 && clear()
|
||||||
|
? viewGame(game, richese).battleChoices : [];
|
||||||
|
if (!open && choices.some(choice => choice.territory === army?.territory)) {
|
||||||
|
const choice = choices.find(candidate => candidate.territory === army!.territory)!;
|
||||||
|
beforeBattle = structuredClone(game);
|
||||||
|
battleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: army!.territory,
|
||||||
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||||
|
act(battleStep);
|
||||||
|
open = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (game.phase === 2 && game.turn === 4 && clear() &&
|
||||||
|
rich().charityTurn !== game.turn && rich().spice < 2) {
|
||||||
|
act({ actor: richese, action: { type: 'charity' } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (game.phase === 5 && game.active === richese && clear()) {
|
||||||
|
const turn = game.turn;
|
||||||
|
if (route === 'tuning' && turn === 1 && !done.shipment[1]) {
|
||||||
|
act({ actor: richese, action: { type: 'ship', noField: tokenOf(5).id,
|
||||||
|
event: rich().noFieldEvent, territory: RICHESE_BLOW.territory, sector: RICHESE_BLOW.sector } });
|
||||||
|
done.shipment[1] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route === 'tuning' && turn === 2 && rich().noField!.deployed && !done.reveal[2]) {
|
||||||
|
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
|
||||||
|
token: rich().noField!.deployed!.tokenId } });
|
||||||
|
done.reveal[2] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route === 'tuning' && turn === 2 && !done.shipment[2] && !rich().shipped) {
|
||||||
|
const cityChoice = stormFreeCity();
|
||||||
|
city = `${cityChoice.territory}:${cityChoice.sector}`;
|
||||||
|
act({ actor: richese, action: { type: 'ship', noField: tokenOf(reuseValue).id,
|
||||||
|
event: rich().noFieldEvent, territory: cityChoice.territory, sector: cityChoice.sector } });
|
||||||
|
done.shipment[2] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route === 'tuning' && turn === 3 && rich().noField!.deployed && !done.reveal[3]) {
|
||||||
|
assert.equal(rich().noField!.deployed!.tokenId, tokenOf(reuseValue).id,
|
||||||
|
'The reuse token must still be the concealed one this timeline reveals');
|
||||||
|
act({ actor: richese, action: { type: 'revealNoField', event: rich().noFieldEvent,
|
||||||
|
token: rich().noField!.deployed!.tokenId } });
|
||||||
|
done.reveal[3] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route === 'tuning' && turn === 3 && !done.shipment[3] && !rich().shipped) {
|
||||||
|
const cityChoice = stormFreeCity();
|
||||||
|
act({ actor: richese, action: { type: 'ship', territory: cityChoice.territory,
|
||||||
|
sector: cityChoice.sector, amount: tuning } });
|
||||||
|
done.shipment[3] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const finalTurn = route === 'tuning' ? 4 : 1;
|
||||||
|
if (turn === finalTurn && !done.shipment[4] && !rich().shipped) {
|
||||||
|
army = battleTarget();
|
||||||
|
beforeShipment = structuredClone(game);
|
||||||
|
reservesBefore = Object.fromEntries(game.players.map(player => [player.id, player.reserves]));
|
||||||
|
shipmentStep = { actor: richese, action: { type: 'ship', noField: tokenOf(marker).id,
|
||||||
|
event: rich().noFieldEvent, territory: army.territory, sector: army.sector } };
|
||||||
|
act(shipmentStep);
|
||||||
|
done.shipment[4] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert.ok(turn <= (route === 'tuning' ? 4 : 1),
|
||||||
|
'The natural timeline must reach its battle by the scheduled turn');
|
||||||
|
}
|
||||||
|
const step = nextRicheseReinforcementsStep(game);
|
||||||
|
assert.ok(step, `Native Richese continuation stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0;
|
||||||
|
act(step);
|
||||||
|
}
|
||||||
|
assert.ok(open, 'The final concealed shipment must reach a real ordinary battle');
|
||||||
|
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep &&
|
||||||
|
reservesBefore && army, 'The timeline must record its final shipment, army and battle admission');
|
||||||
|
for (let count = 0; count < 120; count++) {
|
||||||
|
const step = nextRicheseReinforcementsStep(game);
|
||||||
|
if (!step) break;
|
||||||
|
act(step);
|
||||||
|
}
|
||||||
|
assert.ok(game.battle && !game.battle.revealed && clear());
|
||||||
|
assert.deepEqual(game.battle.plans, {});
|
||||||
|
const opponent = army.seat;
|
||||||
|
const observer = game.players.find(player => player.faction !== 'richese' &&
|
||||||
|
player.id !== opponent)!.id;
|
||||||
|
const machines: RicheseMachine[] = [...(options.machines ?? ['reinforcements', 'harass'])];
|
||||||
|
if (options.opponentStone && !machines.includes('mirror')) machines.push('mirror');
|
||||||
|
const hand: Partial<Record<RicheseMachine, string>> = {};
|
||||||
|
const requested = machines.map(machine => machine === 'reinforcements' ? RICHESE_REINFORCEMENTS
|
||||||
|
: machine === 'harass' ? RICHESE_HARASS
|
||||||
|
: machine === 'stone' ? RICHESE_STONE_BURNER : RICHESE_MIRROR_WEAPON);
|
||||||
|
if (options.opponentStone) transferCard(game, opponent, RICHESE_STONE_BURNER, staging, requested);
|
||||||
|
for (const machine of machines) {
|
||||||
|
const cardId = machine === 'reinforcements' ? RICHESE_REINFORCEMENTS
|
||||||
|
: machine === 'harass' ? RICHESE_HARASS
|
||||||
|
: machine === 'stone' ? RICHESE_STONE_BURNER : RICHESE_MIRROR_WEAPON;
|
||||||
|
positionCard(game, richese, cardId, staging, requested);
|
||||||
|
hand[machine] = cardId;
|
||||||
|
}
|
||||||
|
const location = `${army.territory}:${army.sector}`;
|
||||||
|
const enemyForces = richeseReinforcementsPlayer(game, opponent).forces[location] ?? 0;
|
||||||
|
assert.ok(enemyForces > 0, 'The battle army must still hold the stronghold');
|
||||||
|
const reserves = reservesBefore[richese];
|
||||||
|
if (route === 'tuning')
|
||||||
|
assert.equal(reserves, target, 'The tuning route must leave the requested reserve budget');
|
||||||
|
const materializes = Math.min(tokenOf(marker).value, reserves);
|
||||||
|
const ownLeader = availableLeaders(game, richese, army.territory)[0];
|
||||||
|
const otherLeader = availableLeaders(game, opponent, army.territory)[0];
|
||||||
|
assert.ok(ownLeader && otherLeader);
|
||||||
|
const ownDial = options.dial ?? (advanced ? Math.min(1, Math.floor(materializes / 2))
|
||||||
|
: Math.min(2, materializes));
|
||||||
|
const enemyDial = options.opponentDial ?? Math.min(5, enemyForces);
|
||||||
|
const machineSlot = (machine: RicheseMachine | undefined) =>
|
||||||
|
machine === undefined ? null : hand[machine] ?? null;
|
||||||
|
const plan = (weapon: RicheseMachine | undefined, defense: RicheseMachine | undefined):
|
||||||
|
RicheseReinforcementsStep => ({
|
||||||
|
actor: richese,
|
||||||
|
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
|
||||||
|
leader: ownLeader.id, weapon: machineSlot(weapon), defense: machineSlot(defense) },
|
||||||
|
});
|
||||||
|
const plans = {
|
||||||
|
both: plan('reinforcements', 'harass'),
|
||||||
|
reinforcements: plan('reinforcements', undefined),
|
||||||
|
harass: plan(undefined, 'harass'),
|
||||||
|
plain: plan(undefined, undefined),
|
||||||
|
opponent: { actor: opponent, action: { type: 'battlePlan', dial: enemyDial,
|
||||||
|
support: advanced ? enemyDial : 0, leader: otherLeader.id } },
|
||||||
|
} satisfies RicheseReinforcementsFixture['plans'];
|
||||||
|
return {
|
||||||
|
initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
|
||||||
|
game, richese, opponent, observer, family, advanced, route, location, city,
|
||||||
|
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore,
|
||||||
|
machines, hand, plans, nativeSteps, staging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seal one real plan and consume only its pre-reveal preparation windows; a
|
||||||
|
* live post-reveal Stone/withdrawal decision is left for the caller. */
|
||||||
|
export function prepareRicheseReinforcementsPlan(game: Game, step: RicheseReinforcementsStep): Game {
|
||||||
|
let state = applyAction(game, step.actor, step.action);
|
||||||
|
for (let count = 0; count < 120; count++) {
|
||||||
|
if (state.battle?.revealed) return state;
|
||||||
|
const pending = !!state.response || !!state.phaseOpening || !!state.decision ||
|
||||||
|
(!!state.battle?.preLeader && !state.battle.preLeader.closed) || !!state.battle?.preparation;
|
||||||
|
if (!pending) return state;
|
||||||
|
const next = nextRicheseReinforcementsStep(state);
|
||||||
|
if (!next) return state;
|
||||||
|
state = applyAction(state, next.actor, next.action);
|
||||||
|
}
|
||||||
|
throw Error('Sealed plan did not finish its pre-reveal preparation');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seal one real plan and consume only the native windows it opens. */
|
||||||
|
export function sealRicheseReinforcementsPlan(game: Game, step: RicheseReinforcementsStep,
|
||||||
|
stoneMode: 'kill' | 'ignore' = 'ignore'): Game {
|
||||||
|
let state = applyAction(game, step.actor, step.action);
|
||||||
|
for (let count = 0; count < 120; count++) {
|
||||||
|
if (!state.response && !state.phaseOpening && !state.decision) return state;
|
||||||
|
const next = nextRicheseReinforcementsStep(state, stoneMode);
|
||||||
|
assert.ok(next, `Sealed plan left an unconsumed native window at ${state.decision?.kind}`);
|
||||||
|
state = applyAction(state, next.actor, next.action);
|
||||||
|
}
|
||||||
|
throw Error('Sealed plan did not finish its native preparation');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both real plans sealed; the native reveal and any live decisions remain. */
|
||||||
|
export function sealRicheseReinforcementsBoth(fixture: RicheseReinforcementsFixture,
|
||||||
|
plan: 'both' | 'reinforcements' | 'harass' | 'plain' = 'reinforcements',
|
||||||
|
state = fixture.game, stoneMode: 'kill' | 'ignore' = 'ignore'): Game {
|
||||||
|
const first = sealRicheseReinforcementsPlan(state, fixture.plans[plan], stoneMode);
|
||||||
|
return sealRicheseReinforcementsPlan(first, fixture.plans.opponent, stoneMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the programme's own reveal and casualties, before any later battle. */
|
||||||
|
export function finishRicheseReinforcementsBattle(game: Game,
|
||||||
|
options: { callers?: string[]; stoneModes?: Record<string, 'kill' | 'ignore'> } = {}): Game {
|
||||||
|
const event = game.battle?.event;
|
||||||
|
assert.ok(event, 'Finish only a live original battle');
|
||||||
|
let state = game;
|
||||||
|
for (let count = 0; count < 240; count++) {
|
||||||
|
if (!state.battle && state.lastBattleContext?.event === event &&
|
||||||
|
richeseReinforcementsClean(state) && !state.pendingTreacheryDiscard) return state;
|
||||||
|
let step: RicheseReinforcementsStep | null;
|
||||||
|
if (!state.response && !state.phaseOpening && state.decision?.kind === 'stoneBurner') {
|
||||||
|
step = { actor: state.decision.player, action: { type: 'decision', event: state.decision.event,
|
||||||
|
mode: options.stoneModes?.[state.decision.player] ?? 'ignore' } };
|
||||||
|
} else {
|
||||||
|
const next = nextRicheseReinforcementsStep(state);
|
||||||
|
step = next && next.action.type === 'traitorCall'
|
||||||
|
? { actor: next.actor, action: { ...next.action, call: options.callers?.includes(next.actor) ?? false } }
|
||||||
|
: next;
|
||||||
|
}
|
||||||
|
assert.ok(step, `Native battle continuation stalled at ${state.decision?.kind}`);
|
||||||
|
state = applyAction(state, step.actor, step.action);
|
||||||
|
}
|
||||||
|
throw Error('Native Richese battle did not finish');
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||||
|
import { MOBILE_LOCATION } from '../game/board';
|
||||||
|
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||||
|
import {
|
||||||
|
assertNativeFamilyReinforcementsInventory,
|
||||||
|
createNativeFamilyReinforcementsFixture,
|
||||||
|
finishNativeFamilyReinforcementsBattle,
|
||||||
|
nativeFamilyReinforcementsClean,
|
||||||
|
nativeFamilyReinforcementsPlayer as player,
|
||||||
|
revealNativeFamilyReinforcementsBattle,
|
||||||
|
sealNativeFamilyReinforcementsPlan,
|
||||||
|
type NativeFamilyReinforcementsFixture,
|
||||||
|
type NativeFamilyReinforcementsStep,
|
||||||
|
} from './fixture-native-family-reinforcements';
|
||||||
|
|
||||||
|
function assertDisposed(game: Game,
|
||||||
|
fixture: NativeFamilyReinforcementsFixture): void {
|
||||||
|
assert.equal(game.battle, null);
|
||||||
|
assert.ok(nativeFamilyReinforcementsClean(game));
|
||||||
|
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
|
||||||
|
assert.ok(!player(game, fixture.owner).hand.some(c => c.id === REINFORCEMENTS_CARD));
|
||||||
|
assertNativeFamilyReinforcementsInventory(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
for (const faction of ['ixians', 'tleilaxu', 'choam'] as const) {
|
||||||
|
for (const slot of ['weapon', 'defense'] as const) {
|
||||||
|
void test(`${advanced ? 'Advanced' : 'Basic'} native ${faction} ${slot}: fee3/+2 wins without more physical dial or support`, () => {
|
||||||
|
const f = createNativeFamilyReinforcementsFixture({ faction, advanced, slot });
|
||||||
|
const own = player(f.game, f.owner), opponent = player(f.game, f.opponent);
|
||||||
|
const preview = viewGame(f.game, f.owner).battle!.reinforcements;
|
||||||
|
assert.equal(preview?.blocked, null);
|
||||||
|
|
||||||
|
const first = sealNativeFamilyReinforcementsPlan(structuredClone(f.game), f.planActions[0]);
|
||||||
|
assert.equal(player(first, f.owner).reserves, own.reserves, 'A private plan does not spend the eventual reveal fee');
|
||||||
|
assert.equal(player(first, f.owner).tanks, own.tanks);
|
||||||
|
const revealed = sealNativeFamilyReinforcementsPlan(first, f.planActions[1]);
|
||||||
|
assert.deepEqual(player(revealed, f.owner).forces, own.forces, 'Reinforcements has not shipped an army into the battle');
|
||||||
|
assert.deepEqual(player(revealed, f.owner).elites?.forces, own.elites?.forces);
|
||||||
|
|
||||||
|
const continuation: NativeFamilyReinforcementsStep[] = [];
|
||||||
|
const done = finishNativeFamilyReinforcementsBattle(revealed, { actions: continuation });
|
||||||
|
const after = player(done, f.owner);
|
||||||
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||||
|
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||||
|
assert.equal(after.reserves, own.reserves - 3);
|
||||||
|
assert.equal(after.elites?.reserves, own.elites?.reserves, 'Normal reserves pay before untouched cyborg reserves');
|
||||||
|
assert.equal(after.tanks, 5, 'Fee3 plus exactly two original physical committed tokens');
|
||||||
|
assert.equal(after.battleLosses, 5);
|
||||||
|
assert.equal(after.elites?.tanks ?? 0, faction === 'ixians' ? 1 : 0);
|
||||||
|
assert.equal(after.forces[f.location], 1);
|
||||||
|
assert.equal(after.elites?.forces[f.location] ?? 0, faction === 'ixians' ? 1 : 0);
|
||||||
|
assert.equal(player(done, f.opponent).reserves, opponent.reserves);
|
||||||
|
assert.equal(player(done, f.opponent).tanks, 3);
|
||||||
|
if (faction === 'choam' && advanced) assert.ok(continuation.some(s =>
|
||||||
|
s.action.type === 'decision' && s.action.audit === false), 'The surviving original Auditor still offers its real inspection');
|
||||||
|
assertDisposed(done, f);
|
||||||
|
|
||||||
|
// Holding all original armies, leaders, dial and support fixed, remove
|
||||||
|
// only R: total 3.5 (Ix) or 4 (others) loses to the same printed five.
|
||||||
|
// R adds precisely two strength, not extra supported physical tokens.
|
||||||
|
const plain = structuredClone(f.planActions[0]);
|
||||||
|
plain.action.weapon = null; plain.action.defense = null;
|
||||||
|
let control = sealNativeFamilyReinforcementsPlan(structuredClone(f.game), plain);
|
||||||
|
control = sealNativeFamilyReinforcementsPlan(control, f.planActions[1]);
|
||||||
|
control = finishNativeFamilyReinforcementsBattle(control);
|
||||||
|
assert.equal(control.lastBattleContext!.winner, f.opponent);
|
||||||
|
assert.equal(player(control, f.owner).reserves, own.reserves);
|
||||||
|
assert.equal(player(control, f.owner).tanks, 3);
|
||||||
|
assertNativeFamilyReinforcementsInventory(control);
|
||||||
|
|
||||||
|
const impossible = structuredClone(f.planActions[0].action);
|
||||||
|
impossible.dial = faction === 'ixians' ? 6.5 : 5;
|
||||||
|
impossible.support = advanced ? faction === 'ixians' ? 2 : 3 : 0;
|
||||||
|
const unchanged = structuredClone(f.game);
|
||||||
|
assert.throws(() => applyAction(f.game, f.owner, impossible), 'The +2 card does not allow dialing two additional tokens');
|
||||||
|
assert.deepEqual(f.game, unchanged);
|
||||||
|
if (faction === 'ixians' && advanced) {
|
||||||
|
const overSupported = { ...f.planActions[0].action, support: 3 };
|
||||||
|
assert.throws(() => applyAction(f.game, f.owner, overSupported), 'The original suboid cannot receive spice support');
|
||||||
|
assert.deepEqual(f.game, unchanged);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct native consequences, rather than repeated roster/default rows:
|
||||||
|
// cyborg losses/explosion, Tleilaxu's genuine Face Dancer window, and the
|
||||||
|
// Auditor's exceptional-outcome continuation all retain the revealed fee.
|
||||||
|
for (const { faction, outcome } of [
|
||||||
|
{ faction: 'ixians', outcome: 'loser' },
|
||||||
|
{ faction: 'ixians', outcome: 'explosion' },
|
||||||
|
{ faction: 'tleilaxu', outcome: 'loser' },
|
||||||
|
{ faction: 'tleilaxu', outcome: 'traitor' },
|
||||||
|
{ faction: 'choam', outcome: 'traitor' },
|
||||||
|
{ faction: 'choam', outcome: 'explosion' },
|
||||||
|
] as const) {
|
||||||
|
void test(`${advanced ? 'Advanced' : 'Basic'} native ${faction} ${outcome}: revealed fee and mandatory discard survive native aftermath`, () => {
|
||||||
|
const f = createNativeFamilyReinforcementsFixture({ faction, advanced, outcome,
|
||||||
|
slot: advanced ? 'defense' : 'weapon' });
|
||||||
|
const before = player(f.game, f.owner), continuation: NativeFamilyReinforcementsStep[] = [];
|
||||||
|
const done = finishNativeFamilyReinforcementsBattle(revealNativeFamilyReinforcementsBattle(f), {
|
||||||
|
callers: outcome === 'traitor' ? [f.opponent] : [], actions: continuation,
|
||||||
|
});
|
||||||
|
const own = player(done, f.owner);
|
||||||
|
assert.equal(done.lastBattleContext!.result, outcome === 'loser' ? 'normal' : outcome);
|
||||||
|
assert.equal(done.lastBattleContext!.winner, outcome === 'explosion' ? null : f.opponent);
|
||||||
|
assert.equal(own.reserves, before.reserves - 3);
|
||||||
|
assert.equal(own.elites?.reserves, before.elites?.reserves);
|
||||||
|
assert.equal(own.tanks, 6, 'Fee3 and only the three original losing/exploding counters');
|
||||||
|
assert.equal(own.battleLosses, 6);
|
||||||
|
assert.equal(own.elites?.tanks ?? 0, faction === 'ixians' ? 2 : 0);
|
||||||
|
assert.equal(own.forces[f.location] ?? 0, 0);
|
||||||
|
assert.equal(own.spice, before.spice - Number(f.planActions[0].action.support), 'Only the printed physical support is paid, with no support for fee counters or +2');
|
||||||
|
if (faction === 'choam' && advanced) assert.ok(continuation.some(s =>
|
||||||
|
s.action.type === 'decision' && s.action.audit === false), 'The dead or surviving Auditor is handled by its original control');
|
||||||
|
if (faction === 'tleilaxu' && outcome === 'loser') assert.ok(continuation.some(s =>
|
||||||
|
s.action.type === 'decision' && s.action.reveal === false), 'The actual matching native Face Dancer is offered after the Guild victory');
|
||||||
|
assertDisposed(done, f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
|
||||||
|
const f = createNativeFamilyReinforcementsFixture({ advanced, faction: 'ixians', mixedFee: true,
|
||||||
|
expansions: ['ix', 'choam'], slot: advanced ? 'weapon' : 'defense' });
|
||||||
|
const own = player(f.game, f.owner);
|
||||||
|
assert.equal(own.reserves, 5);
|
||||||
|
assert.equal(own.elites!.reserves, 4);
|
||||||
|
const done = finishNativeFamilyReinforcementsBattle(revealNativeFamilyReinforcementsBattle(f));
|
||||||
|
const after = player(done, f.owner);
|
||||||
|
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||||
|
assert.equal(after.reserves, 2);
|
||||||
|
assert.equal(after.elites!.reserves, 2, 'Exactly two original cyborgs supplement the one remaining suboid');
|
||||||
|
assert.equal(after.tanks, 4, 'Three fee tokens plus one half-strength dialed suboid');
|
||||||
|
assert.equal(after.elites!.tanks, 2, 'Reserve cyborg fee enters matching elite Tanks, not normal Tanks');
|
||||||
|
assert.equal(after.battleLosses, 4);
|
||||||
|
assert.equal(after.forces[f.location], 8);
|
||||||
|
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
|
||||||
|
assert.equal(after.forces[MOBILE_LOCATION], 6);
|
||||||
|
assert.equal(after.elites!.forces[MOBILE_LOCATION], 3);
|
||||||
|
assertDisposed(done, f);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
|
import { applyAction, viewGame, RuleError, type Game } from '../game/engine';
|
||||||
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
import { occupyRicheseSeat } from './fixture-ecaz-occupy-richese';
|
||||||
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
|
||||||
import { createHarassNoFieldFixture, sealHarassNoFieldPlan } from './fixture-harass-no-field';
|
|
||||||
import {
|
import {
|
||||||
noFieldReinforcementsFixture, sealNoFieldReinforcements, finishNoFieldReinforcements,
|
noFieldReinforcementsFixture, sealNoFieldReinforcements, finishNoFieldReinforcements,
|
||||||
NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS, type NoFieldReinforcementsFixture,
|
NO_FIELD_REINFORCEMENTS, NO_FIELD_HARASS, type NoFieldReinforcementsFixture,
|
||||||
@@ -186,23 +184,3 @@ void test('Original Advanced native inventory without the selected Occupy profil
|
|||||||
assert.deepEqual(before, fixture.game);
|
assert.deepEqual(before, fixture.game);
|
||||||
assertConserved(fixture, before);
|
assertConserved(fixture, before);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('Ordinary Basic Richese Harass with a real concealed marker does not widen Reinforcements composition support', () => {
|
|
||||||
const fixture = createHarassNoFieldFixture({ advanced: false, marker: 3, reserves: 6 });
|
|
||||||
stageEcazOccupyCard(fixture.game, fixture.owner, card => card.id === NO_FIELD_REINFORCEMENTS);
|
|
||||||
const before = structuredClone(fixture.game);
|
|
||||||
const preview = viewGame(before, fixture.owner).battle!;
|
|
||||||
assert.equal(before.advanced, false);
|
|
||||||
assert.equal(preview.harassWithdraw!.blocked, null);
|
|
||||||
assert.ok(preview.reinforcements!.blocked);
|
|
||||||
assert.throws(() => applyAction(before, fixture.owner, {
|
|
||||||
...fixture.planActions[0].action, weapon: NO_FIELD_REINFORCEMENTS, defense: null,
|
|
||||||
}), RuleError);
|
|
||||||
assert.deepEqual(before, fixture.game);
|
|
||||||
const legal = sealHarassNoFieldPlan(before, fixture.planActions[0]);
|
|
||||||
assert.ok(legal.battle!.plans[fixture.owner]);
|
|
||||||
assert.equal(legal.battle!.revealed, false);
|
|
||||||
assert.equal(occupyRicheseSeat(legal, fixture.owner).reserves, 6);
|
|
||||||
assert.deepEqual(occupyRicheseSeat(legal, fixture.owner).noField,
|
|
||||||
occupyRicheseSeat(before, fixture.owner).noField);
|
|
||||||
});
|
|
||||||
@@ -161,13 +161,3 @@ void test('insufficient reserves and unsupported modules reject without mutating
|
|||||||
assert.throws(() => applyAction(g, 'a',
|
assert.throws(() => applyAction(g, 'a',
|
||||||
{ type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);
|
{ type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }), RuleError);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('other expansion factions remain ineligible for the paired Ecaz card prototype', () => {
|
|
||||||
for (const owner of ['ixians', 'choam'] as const) {
|
|
||||||
const g = harassWithdrawGame({ advanced: true, factions: [owner, 'atreides', 'harkonnen'] });
|
|
||||||
takeHarassCard(g, 'a', cardId);
|
|
||||||
assert.throws(() => applyAction(g, 'a', {
|
|
||||||
type: 'battlePlan', dial: 0, support: 0, leader: `${owner}-0`, weapon: cardId,
|
|
||||||
}), RuleError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, viewGame, RuleError, type Action, type Game } from '../game/engine';
|
||||||
|
import {
|
||||||
|
createRicheseReinforcementsFixture, finishRicheseReinforcementsBattle,
|
||||||
|
prepareRicheseReinforcementsPlan, RICHESE_HARASS, RICHESE_MIRROR_WEAPON,
|
||||||
|
RICHESE_REINFORCEMENTS, RICHESE_STONE_BURNER, richeseReinforcementsPlayer,
|
||||||
|
sealRicheseReinforcementsBoth, sealRicheseReinforcementsPlan,
|
||||||
|
type RicheseReinforcementsFixture,
|
||||||
|
} from './fixture-richese-reinforcements';
|
||||||
|
|
||||||
|
/** Every conserved physical card, counter pool and hand limit of the programme. */
|
||||||
|
function assertConserved(fixture: RicheseReinforcementsFixture, game: Game): void {
|
||||||
|
const cards = (state: Game) => [...state.deck, ...state.discard, ...(state.richeseCache ?? []),
|
||||||
|
...(state.richeseRemoved ?? []), ...state.players.flatMap(player => player.hand)]
|
||||||
|
.map(card => card.id).sort();
|
||||||
|
assert.deepEqual(cards(game), cards(fixture.initial),
|
||||||
|
'Every original deck, cache, discard and hand face keeps physical custody.');
|
||||||
|
const pools = (state: Game) => state.players.map(player => [player.id,
|
||||||
|
player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0),
|
||||||
|
player.elites
|
||||||
|
? player.elites.reserves + player.elites.tanks +
|
||||||
|
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0)
|
||||||
|
: 0,
|
||||||
|
]);
|
||||||
|
assert.deepEqual(pools(game), pools(fixture.afterSetup),
|
||||||
|
'Every physical counter stays in its own native pool.');
|
||||||
|
for (const player of game.players)
|
||||||
|
assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4),
|
||||||
|
`${player.faction} stays inside its printed hand limit.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function assertNoUtilityCustody(game: Game, card: string): void {
|
||||||
|
assert.equal(game.discard.filter(face => face.id === card).length, 1);
|
||||||
|
assert.equal(game.deck.filter(face => face.id === card).length, 0);
|
||||||
|
assert.ok(game.players.every(player => !player.hand.some(face => face.id === card)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one selected plan leader without trusting the loose action shape. */
|
||||||
|
function actionLeader(action: Action): string {
|
||||||
|
const leader = action.leader;
|
||||||
|
assert.ok(typeof leader === 'string', 'The prepared plan selects one physical leader');
|
||||||
|
return leader;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (const slot of ['weapon', 'defense'] as const)
|
||||||
|
void test(`Funded marker 3 leaves exactly three reserves after the real reveal and charges them to the Tanks in the ${slot} slot`, () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 6 });
|
||||||
|
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
|
||||||
|
assert.equal(preview.blocked, null);
|
||||||
|
assert.equal(preview.noField!.materializes, 3);
|
||||||
|
assert.equal(preview.noField!.reservesAfterReveal, 3);
|
||||||
|
const plan = slot === 'weapon' ? fixture.plans.reinforcements : {
|
||||||
|
...fixture.plans.reinforcements,
|
||||||
|
action: { ...fixture.plans.reinforcements.action, weapon: null, defense: RICHESE_REINFORCEMENTS },
|
||||||
|
};
|
||||||
|
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
|
||||||
|
assert.equal(first.battle!.revealed, false);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(first, fixture.richese).reserves, 6,
|
||||||
|
'The reserve fee is not charged before the native reveal.');
|
||||||
|
const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent);
|
||||||
|
assert.equal(revealed.battle!.revealed, true);
|
||||||
|
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
|
||||||
|
assert.equal(owner.reserves, 3, 'The reveal materializes three capped reserves.');
|
||||||
|
assert.equal(owner.forces[fixture.location], 3);
|
||||||
|
assert.equal(owner.tanks, 0);
|
||||||
|
assert.equal(owner.noField!.deployed, null);
|
||||||
|
assert.equal(owner.noField!.lastShipped, fixture.token.id,
|
||||||
|
'Revelation preserves the real most-recent-shipment identity.');
|
||||||
|
const done = finishRicheseReinforcementsBattle(revealed);
|
||||||
|
const after = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||||
|
assert.equal(done.lastBattleContext!.winner, fixture.opponent);
|
||||||
|
assert.equal(after.reserves, 0);
|
||||||
|
assert.equal(after.tanks, 6, 'Three physical losses plus the three-reserve fee reached the Tanks.');
|
||||||
|
assert.equal(after.battleLosses, 6);
|
||||||
|
assert.equal(after.forces[fixture.location] ?? 0, 0);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [marker, reserves, materializes, finalReserves, finalTanks] of
|
||||||
|
[[0, 6, 0, 3, 3], [5, 8, 5, 0, 8], [3, 8, 3, 2, 6]] as const)
|
||||||
|
void test(`Funded No-Field ${marker} with ${reserves} reserves reveals ${materializes} counters and pays the fee`, () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker, reserves });
|
||||||
|
const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!;
|
||||||
|
assert.equal(preview.blocked, null);
|
||||||
|
assert.equal(preview.noField!.materializes, materializes);
|
||||||
|
assert.equal(preview.noField!.reservesAfterReveal, reserves - materializes);
|
||||||
|
assert.equal(preview.normal, Math.min(3, reserves - materializes));
|
||||||
|
const revealed = sealRicheseReinforcementsBoth(fixture, 'reinforcements');
|
||||||
|
const owner = richeseReinforcementsPlayer(revealed, fixture.richese);
|
||||||
|
assert.equal(owner.forces[fixture.location] ?? 0, materializes);
|
||||||
|
assert.equal(owner.noField!.deployed, null);
|
||||||
|
const done = finishRicheseReinforcementsBattle(revealed);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, finalReserves);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).tanks, finalTanks);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('A reserve-capped marker five at three reserves materializes only three and refuses the plan', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 3 });
|
||||||
|
const before = structuredClone(fixture.game);
|
||||||
|
const preview = viewGame(before, fixture.richese).battle!.reinforcements!;
|
||||||
|
assert.equal(preview.noField!.materializes, 3, 'The reveal is capped by the physical reserves.');
|
||||||
|
assert.equal(preview.noField!.reservesAfterReveal, 0);
|
||||||
|
assert.equal(preview.normal + preview.elite, 0);
|
||||||
|
assert.ok(preview.blocked);
|
||||||
|
assert.throws(() => applyAction(before, fixture.richese, fixture.plans.reinforcements.action), RuleError);
|
||||||
|
assert.deepEqual(before, fixture.game, 'A refused forecast reserves nothing.');
|
||||||
|
assertConserved(fixture, fixture.game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('A prospective reveal leaving fewer than three reserves refuses the first seal but the real Harass fallback still plays', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 5 });
|
||||||
|
const before = structuredClone(fixture.game);
|
||||||
|
const saved = structuredClone(before);
|
||||||
|
const preview = viewGame(before, fixture.richese).battle!.reinforcements!;
|
||||||
|
assert.ok(preview.blocked);
|
||||||
|
assert.equal(preview.normal + preview.elite, 2);
|
||||||
|
assert.equal(preview.noField!.materializes, 3);
|
||||||
|
assert.equal(preview.noField!.reservesAfterReveal, 2);
|
||||||
|
assert.equal(viewGame(before, fixture.richese).battle!.harassWithdraw!.blocked, null);
|
||||||
|
for (const plan of [fixture.plans.reinforcements, fixture.plans.both]) {
|
||||||
|
assert.throws(() => applyAction(before, fixture.richese, plan.action), RuleError);
|
||||||
|
assert.deepEqual(before, saved, 'A refused plan reserves no card, spice, marker or window.');
|
||||||
|
assert.equal(before.battle!.plans[fixture.richese], undefined);
|
||||||
|
}
|
||||||
|
const harass = sealRicheseReinforcementsPlan(before, fixture.plans.harass);
|
||||||
|
const revealed = sealRicheseReinforcementsPlan(harass, fixture.plans.opponent);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, 3,
|
||||||
|
'The concealed marker still reveals under the legal fallback.');
|
||||||
|
const done = finishRicheseReinforcementsBattle(revealed);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, 3,
|
||||||
|
'The undialed counter returns through the real Harass allocation.');
|
||||||
|
assertNoUtilityCustody(done, RICHESE_HARASS);
|
||||||
|
assert.equal(done.discard.filter(face => face.id === RICHESE_REINFORCEMENTS).length, 0);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
void test('Same-plan Reinforcements and Harass charge the three-reserve fee before the native undialed return', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 3, reserves: 6 });
|
||||||
|
const revealed = sealRicheseReinforcementsBoth(fixture, 'both');
|
||||||
|
assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).reserves, 3);
|
||||||
|
const done = finishRicheseReinforcementsBattle(revealed);
|
||||||
|
const owner = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
assert.equal(owner.reserves, 1, 'Three reserves paid, one undialed counter returned afterwards.');
|
||||||
|
assert.equal(owner.tanks, 5, 'Only the dialed counters and the fee left the owner.');
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_HARASS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
void test('Reinforcements adds two to the dialed battle strength without adding a physical undialed counter', () => {
|
||||||
|
const funded = createRicheseReinforcementsFixture({ marker: 3, reserves: 6, dial: 3, opponentDial: 4 });
|
||||||
|
const withMachine = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(funded, 'reinforcements'));
|
||||||
|
assert.equal(withMachine.lastBattleContext!.winner, funded.richese,
|
||||||
|
'The committed two strength wins the identical physical contest.');
|
||||||
|
assert.equal(richeseReinforcementsPlayer(withMachine, funded.richese).tanks, 6);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(withMachine, funded.richese).forces[funded.location] ?? 0, 0,
|
||||||
|
'No bonus counter ever appears on the board.');
|
||||||
|
const without = createRicheseReinforcementsFixture({ marker: 3, reserves: 6, dial: 3, opponentDial: 4 });
|
||||||
|
const plain = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(without, 'plain'));
|
||||||
|
assert.equal(plain.lastBattleContext!.winner, without.opponent,
|
||||||
|
'The identical physical commitment loses without the machine.');
|
||||||
|
assert.equal(richeseReinforcementsPlayer(plain, without.richese).tanks, 3);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(plain, without.richese).reserves, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const marker of [0, 3, 5] as const)
|
||||||
|
void test(`Advanced native marker ${marker} pays its fee without changing the original physical support`, () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ advanced: true, marker });
|
||||||
|
const before = richeseReinforcementsPlayer(fixture.game, fixture.richese);
|
||||||
|
assert.equal(viewGame(fixture.game, fixture.richese).battle!.reinforcements!.blocked, null);
|
||||||
|
const done = finishRicheseReinforcementsBattle(sealRicheseReinforcementsBoth(fixture, 'reinforcements'));
|
||||||
|
const after = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
assert.equal(after.reserves, 17 - marker, 'The materialized physical counters leave before the three-reserve fee');
|
||||||
|
assert.equal(after.tanks, marker + 3);
|
||||||
|
assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support));
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, options] of [['Basic', { family: 'ix+choam' as const, marker: 3 as const, reserves: 6 }],
|
||||||
|
['Advanced', { family: 'ix+choam' as const, advanced: true, marker: 3 as const }]] as const)
|
||||||
|
void test(`The union Ix and CHOAM selection is admitted and resolves a clean Richese ${label} programme`, () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture(options);
|
||||||
|
assert.equal(viewGame(fixture.game, fixture.richese).battle!.reinforcements!.blocked, null);
|
||||||
|
const revealed = sealRicheseReinforcementsBoth(fixture, 'both');
|
||||||
|
const done = finishRicheseReinforcementsBattle(revealed);
|
||||||
|
assert.equal(done.battle, null);
|
||||||
|
const owner = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
assert.equal(owner.reserves, fixture.advanced ? 16 : 1);
|
||||||
|
assert.equal(owner.tanks, fixture.advanced ? 4 : 5);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_HARASS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('The original physical cache Stone compares undialed counters, ignores two score strength and still charges the fee', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
|
||||||
|
machines: ['reinforcements', 'stone'] });
|
||||||
|
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
|
||||||
|
weapon: RICHESE_STONE_BURNER, defense: RICHESE_REINFORCEMENTS } };
|
||||||
|
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
|
||||||
|
const revealed = applyAction(first, fixture.plans.opponent.actor, fixture.plans.opponent.action);
|
||||||
|
assert.equal(revealed.decision?.kind, 'stoneBurner');
|
||||||
|
const afterChoice = applyAction(revealed, revealed.decision!.player, {
|
||||||
|
type: 'decision', event: revealed.decision!.event, mode: 'ignore',
|
||||||
|
});
|
||||||
|
const done = finishRicheseReinforcementsBattle(afterChoice);
|
||||||
|
const owner = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
assert.equal(done.lastBattleContext!.winner, fixture.richese,
|
||||||
|
'The undialed physical token count decides, not the ordinary strength total.');
|
||||||
|
assert.equal(owner.tanks, 5, 'The three-reserve fee still applies under the Stone comparison.');
|
||||||
|
assert.equal(owner.reserves, 0);
|
||||||
|
assert.equal(owner.forces[fixture.location], 3, 'Only the surviving real counters remain.');
|
||||||
|
assert.equal(done.discard.filter(face => face.id === RICHESE_STONE_BURNER).length, 0,
|
||||||
|
'The winning Stone Burner stays with its owner.');
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('The native Stone kill mode still charges the fee and kills both participating leaders', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
|
||||||
|
machines: ['reinforcements', 'stone'] });
|
||||||
|
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
|
||||||
|
weapon: RICHESE_STONE_BURNER, defense: RICHESE_REINFORCEMENTS } };
|
||||||
|
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
|
||||||
|
const revealed = applyAction(first, fixture.plans.opponent.actor, fixture.plans.opponent.action);
|
||||||
|
assert.equal(revealed.decision?.kind, 'stoneBurner');
|
||||||
|
const afterChoice = applyAction(revealed, revealed.decision!.player, {
|
||||||
|
type: 'decision', event: revealed.decision!.event, mode: 'kill',
|
||||||
|
});
|
||||||
|
const done = finishRicheseReinforcementsBattle(afterChoice);
|
||||||
|
const owner = richeseReinforcementsPlayer(done, fixture.richese);
|
||||||
|
const enemy = richeseReinforcementsPlayer(done, fixture.opponent);
|
||||||
|
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
||||||
|
assert.equal(owner.leaders.find(leader => leader.id === actionLeader(fixture.plans.reinforcements.action))!.dead, true);
|
||||||
|
assert.equal(enemy.leaders.find(leader => leader.id === actionLeader(fixture.plans.opponent.action))!.dead, true);
|
||||||
|
assert.equal(owner.tanks, 5, 'The reserve fee is independent of the chosen Stone mode.');
|
||||||
|
assert.equal(owner.reserves, 0);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('A clean Mirror Weapon copies the real opposing Stone Burner into a native reveal choice while Reinforcements still pays', () => {
|
||||||
|
const fixture = createRicheseReinforcementsFixture({ marker: 5, reserves: 8, dial: 2, opponentDial: 10,
|
||||||
|
machines: ['reinforcements'], opponentStone: true });
|
||||||
|
const plan = { actor: fixture.richese, action: { ...fixture.plans.reinforcements.action,
|
||||||
|
weapon: RICHESE_MIRROR_WEAPON, defense: RICHESE_REINFORCEMENTS } };
|
||||||
|
const opponent = { actor: fixture.opponent,
|
||||||
|
action: { ...fixture.plans.opponent.action, weapon: RICHESE_STONE_BURNER } };
|
||||||
|
const first = prepareRicheseReinforcementsPlan(fixture.game, plan);
|
||||||
|
const revealed = applyAction(first, opponent.actor, opponent.action);
|
||||||
|
assert.equal(revealed.decision?.kind, 'stoneBurner');
|
||||||
|
assert.equal(revealed.decision!.player, fixture.richese,
|
||||||
|
'A copied Stone chooses before the original physical Stone.');
|
||||||
|
const afterChoice = applyAction(revealed, revealed.decision!.player, {
|
||||||
|
type: 'decision', event: revealed.decision!.event, mode: 'ignore',
|
||||||
|
});
|
||||||
|
const done = finishRicheseReinforcementsBattle(afterChoice);
|
||||||
|
assert.equal(done.lastBattleContext!.winner, fixture.richese);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).tanks, 5);
|
||||||
|
assert.equal(richeseReinforcementsPlayer(done, fixture.richese).reserves, 0);
|
||||||
|
assertNoUtilityCustody(done, RICHESE_REINFORCEMENTS);
|
||||||
|
assertConserved(fixture, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +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.',
|
||||||
'\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.' +
|
||||||
|
|||||||
Reference in new issue
Block a user