Connect native Moritani and Harkonnen captive Duke rules
This commit is contained in:
1 parent
559b42dd78
commit
a5a6db7868
40 files changed
+1408
-352
No files matched your search
+23
-1
@@ -1,6 +1,6 @@
|
||||
# Current development status
|
||||
|
||||
Updated 14 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 15 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Native Moritani/Harkonnen custody — 15 October.** Original standalone,
|
||||
paired and mixed-family no-Skills E3 Nexus now admit Advanced Harkonnen in
|
||||
their existing module envelopes. Original capture/Keep/Execute feeds a later
|
||||
qualifying Moritani takeover of living captive Duke, preserving physical
|
||||
identity/history and original one-battle/current-turn release. Karama cancels
|
||||
with the real card. Public qualification uses the public snapshot; allowed
|
||||
resolution exposes actual unavailability without reviving private execution.
|
||||
Takeover/visibility and Terror effective-control/public-victim/spent-empty
|
||||
behavior are explicitly provisional product compositions.
|
||||
An actual Harkonnen win using captive Duke6 opens the distinct native-Harkonnen
|
||||
Traitor choice: Beast Rabban4 dies for four Bank spice, then its original card
|
||||
retires face up and distinct Esmar Tuek arrives privately at Mentat.
|
||||
Affected22-file union195/195, types/lint/build and fourteen actual API/policy
|
||||
paths pass. Actual styled390px GameTable completes movement, shows Duke Ready,
|
||||
uses original Karama/reveal and Terror death6, without overflow/browser errors.
|
||||
The empty-pool boundary has staged preceding Guild deaths; it is not a genuine
|
||||
five-death chronology. Four-profile decisions are legal, not calibrated.
|
||||
Ecaz-only revival, Ambassador exclusions, Skills/Harkonnen/shared-training,
|
||||
unrelated non-Nexus and public-start guards remain. No saved/deployed rooms
|
||||
touched. Existing build deprecation/chunk/route warnings remain.
|
||||
[Canonical lifecycle, source and remaining boundaries](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
|
||||
**Ecaz identity-tracked repeated cohorts — 14 October.** The repeated-history
|
||||
fence is replaced by provisional native-disc accounting: five Tanks including
|
||||
Duke or the existing unavailable-roster exception opens ordinary returns;
|
||||
|
||||
@@ -32,9 +32,9 @@ The [base Harkonnen capture rules, p. 17](https://www.gf9games.com/dunegame/wp-c
|
||||
|
||||
| Combination | Evidence boundary and safe implementation scope |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Moritani acquisition from captured or ghola custody | Ecaz’s Ambassador explicitly excludes both; Moritani’s acquisition excludes only Tanks and says to take Duke from any controller. Do not silently copy the Ecaz exclusions as a confirmed Moritani rule. The interaction with concealed capture and the apparent ghola restriction needs an explicit supported policy. |
|
||||
| Duke used in a lost battle against Harkonnen | Original standalone Ecaz/no-Skills Nexus now retains the eligible surviving disc in the real capture pool through a validated release witness. This capture-before-final-return composition is provisional; see the current follow-up below. |
|
||||
| Returning a captured Duke | Current provisional policy restores an unused valid prior tenure on native early return; used/dead/expired tenure is set aside. Nexus liberation clears captivity rather than restoring the captor later. Broader captured Moritani acquisition remains unresolved. |
|
||||
| Moritani acquisition from captured or ghola custody | The 15 October no-Skills native Nexus follow-up below connects living Harkonnen captivity under an explicit provisional takeover policy. Public qualification uses the public snapshot; actual resolution never revives an executed disc. Foreign-Ghola Duke remains forbidden by Ecaz-only revival. |
|
||||
| Duke used in a lost battle against Harkonnen | Original no-Skills E3 Nexus retains the eligible surviving disc in the real capture pool through a validated release witness. This capture-before-final-return composition is provisional; see the dated follow-ups below. |
|
||||
| Returning a captured Duke | The provisional native return policy restores an unused valid prior tenure on native early return; used/dead/expired tenure is set aside. Nexus liberation and qualifying Moritani acquisition permanently clear captivity, never saving a captor to restore later. |
|
||||
| Loan to the new Ecaz ally | Basic has an optional same-disc first version after accepted Ambassador alliance consent. The authorized supplied Advanced PDF, physical p30 Ecaz A, instead adds an available Duke automatically after consent and sets an unused disc aside at turn end; that path is now connected without another acknowledgement. One-battle release is still provisional. Capture and alliance-break interactions remain for refinement. |
|
||||
| Ecaz co-occupied battles | Ecaz’s Occupy rules distinguish participating forces from the faction chosen to fight. Duke eligibility excludes battles involving Ecaz, but no direct combined example decides every Ecaz-ally designation case. Ordinary faction-pair battle lists alone do not certify the full Occupy interaction. |
|
||||
| Revival alternatives | Ecaz’s Tleilaxu Ambassador effect and sharing an Ambassador benefit may interact with the absolute Ecaz-only restriction. No retrieved specific exception permits another faction to revive Duke. Keep that restriction unless a primary clarification establishes otherwise. |
|
||||
@@ -46,7 +46,7 @@ The [base Harkonnen capture rules, p. 17](https://www.gf9games.com/dunegame/wp-c
|
||||
- `battles(g)` currently calls mutating `settleAdvisors(g)`. Do not use it directly from a pure availability helper or private-view projection. Snapshot after resolved movement/advisor obligations or extract a pure calculation.
|
||||
- End-movement acquisition must happen once before battle preparation, with a persisted Karama response. It must not run per player’s movement completion or again after reload.
|
||||
- Battle settlement, capture selection, Ghola effects, normal revival, foreign-ghola actions, native-leader counts and turn-end cleanup all currently operate on ordinary rosters. Audit each entry point; one new leader record or UI option does not implement the lifecycle.
|
||||
- Keep current exceptional Assassination-pool gates: adding Duke changes a random leader pool and must not silently pass the ordinary five-native-leader validator.
|
||||
- Terror Assassination now uses its explicit provisional effective-control pool, not the old ordinary-five-disc gate; see [the pool contract](MORITANI_ASSASSINATION_SNEAK.md#assassination-pool-adopted-provisional-composition-15-october-2026).
|
||||
|
||||
No source edits to runtime or live-room mutations were performed. Targeted official GF9 and [designer-site](https://futurepastimes.com/dune-ecaz-moritani) searches did not yield written answers resolving the combinations above. Direct publisher download has previously returned 403; indexed crawler dates are not treated as rule revision dates. Parent owns runtime integration and behavioral validation.
|
||||
|
||||
@@ -117,3 +117,39 @@ returns set aside; he neither becomes a sixth native leader nor a foreign
|
||||
Ghola. Actual ordinary/Ghola returns, native controls and four legal profiles
|
||||
are connected. The old repeated-cycle guard above is historical.
|
||||
[Current policy, original native proof and remaining gates](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026).
|
||||
|
||||
## Native Moritani acquisition from captivity — 15 October 2026
|
||||
|
||||
Original standalone, paired and mixed-family **no-Skills E3 Nexus** predicates
|
||||
now admit Advanced Harkonnen, retaining their existing optional-module
|
||||
envelopes. Skills/Harkonnen, shared-Duke training and unrelated non-Nexus
|
||||
guards remain. Ecaz's Ambassador still cannot acquire a captured/dead/Ghola
|
||||
disc; only Ecaz may revive Duke. No foreign-Ghola Duke source is introduced.
|
||||
|
||||
Under the selected **provisional takeover composition**, qualifying native
|
||||
Moritani takes the actual living captive from its current Harkonnen controller.
|
||||
Clear captivity and its private snapshot permanently; preserve death and
|
||||
battle-use history, native Ecaz identity and the original one-battle/current-turn
|
||||
tenure. Use or unused expiry never restores the old captor. This composes the
|
||||
printed take-from-current-controller grant; it is not a retrieved capture-priority
|
||||
or return FAQ. Ecaz Occupy participation/designation remains a separate boundary.
|
||||
|
||||
**Public availability composition:** qualification uses the public shared-disc
|
||||
snapshot, not concealed execution status. A privately executed disc therefore
|
||||
does not remove or identify the public qualifying response window. Original
|
||||
Karama cancellation preserves the capture and private snapshot. If allowed,
|
||||
the public acquisition resolves actual availability: a dead executed Duke stays
|
||||
dead in Ecaz's Tanks, without a Moritani resurrection, and its unavailable state
|
||||
becomes public. This disclosure-at-resolution policy is provisional, distinct
|
||||
from promising permanent secrecy for an execution after a public custody effect.
|
||||
|
||||
Original native three-seat play supplies the real capture lottery, Keep/Execute,
|
||||
two later distinct stronghold battles, original Karama, actual battle use and
|
||||
unused expiry. The actual styled390px table completes movement, shows Duke6
|
||||
Ready in Moritani's leaders, then removes him after original battle use.
|
||||
Another actual Harkonnen win uses captive Duke6 before the existing Moritani
|
||||
private ability kills **native Harkonnen** Beast Rabban4 and replaces its
|
||||
physical card at Mentat. Random Terror can kill the captive Duke for six.
|
||||
See [Assassinate identity](MORITANI_ASSASSINATE_LEADERS.md#native-harkonnen-battle-identity--15-october-2026)
|
||||
and [Terror policy/evidence](MORITANI_ASSASSINATION_SNEAK.md#assassination-pool-adopted-provisional-composition-15-october-2026).
|
||||
This is local changed-path proof, not saved-room, deployed or complete-mode acceptance.
|
||||
@@ -4,6 +4,18 @@ Integrated 7 September 2026. Ecaz can now choose **Acquire Duke Vidal for Ecaz**
|
||||
|
||||
The direct acquisition and tenure follow the publisher’s [Ecaz & Moritani rules, pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Fresh indexed publisher retrieval rechecked eligibility outside Tanks, captured or ghola custody. The prior [Duke source audit](DUKE_VIDAL_RULES.md) and [remaining effects readiness review](ECAZ_REMAINING_EFFECTS_READINESS.md) record the separate unresolved combinations. This implements the direct self-acquisition choice; it does not certify the token’s alliance and loan alternatives, full Ecaz or a complete expansion game.
|
||||
|
||||
## No-Skills native Nexus scope extension — 15 October 2026
|
||||
|
||||
Original standalone, paired and mixed-family E3 Nexus predicates without
|
||||
Skills now admit Advanced Harkonnen, retaining their existing optional-module
|
||||
envelopes. The Ambassador's captured/dead/Ghola exclusions still apply; it does
|
||||
not inherit Moritani's provisional captive takeover or Nexus liberation.
|
||||
Basic optional loan and Advanced automatic new-ally grant keep their original
|
||||
tenure. Skills/Harkonnen, shared training and unrelated non-Nexus guards remain.
|
||||
[Current native custody/source and bounded verification](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
Historical blanket/standalone-only Harkonnen wording below is superseded only
|
||||
in those admitted envelopes; no complete-mode or public-start gate opens.
|
||||
|
||||
## Original standalone Advanced Harkonnen follow-up — 14 October 2026
|
||||
|
||||
The already admitted standalone Ecaz/classic no-Skills Nexus envelope now
|
||||
|
||||
@@ -87,6 +87,18 @@ This supersedes the repeated-cohort guard above and historical algorithm
|
||||
boundary below, not the binding Ecaz-only revival rule, broader profile
|
||||
guards or complete-mode/deployed acceptance.
|
||||
|
||||
## No-Skills native Nexus scope extension — 15 October 2026
|
||||
|
||||
Original standalone, paired and mixed-family E3 Nexus predicates without
|
||||
Skills now admit Advanced Harkonnen, within their existing optional-module
|
||||
envelopes. This supersedes historical standalone-only Harkonnen scope below.
|
||||
**Only Ecaz revives Duke**: paid5 or real Ghola returns the same living
|
||||
set-aside disc under its own original costs/allowances. Moritani takeover never
|
||||
revives a privately executed or publicly dead disc. Foreign-Ghola Duke,
|
||||
shared training, Skills/Harkonnen and unrelated non-Nexus guards remain.
|
||||
Native identity-cohort policy above is unchanged.
|
||||
[Current capture/takeover policy and actual bounded proof](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
|
||||
## Captured return and legal Ghola composition — 14 October 2026
|
||||
|
||||
The original standalone Ecaz/classic no-Skills Nexus envelope now admits
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 15 October 2026 — Native Moritani/Harkonnen and actual captive Duke
|
||||
|
||||
Connected original standalone, paired and mixed-family no-Skills E3 Nexus
|
||||
Advanced Harkonnen, retaining existing optional-module envelopes. Native
|
||||
capture/Keep/Execute and actual later two-stronghold qualification supply
|
||||
Moritani's same-disc captive takeover. It clears capture permanently,
|
||||
preserves native identity/death/use and original one-battle/current-turn
|
||||
tenure; no captor restoration. Original Karama can cancel with the real card.
|
||||
Public qualification uses the public snapshot, not hidden execution status.
|
||||
Allowed resolution publishes actual unavailability without Moritani revival.
|
||||
These custody/visibility choices are labelled provisional compositions.
|
||||
|
||||
Advanced Moritani now binds the actual surviving pre-release battle reference:
|
||||
Harkonnen can win with its native, captured foreign/Moritani or shared Duke
|
||||
disc while the eligible assassination cards stay native Harkonnen identities.
|
||||
Game-long normal-call forfeiture, printed/dead bounty, once-per-faction use
|
||||
and original distinct physical Mentat replacement remain. Last-native Harkonnen
|
||||
death uses original captive cleanup. Non-Harkonnen borrowed/shared winners,
|
||||
exceptional targets and private-step UX remain separate.
|
||||
|
||||
Random Terror uses a focused canonical living effective-control pool:
|
||||
native/captive/ordinary foreign Ghola/shared Duke/optional Advanced Auditor
|
||||
once, with capture precedence and no battle-use filter. No pool/count is
|
||||
projected or used for offer availability. Actual victim becomes public,
|
||||
printed bounty remains Zoal3/Duke6, original death/custody/skills cleanup
|
||||
resumes arrival. Empty revelation spends with no RNG/victim/bounty. These are
|
||||
provisional product policies, not a publisher exceptional-pool FAQ.
|
||||
|
||||
Removed obsolete Harkonnen rejection and hidden-pool availability tests,
|
||||
not re-pinned. The affected run exposed a mixed-family fixture probing plans
|
||||
before original preparation finished; it now consumes the actual preparation
|
||||
sequence rather than cloning a ready-looking posture or weakening legality.
|
||||
The captured-disc physical census now includes original face-up retired
|
||||
Moritani Traitors, matching the existing canonical zone.
|
||||
|
||||
Verification: affected22-file union **195/195**, types/lint and production build
|
||||
pass; actual prototype CLI help passes. Fourteen original API/policy paths
|
||||
cover takeover/use/expiry, private execution/no resurrection, original
|
||||
Karama/captive-Duke winner/native Harkonnen assassination/Mentat replacement,
|
||||
Terror Duke death6, controlled empty boundary and all four legal response/
|
||||
Terror policies. Easy reveals; other Terror profiles form the accepted
|
||||
alliance, and all Harkonnen counter profiles pass. No strategy claim.
|
||||
|
||||
Actual styled390px production GameTable completes original final movement,
|
||||
shows Duke6 Ready then removes him after original battle use; cancels with
|
||||
original Karama, reveals actual Beast Rabban4 for four Bank spice, displays
|
||||
original private Esmar Tuek replacement and reveals original Terror against
|
||||
captive Duke for six. Actual executed Duke stays dead2 after qualification.
|
||||
No overflow/browser errors. The QA host scopes the original Terror permutation,
|
||||
not the effect; the client sends no target. Empty-pool preceding Guild deaths
|
||||
are explicitly staged, while its actual arrival/token/continuation are original.
|
||||
|
||||
Ecaz-only revival, Ambassador captured/dead/Ghola exclusions, native five-disc
|
||||
inventory, Skills/Harkonnen/shared-training, unrelated non-Nexus and public
|
||||
gates remain. No saved/deployed rooms touched. Existing module.register
|
||||
deprecation, large-chunk and Unknown-route warnings remain. Full combinations,
|
||||
assurance, administration, calibration and final acceptance remain unfinished.
|
||||
[Canonical source/policy](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
|
||||
## 14 October 2026 — Ecaz identity-tracked repeated native cohorts
|
||||
|
||||
Replaced the blanket repeated-six-disc guard with explicit provisional native
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Moritani: Advanced Assassinate Leaders preview
|
||||
|
||||
14 September 2026. **Prototyped / Partial**, in an explicitly opted-in local
|
||||
preview. Normal game-start and publication gates remain closed. This is the
|
||||
battle-loss ability, separate from the random Assassination Terror token.
|
||||
Updated 15 October 2026. **Prototyped / Partial**. Original native Advanced
|
||||
Moritani play is connected through the supported local entry profiles, including
|
||||
the bounded no-Skills E3 Nexus composition below. Normal public game-start and
|
||||
publication gates remain closed. This is the battle-loss ability, separate from
|
||||
the random Assassination Terror token.
|
||||
|
||||
## Authority and Advanced duration cutover
|
||||
|
||||
@@ -40,16 +42,19 @@ the independent private-step UX/public-preview gates remain unchanged. See
|
||||
|
||||
The captured-own-leader answer preserves original faction identity: a Moritani
|
||||
leader used by Harkonnen does not become a Harkonnen-faction assassination card.
|
||||
It does not define every other captured or foreign-ghola target interaction.
|
||||
Thus a victorious Harkonnen using that captive can permit a reveal of a
|
||||
different held **printed Harkonnen** Traitor Card, not the captured Moritani's
|
||||
card. The played disc remains Moritani in the battle record. This clarification
|
||||
does not define every other captured or foreign-ghola **target** interaction.
|
||||
|
||||
## Connected scope and private information
|
||||
|
||||
The preview supports native Moritani with Atreides, Bene Gesserit, Emperor,
|
||||
Fremen and Guild opponents, Advanced rules and the Ecaz faction expansion, without
|
||||
optional modules. Harkonnen presence is excluded uniformly from public configuration
|
||||
so candidate availability cannot reveal secret captures. Other expansion factions,
|
||||
Nexus, Stronghold Cards, Homeworlds, Discoveries and other faction combinations
|
||||
retain their separate integration boundaries. The
|
||||
The original preview supports native Moritani with Atreides, Bene Gesserit,
|
||||
Emperor, Fremen and Guild opponents, Advanced rules and the Ecaz faction
|
||||
expansion, without optional modules. The later original E3 Nexus compositions
|
||||
add Ecaz and, in the bounded no-Skills composition below, Harkonnen. Other
|
||||
expansion factions, Skills with Harkonnen, Homeworlds, Discoveries and other
|
||||
combinations retain their separate integration boundaries. The
|
||||
[bounded all14 native skill composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
|
||||
now extends this same non-Harkonnen roster without opening public starts.
|
||||
Winner Suk rescue and Rihani choice finish first; the unspent assassination
|
||||
@@ -65,8 +70,9 @@ The classic-only trigger/receipt list was a software scope limit, not
|
||||
a printed prohibition. Two real original native Ecaz battle losses reject
|
||||
before, then reveal a different actual held Ecaz card, kill its original
|
||||
disc for printed bounty and replace it once at Mentat after the shared list
|
||||
gains Ecaz. Winning-disc exclusion, game-long normal-call forfeiture,
|
||||
shared/captured/Ghola and Advanced Harkonnen guards remain. A foreign
|
||||
gains Ecaz. Winning-disc exclusion and game-long normal-call forfeiture
|
||||
remain; shared/captured/Ghola and Advanced Harkonnen were still guarded at
|
||||
that checkpoint (the no-Skills Harkonnen follow-up is below). A foreign
|
||||
Homeworld's normal Traitor prohibition does not prohibit this distinct
|
||||
native ability. The no-Skills receipt retains its earlier pause before
|
||||
winner casualties; all14 retains its skill-first pause afterward.
|
||||
@@ -78,8 +84,9 @@ and blocked reason; rivals see the same decision and no private options. This
|
||||
requires Continue even for an ineligible hand, conflicting with the user's usual
|
||||
automatic-action preference. Moritani's uniform-private-step approval remains
|
||||
separate: the 11 October Mentat approval applies only to that card-show flow.
|
||||
This ability still requires its explicit development preview; no ordinary
|
||||
action or room API enables it.
|
||||
The bounded native Nexus composition uses its original entry and original
|
||||
postbattle/assassination continuation, not a separate opt-in assassination shim;
|
||||
ordinary public starts and room API mode gates remain closed.
|
||||
A publicly spent faction or prior normal traitor call can be skipped without consulting hidden eligibility.
|
||||
|
||||
All four AI profiles use the same owner-only quote. They choose a legal card or
|
||||
@@ -93,6 +100,60 @@ it. When no distinct card remains, the replacement is blocked with an explicit
|
||||
reason instead of committing an invalid receipt. (7 October 2026)
|
||||
Dead-target inspection retains the printed leader strength despite its zero bounty.
|
||||
|
||||
## Native Harkonnen battle identity — 15 October 2026
|
||||
|
||||
The bounded **Advanced Moritani/Harkonnen E3 Nexus composition without Leader
|
||||
Skills**, including the paired Ecaz/Moritani roster, admits an original normal
|
||||
Moritani loss to a victorious Harkonnen. The winning surviving battle disc may
|
||||
be one of Harkonnen's own five native discs, an actual captured foreign native
|
||||
disc (including Moritani), or the separately controlled/captured Duke Prad
|
||||
Vidal. The engine must bind that exact disc to the actual Harkonnen battle
|
||||
plan, effective control and survival before creating the opportunity; a helper
|
||||
identity check alone is not evidence of captivity or a completed battle.
|
||||
|
||||
The supplied Advanced p35 / logical p33 D conditions still govern: normal
|
||||
loss, surviving opposing disc, no Traitor revealed in the battle, no earlier
|
||||
use against that printed opposing faction, and no prior normal own-card reveal
|
||||
this game. The held card must be an ordinary **Harkonnen-faction** Traitor
|
||||
identity, different from the actual played disc. A native Harkonnen played
|
||||
disc therefore excludes its own card. A captured Atreides or Moritani disc
|
||||
does not exclude or become any Harkonnen card. Duke stays the separate Ecaz
|
||||
strength-6 disc with no Traitor Card; Cheap Hero cannot open this ability.
|
||||
|
||||
The original five native Harkonnen targets pay their printed strength if
|
||||
living, or zero with no second death if already in the Tanks. Use is still
|
||||
once per printed opposing faction, with the revealed physical card held until
|
||||
the original automatic Mentat face-up retirement and one distinct private
|
||||
replacement. The normal-call forfeiture is game-long; decline spends no use.
|
||||
Karama behavior, private uniform opportunity, casualty cleanup and replacement
|
||||
ordering remain the original ability, not new Harkonnen-specific mechanics.
|
||||
|
||||
`game/moritani-assassinate.ts` admits the distinct native/captive/Duke battle
|
||||
identity while retaining version1 state, receipt shape and the original
|
||||
signature. Its target pool remains the opposing faction's native identities,
|
||||
not the winner's battle-use pool. Existing missing/altered, captured, concealed,
|
||||
foreign-Ghola or foreign-controlled **target** guards remain unchanged;
|
||||
admitting Harkonnen's captured **battle disc** is not a general relaxation
|
||||
of those policies or a ruling on other profiles.
|
||||
|
||||
`tests/moritani-harkonnen-assassinate.test.ts` supplies pure trigger/receipt/
|
||||
choice regressions for native, captured foreign/Moritani and Duke identities,
|
||||
native-card exclusion, wrong-faction and Cheap Hero rejection, dead bounty,
|
||||
once-use, normal-call forfeiture and physical replacement.
|
||||
The frozen affected22-file union passes **195/195**, with types/lint/build.
|
||||
Original three-seat native play produces Harkonnen's actual capture, original
|
||||
Karama cancellation and a real Harkonnen win using captive Duke6. The engine
|
||||
binds the original surviving battle reference before its custody release;
|
||||
Moritani then reveals its actual held **Beast Rabban4**, receives four Bank
|
||||
spice and kills that native disc. Original Mentat retires the card face up and
|
||||
draws distinct **Esmar Tuek** privately. Actual styled390px GameTable performs
|
||||
the cancellation/reveal and displays the replacement, without overflow or
|
||||
browser errors. Four-profile response smoke is legal, not calibrated strategy.
|
||||
The separate random Terror pool is not derived from this captured-own FAQ.
|
||||
Non-Harkonnen foreign/shared winning discs, exceptional targets, Skills with
|
||||
Harkonnen, private-step UX and complete-mode/deployed acceptance remain open.
|
||||
|
||||
|
||||
## Save and physical custody
|
||||
|
||||
The resolved battle binds the opportunity's event, opposing faction and surviving
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Terror Assassination and Sneak Attack: source boundary
|
||||
|
||||
Reviewed 2026-09-06. Bounded follow-up to `MORITANI_TERROR_RULES.md` and `MORITANI_ENTRY_TIMING.md`. The research phase made no runtime edits; this note distinguishes printed instructions from compositional interpretations.
|
||||
Reviewed 15 October 2026. Bounded follow-up to `MORITANI_TERROR_RULES.md` and `MORITANI_ENTRY_TIMING.md`. This note distinguishes printed instructions from explicitly provisional product composition; the original research phase made no runtime edits.
|
||||
|
||||
## Direct Terror instructions
|
||||
|
||||
@@ -53,30 +53,92 @@ the once-per-turn guard, the unchanged zero-force resolution, the altered-sector
|
||||
rejection, the post-entry Intrusion offer and the full decline → advisor →
|
||||
placement chain; `docs/COMPONENT_INVENTORY.md` records the reduced boundary.
|
||||
|
||||
## Assassination pool: safe ordinary support
|
||||
## Assassination pool: adopted provisional composition, 15 October 2026
|
||||
|
||||
Ordinary live native discs held by the entrant are the straightforward pool. Killing a disc already in the Tanks again, or awarding its strength again, is not supported. Do not award Moritani a player-chosen victim. Zoal's explicit payout must survive any generic strength calculation.
|
||||
The selected product policy is **effective current control**, not native-array
|
||||
membership and not battle availability. It includes every actual living native
|
||||
disc still controlled by the entrant, its actual captured foreign discs and
|
||||
ordinary foreign Gholas, and the one canonical shared Duke when controlled by
|
||||
the entrant. A native disc captured or held as a foreign Ghola elsewhere is not
|
||||
in its native faction's pool. Harkonnen capture overrides a foreign Ghola's
|
||||
underlying Tleilaxu control. Dead discs never enter the pool. An ordinary native
|
||||
leader revived normally remains ordinary; its death history alone is not
|
||||
foreign-Ghola custody.
|
||||
|
||||
Battle availability and physical custody are different facts. The base Harkonnen capture power expressly excludes leaders previously used elsewhere that turn; that is a restriction on **capture**, not a generic definition of all random-leader effects. Importing `captureCandidates` wholesale would add a condition absent from Terror. Ordinary leader rules restrict repeated battle use, not all other effects on that disc. [Base rulebook, pp. 10–11, 17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
This exceptional-pool definition is **provisional product composition**, not a
|
||||
retrieved publisher ruling or a numeric decision supplied by the user. The
|
||||
printed Terror instruction supplies no exceptional-custody definition. The
|
||||
separate p. 14 **Assassinate Leaders** FAQ does not settle this random Terror
|
||||
pool, its visibility, or its bounty.
|
||||
|
||||
No targeted primary clarification was found for these pools:
|
||||
Battle availability and physical custody remain different facts. Harkonnen's
|
||||
restriction on capturing leaders previously used elsewhere that turn is a
|
||||
restriction on **capture**, not on Terror. A living `usedAt` disc remains
|
||||
eligible; importing `captureCandidates` would silently change probabilities.
|
||||
[Base rulebook, pp. 10–11, 17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
|
||||
- An entrant's native leader held by another faction after capture.
|
||||
- Captured foreign leaders controlled by the entrant.
|
||||
- Foreign Gholas or native leaders currently controlled as another faction's Gholas.
|
||||
- Duke Vidal under his separate custody lifecycle.
|
||||
- An empty eligible pool when all relevant leaders are dead or unavailable.
|
||||
The focused server quote in `game/moritani-terror-assassination.ts` validates
|
||||
original native identity, name and strength, complete five-disc native
|
||||
inventories and unique physical custody. It preserves the original rosters:
|
||||
foreign discs stay in their native arrays, and Duke remains a separate Ecaz
|
||||
strength-six disc with no Traitor. The actual optional Advanced CHOAM Auditor
|
||||
is a physical sixth disc and is eligible while living; its capture and
|
||||
foreign-Ghola prohibitions do not grant immunity to random Terror.
|
||||
Foreign-Ghola Duke is forbidden by binding Ecaz-only revival, not treated as a
|
||||
new source. The quote reuses `controlsLeader` and server `dukeController`,
|
||||
returns original disc references in a fresh array, and performs no RNG,
|
||||
mutation or public projection.
|
||||
|
||||
For a limited implementation, reject an unresolved exceptional pool **before** revealing or consuming RNG, while retaining the decline option. Silently filtering special discs out changes selection probabilities and effectively grants immunity; it is not equivalent to leaving their support unimplemented. The check must consider actual ownership/control across the game, not merely the entrant's native leader array. An ordinary native leader revived normally remains an ordinary living disc; a death history alone is not foreign-Ghola custody.
|
||||
**Public victim policy:** actual Terror assassination publicly identifies the
|
||||
killed disc, including a previously concealed captive, and clears that
|
||||
capture's concealment. This is an explicitly provisional public-effect
|
||||
interpretation. It does not change ordinary Harkonnen private execution or
|
||||
turn unrelated hidden captures into public information. Pay the killed disc's
|
||||
native printed bounty, explicitly **Zoal 3** and **Duke 6**, never a copied
|
||||
battle strength or skill bonus. Kill once, remove the original dead leader's
|
||||
skill if applicable, clear battle-use history, and use original death/custody
|
||||
cleanup: a captured ordinary disc returns to its native or underlying Ghola
|
||||
custody, while the dead shared Duke is set aside in Ecaz's Tanks rather than
|
||||
restoring his former controller.
|
||||
|
||||
**Empty-pool policy:** revelation spends/removes the token even when the
|
||||
effective-control pool is empty; there is no victim, no bounty and **no random
|
||||
draw**. Continue the original committed arrival once. This is provisional
|
||||
composition, not a located empty-pool FAQ. Neither the offer nor public/private
|
||||
availability may depend on held or concealed leader state: do not project the
|
||||
pool, expose its size, or restore the former exceptional-custody/empty-pool
|
||||
availability gates. Structural physical-identity validation is not a player
|
||||
choice or a hidden-state-dependent offer.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
Read the relevant printed pp. 5–6 and 14–16 in `/tmp/dune-rules/ecaz-audit.txt`, the corresponding publisher-indexed expansion passages, base rules, and the Heighliners passage in `/tmp/dune-rules/ix-official-mirror.txt`. Targeted GF9/Future Pastimes searches used Sneak Attack with shipment/advisors/Intrusion, and Assassination with captured/Ghola/Duke. The [designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani) yielded no additional written pool or entry-classification clarification. No community answer or rule for a different Dune game was substituted. Lack of a retrieved ruling is a documented research limit, not proof that none exists.
|
||||
|
||||
## Integrated runtime checkpoint
|
||||
## Implementation and evidence boundary
|
||||
|
||||
The parent integrated ordinary Assassination and bounded Sneak Attack with the above unresolved cases gated. The native catalog, uniqueness and custody checks run before revelation/RNG. Advanced Harkonnen games use a uniform configuration guard to avoid leaking hidden capture state. Empty living pools retain the hidden token and permit decline. Used living discs remain eligible; Zoal pays three.
|
||||
The former ordinary-only checkpoint gated exceptional custody and empty pools;
|
||||
that policy is superseded by the provisional composition above. Pure behavior
|
||||
regressions cover effective native/captive/Ghola custody, capture precedence,
|
||||
canonical Duke/Auditor participation, used living discs, dead/empty pools,
|
||||
printed values and rejected altered/duplicate physical identities.
|
||||
The frozen affected22-file union passes **195/195**, with types/lint/build.
|
||||
Original native play supplies a real Harkonnen-captured Duke and a later real
|
||||
paid shipment into the placed token. Actual styled390px GameTable reveals the
|
||||
original token; a scoped original random permutation selects captive Duke,
|
||||
kills him once, pays six, removes the token and resumes the original arrival.
|
||||
It does not fake an effect or put a target in the client action. No overflow or
|
||||
browser errors were observed. The empty-pool engine/API boundary spends the
|
||||
token without bounty/RNG and resumes a genuine Guild arrival; its preceding
|
||||
five Guild deaths are **staged**, not an original five-death play chronology.
|
||||
All four original policies make accepted reveal or alliance decisions; only
|
||||
Easy reveals in that position, while Medium/Hard/Brutal form the accepted
|
||||
alliance. This is minimal legal participation, not four Duke kills or strategic
|
||||
calibration. No saved/deployed rooms or complete-mode acceptance are covered.
|
||||
|
||||
Sneak Attack's owner projection supplies reserve maximum and any blocking reason at the offer and resolution stages. Thus the player can preserve the hidden token before an unsupported positive entry; deliberate zero-force revelation remains possible. AI declines blocked or empty offers. No ordinary action counter or spice payment is consumed. BG followup/Intrusion and phase-five technology combinations remain unsupported pending interpretation/integration; no blanket bypass was inferred.
|
||||
|
||||
Validation: fifteen focused force/leader tests; integrated 858-unit and 45-persisted/API suites; typecheck, lint and build. Expansion starts remain gated.
|
||||
Sneak Attack's owner projection supplies reserve maximum and any blocking
|
||||
reason at the offer and resolution stages. Thus the player can preserve the
|
||||
hidden token before an unsupported positive entry; deliberate zero-force
|
||||
revelation remains possible. No ordinary action counter or spice payment is
|
||||
consumed. The adopted Heighliners and BG shipment compositions are recorded
|
||||
above; older blanket unsupported claims are not current policy. Expansion
|
||||
starts and full-combination acceptance remain gated.
|
||||
@@ -35,7 +35,7 @@ The following paraphrases preserve the mechanically necessary conditions from th
|
||||
|
||||
| Token | Resolution and continuing state |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Assassination | Randomly select an entrant's leader, send it to the Tanks, and award Moritani spice for its value; Zoal pays 3. This is not a player-selected victim. The exact exceptional custody pool is not specified here; see gaps. |
|
||||
| Assassination | Randomly select an entrant's living effectively controlled disc, send it to the Tanks and award its native printed value; Zoal pays3 and Duke6. Exceptional custody, public victim and empty-pool behavior follow the explicit provisional composition below, not a publisher clarification. |
|
||||
| Atomics | Send **all** forces in the territory to the Tanks. Place Aftermath there, permanently prohibiting shipment into that territory, expressly including Fremen. Starting this turn, Moritani's hand limit and its ally's are each reduced by 1; an excessive hand loses a random card. The passage does not prohibit ordinary movement into the territory. |
|
||||
| Extortion | Set aside 5 bank spice in front of Moritani's shield, to collect during Mentat Pause. Then return Extortion to supply unless one player, approached in storm order, pays Moritani 3 spice. A payment prevents recovery; it does not replace or cancel the bank's 5. Treat the deferred 5 separately from currently spendable spice. |
|
||||
| Robbery | Moritani chooses either half the entrant's spice, rounded up, or the top Treachery card. For the card option, draw first; if over the hand limit, Moritani chooses a card to discard. The printed option is not restricted to a non-full hand. |
|
||||
@@ -77,7 +77,7 @@ These are software integration recommendations, not newly asserted publisher tim
|
||||
| Question | Status and implementation consequence |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Terror vs. Intrusion, spiritual advisors, ambassadors, Guild income or storm casualties from the same arrival | No complete priority table retrieved. Entry creates the opportunity, but the exact ordering of simultaneous reactions remains material. Do not claim the Ecaz-only interruption FAQ settles every Terror interaction. |
|
||||
| Assassination victim pool | Ordinary live owned leaders are the straightforward case. The short paragraph does not resolve captured leaders, foreign Gholas, Duke Vidal custody or unavailable/already-dead discs. Do not silently pool every printed leader identity. |
|
||||
| Assassination victim pool | The 15 October provisional composition includes actual living effectively controlled native/captive/Ghola/shared Duke and optional Advanced Auditor discs once, irrespective of battle-use stamp. Actual victim becomes public; an empty pool spends the token without RNG/bounty and resumes entry. [Canonical policy, source limits and original native proof](MORITANI_ASSASSINATION_SNEAK.md#assassination-pool-adopted-provisional-composition-15-october-2026). |
|
||||
| Atomics and future allies | The retrieved passage does not identify future-ally treatment. Under the choose-recommended-option priority, the prototype now keeps the permanent penalty on Moritani and the ally at detonation, allowing later Nexus and Enemy of My Enemy alliances without transferring it to an uninvolved new ally. Original target history is retained. This is a provisional implementation interpretation, not publisher clarification. |
|
||||
| Extortion and other Mentat events | Collection precedes recovery/payment by the printed sequence. Relative order against the turn's placement opportunity and other factions' Mentat effects is not explicit. Returning Extortion and immediately placing it again must not emerge accidentally from handler order. |
|
||||
| Sabotage recipient hand overflow | The normal case frees a slot before the gift. If there was no discardable card or another exceptional capacity change, this paragraph supplies no additional forced-discard procedure. |
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
|
||||
|
||||
## Native Moritani/Harkonnen custody — 15 October 2026
|
||||
|
||||
Original standalone, paired and mixed-family **no-Skills E3 Nexus** profiles
|
||||
now admit Advanced Harkonnen within their existing optional-module envelopes.
|
||||
This supersedes the earlier no-Skills Moritani/paired Harkonnen exclusions,
|
||||
not Skills/Harkonnen, shared-Duke training, unrelated non-Nexus profiles or
|
||||
public starts. Ecaz's existing available-Duke Ambassador/loan and Ecaz-only
|
||||
revival predicates use the same admitted no-Skills envelopes; their actual
|
||||
captured/dead/Ghola exclusions and independent costs remain.
|
||||
|
||||
Native Moritani's original qualifying end-movement grant can permanently take
|
||||
a living captive Duke. Original Karama can cancel it. Public qualification
|
||||
uses the public snapshot; allowed resolution reveals actual unavailability
|
||||
without reviving an executed disc. These are labelled provisional custody/
|
||||
visibility compositions, not a new publisher ruling.
|
||||
[Canonical lifecycle](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
An actual Harkonnen win with captive Duke6 can open the distinct Advanced
|
||||
native-Harkonnen Traitor choice; random Terror separately uses living effective
|
||||
control, explicit public victim and spent-empty policies.
|
||||
[Original identity/control proof](MORITANI_ASSASSINATE_LEADERS.md#native-harkonnen-battle-identity--15-october-2026).
|
||||
Affected22-file union195/195, types/lint/build and actual390px controls pass.
|
||||
Broader combinations, private-step UX, assurance and deployed acceptance remain open.
|
||||
|
||||
## Original quiet-phase Ecaz Cunning — 14 October 2026
|
||||
|
||||
The artificial Battle-only restriction is removed: printed Cunning lasts
|
||||
|
||||
@@ -82,6 +82,20 @@ historical save authenticity. The bounded Duke Cunning path below is separate;
|
||||
Secret Ally's exceptional leader scope and audience remain unsettled beyond
|
||||
the bounded runtime below.
|
||||
|
||||
## No-Skills native Harkonnen envelope extension — 15 October 2026
|
||||
|
||||
The original standalone, paired and mixed-family E3 Nexus predicates without
|
||||
Skills now admit Advanced Harkonnen and retain their existing optional-module
|
||||
envelopes. This supersedes historical standalone-only/no-paired-Harkonnen scope
|
||||
below, not the existing Skills/Harkonnen or shared-training guards.
|
||||
Available-Duke Ambassador acquisition/new-ally grant, Ecaz-only revival and
|
||||
quiet-phase Cunning retain their distinct original exclusions, costs and tenure.
|
||||
Only Cunning and qualifying native Moritani can liberate a living captive;
|
||||
Ecaz's Ambassador still cannot acquire one, and foreign-Ghola Duke remains forbidden.
|
||||
[Native Moritani takeover, explicit provisional visibility and actual proof](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
The affected22-file union195/195, types/lint/build and real390px table pass;
|
||||
this is not a new full-combination or deployed/save-assurance claim.
|
||||
|
||||
## Original quiet-phase Cunning timing — 14 October 2026
|
||||
|
||||
Printed Cunning says to take Duke **for this turn**, without a Battle-only
|
||||
|
||||
+32
-1
@@ -1,6 +1,37 @@
|
||||
# Rule decision index
|
||||
|
||||
Updated 14 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
Updated 15 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
|
||||
## Recommended product compositions — 15 October 2026
|
||||
|
||||
Under the user's choose-recommended-option instruction, the following are
|
||||
**provisional product policies**, not retrieved publisher FAQs or new
|
||||
user-supplied numerical rulings:
|
||||
|
||||
- **Native Moritani takes captive Duke:** a qualifying original end-movement
|
||||
acquisition takes the actual living disc from its current Harkonnen captor,
|
||||
permanently clears captivity and preserves death/use history. Original
|
||||
one-battle/current-turn release never restores that captor.
|
||||
- **Public qualification / private execution:** qualify from the public Duke
|
||||
snapshot. Original Karama cancellation preserves private custody. If allowed,
|
||||
resolve actual availability publicly: an executed disc stays dead in Ecaz's
|
||||
Tanks, never revived by Moritani. The public qualifying window does not vary
|
||||
with concealed execution; permanent execution secrecy after the public effect
|
||||
is not promised.
|
||||
[Takeover, scope and proof](DUKE_VIDAL_RULES.md#native-moritani-acquisition-from-captivity--15-october-2026).
|
||||
- **Terror random pool:** actual living effective control includes native,
|
||||
captive, ordinary foreign Ghola, canonical shared Duke and optional Advanced
|
||||
Auditor once. Battle-use stamps do not exclude living discs. The killed disc
|
||||
becomes public; printed bounty is Zoal3/Duke6. Empty revelation spends the
|
||||
token with no RNG/victim/bounty and resumes original entry. Neither pool nor
|
||||
size is projected or used to gate a private offer.
|
||||
[Separate Terror contract](MORITANI_ASSASSINATION_SNEAK.md#assassination-pool-adopted-provisional-composition-15-october-2026).
|
||||
|
||||
The distinct Advanced ability now recognizes an actual victorious Harkonnen's
|
||||
native/captive/shared battle identity while still targeting printed Harkonnen
|
||||
Traitor identities, never reclassifying a captured Moritani or Duke as native Harkonnen.
|
||||
Only original no-Skills E3 Nexus envelopes gain this Harkonnen composition;
|
||||
Skills/shared-training, unrelated guards and public/deployment gates remain.
|
||||
|
||||
## User decisions — 14 October 2026
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): b
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
let native: 'ecaz' | 'moritani' | null = null, harkonnen = false;
|
||||
let native: 'ecaz' | 'moritani' | null = null;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
|
||||
@@ -152,9 +152,8 @@ export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): b
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
|
||||
return native !== null;
|
||||
}
|
||||
|
||||
/** Exactly one original E1/E2 native/classics with Nexus/Discovery and no Skills. */
|
||||
@@ -209,7 +208,7 @@ export function mixedE1E2DiscoveryNexusProfile(game: FactionModuleProfile): bool
|
||||
return ix && choam;
|
||||
}
|
||||
|
||||
/** Both original E3 natives with Nexus/Discovery and no Skills; Advanced capture exclusion remains. */
|
||||
/** Both original E3 natives with Nexus/Discovery and no Skills. */
|
||||
export function pairedE3DiscoveryNexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
!game.nexusCards || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' ||
|
||||
@@ -227,7 +226,6 @@ export function pairedE3DiscoveryNexusProfile(game: FactionModuleProfile): boole
|
||||
if (player.faction === 'ecaz') ecaz = true;
|
||||
else if (player.faction === 'moritani') moritani = true;
|
||||
else {
|
||||
if (game.advanced && player.faction === 'harkonnen') return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
|
||||
+4
-4
@@ -197,7 +197,7 @@ export function executeCapturedDuke(
|
||||
/**
|
||||
* The caller authorizes the faction power and resolves any applicable Karama.
|
||||
* Ecaz Nexus Cunning independently revives the set-aside shared disc from Tanks.
|
||||
* Ecaz Nexus Cunning liberates an actual captive; no foreign-Ghola source is legal.
|
||||
* Moritani and Ecaz Nexus Cunning liberate a living captive; no foreign-Ghola source is legal.
|
||||
*/
|
||||
export function acquireDuke(
|
||||
state: DukeState,
|
||||
@@ -217,8 +217,8 @@ export function acquireDuke(
|
||||
if (state.leader.gholaBy)
|
||||
throw new Error('Duke Vidal cannot be acquired from foreign Ghola custody.');
|
||||
if (state.leader.capturedBy) {
|
||||
if (source !== 'ecazNexus' || state.leader.dead)
|
||||
throw new Error('Only Ecaz Nexus Cunning can acquire the living captured Duke.');
|
||||
if ((source !== 'ecazNexus' && source !== 'moritani') || state.leader.dead)
|
||||
throw new Error('Only Moritani acquisition or Ecaz Nexus Cunning can take the living captured Duke.');
|
||||
validCaptive(state, turn);
|
||||
} else if ((state.leader.concealed || state.captureVisibility) &&
|
||||
!(source === 'ecazNexus' && returnedDukeCapture(state))) {
|
||||
@@ -230,7 +230,7 @@ export function acquireDuke(
|
||||
if (source === 'ecazNexus' && !state.leader.dead && state.leader.usedAt)
|
||||
throw new Error('Duke Vidal has already been used in battle this turn.');
|
||||
const acquired = cloneDuke(state);
|
||||
if (source === 'ecazNexus') {
|
||||
if (source === 'ecazNexus' || source === 'moritani') {
|
||||
delete acquired.leader.capturedBy;
|
||||
delete acquired.leader.concealed;
|
||||
delete acquired.captureVisibility;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Game } from './engine';
|
||||
import type { FactionId } from './catalog';
|
||||
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
import { standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
|
||||
export type EcazDukeContext = Partial<FactionModuleProfile> & {
|
||||
status: Game['status'];
|
||||
@@ -57,7 +57,8 @@ export function quoteEcazDukeAcquisition(
|
||||
);
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
|
||||
(!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
||||
tableBlock,
|
||||
'table',
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
|
||||
import type { Leader } from './cards';
|
||||
import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
import { standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
|
||||
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
|
||||
export type EcazDukeRevivalContext = Partial<FactionModuleProfile> & {
|
||||
@@ -85,7 +85,8 @@ export function resolveEcazDukeRevival(
|
||||
);
|
||||
requireDuke(
|
||||
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
||||
(!!g.expansions && standaloneE3NexusProfile(g as FactionModuleProfile)),
|
||||
(!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
||||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
||||
'table',
|
||||
);
|
||||
const duke = g.dukeVidal;
|
||||
|
||||
+54
-71
@@ -52,6 +52,7 @@ import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningR
|
||||
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||
import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } from './moritani-terror-assassination';
|
||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
|
||||
@@ -464,6 +465,7 @@ import {
|
||||
captureDuke,
|
||||
releaseCapturedDuke,
|
||||
executeCapturedDuke,
|
||||
returnedDukeCapture,
|
||||
type DukeReleasedCaptureWitness,
|
||||
DUKE_VIDAL_ID,
|
||||
type DukeState,
|
||||
@@ -8303,11 +8305,12 @@ function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateRec
|
||||
if (g.lastBattleContext?.event === receipt.event)
|
||||
g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature};
|
||||
}
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) {
|
||||
function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null,
|
||||
opposingLeader: Leader | undefined) {
|
||||
const state = g.moritaniAssassinate;
|
||||
if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner ||
|
||||
state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return;
|
||||
const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader);
|
||||
if (opposingLeader?.id !== b.plans[winner.id].leader) return;
|
||||
const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id,
|
||||
faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory,
|
||||
opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead,
|
||||
@@ -8348,7 +8351,13 @@ function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'morit
|
||||
requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.');
|
||||
const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card)));
|
||||
const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!;
|
||||
if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;}
|
||||
if (quote.kill) {
|
||||
target.dead = true;
|
||||
target.deaths++;
|
||||
delete target.usedAt;
|
||||
if (getPlayer(g, r.opponent).faction === 'harkonnen') returnCaptives(g, getPlayer(g, r.opponent));
|
||||
finishLeaderSkillCustody(g);
|
||||
}
|
||||
const owner = getPlayer(g,r.owner);
|
||||
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
||||
owner.spice += quote.bounty;
|
||||
@@ -17465,8 +17474,9 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
|
||||
}
|
||||
}
|
||||
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
|
||||
if (g.advanced && byFaction(g, 'harkonnen') && !standaloneE3NexusProfile(g))
|
||||
return 'Advanced Duke loans with Harkonnen capture remain guarded.';
|
||||
if (g.advanced && byFaction(g, 'harkonnen') &&
|
||||
!(standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
|
||||
return 'Advanced Duke loans with Harkonnen capture remain guarded in this profile.';
|
||||
if (owner.ally !== entrant.id || entrant.ally !== owner.id)
|
||||
return 'The new Ecaz alliance is no longer current.';
|
||||
const duke = g.dukeVidal;
|
||||
@@ -18021,7 +18031,7 @@ function continueTerrorDiscard(
|
||||
};
|
||||
} else finishTerrorEntry(g);
|
||||
}
|
||||
/** Keep unsupported custody out of the random draw, without leaking concealed captures. */
|
||||
/** Public effect admission only; the private living-disc pool is quoted at reveal. */
|
||||
function terrorRevealBlocked(
|
||||
g: Game,
|
||||
entry: NonNullable<Game['pendingTerrorEntry']>,
|
||||
@@ -18040,39 +18050,6 @@ function terrorRevealBlocked(
|
||||
return null;
|
||||
if (kind !== 'assassination')
|
||||
return 'This Terror effect is still being implemented.';
|
||||
if (g.advanced && byFaction(g, 'harkonnen'))
|
||||
return 'Assassination with advanced Harkonnen capture is still being implemented.';
|
||||
const entrant = getPlayer(g, entry.entrant);
|
||||
if (g.dukeVidal?.controller === entrant.id)
|
||||
return 'Assassination with Duke Vidal in the leader pool is still being implemented.';
|
||||
const catalog = leaders(entrant.faction);
|
||||
if (
|
||||
catalog.length !== 5 ||
|
||||
entrant.leaders.length !== catalog.length ||
|
||||
new Set(entrant.leaders.map((leader) => leader.id)).size !==
|
||||
catalog.length ||
|
||||
entrant.leaders.some(
|
||||
(leader) =>
|
||||
!catalog.some(
|
||||
(native) =>
|
||||
native.id === leader.id &&
|
||||
native.faction === leader.faction &&
|
||||
native.strength === leader.strength,
|
||||
),
|
||||
)
|
||||
)
|
||||
return 'Assassination requires a verified ordinary leader pool; this pool is still being implemented.';
|
||||
if (
|
||||
entrant.leaders.some((leader) => leader.capturedBy || leader.gholaBy) ||
|
||||
g.players.some(
|
||||
(other) =>
|
||||
other.id !== entrant.id &&
|
||||
other.leaders.some((leader) => controlsLeader(entrant, leader)),
|
||||
)
|
||||
)
|
||||
return 'Assassination with captured or foreign ghola leaders is still being implemented.';
|
||||
if (!entrant.leaders.some((leader) => !leader.dead))
|
||||
return 'Assassination with an empty living leader pool needs a verified ruling.';
|
||||
return null;
|
||||
}
|
||||
function sneakAttackOptions(
|
||||
@@ -18229,6 +18206,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
'Reveal the Terror token or leave it hidden.',
|
||||
);
|
||||
const atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null;
|
||||
const assassination = token.kind === 'assassination'
|
||||
? nexusRule(() => moritaniTerrorAssassinationPool(g, entrant.id)) : null;
|
||||
const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind);
|
||||
requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
|
||||
g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
|
||||
@@ -18241,17 +18220,23 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
entry.stage = 'robbery';
|
||||
reopen();
|
||||
} else if (token.kind === 'assassination') {
|
||||
const victim = shuffle(
|
||||
entrant.leaders.filter((leader) => !leader.dead),
|
||||
)[0];
|
||||
victim.dead = true;
|
||||
victim.deaths++;
|
||||
delete victim.usedAt;
|
||||
p.spice += victim.strength;
|
||||
log(
|
||||
g,
|
||||
`${victim.name} was assassinated; ${p.name} received ${victim.strength} spice.`,
|
||||
);
|
||||
const victim = assassination!.length ? shuffle(assassination!)[0] : null;
|
||||
if (victim) {
|
||||
const bounty = moritaniTerrorAssassinationValue(victim);
|
||||
victim.dead = true;
|
||||
victim.deaths++;
|
||||
delete victim.usedAt;
|
||||
delete victim.capturedBy;
|
||||
delete victim.concealed;
|
||||
if (victim.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!);
|
||||
const harkonnen = byFaction(g, 'harkonnen');
|
||||
if (harkonnen) returnCaptives(g, harkonnen);
|
||||
finishLeaderSkillCustody(g);
|
||||
p.spice += bounty;
|
||||
log(g, `${victim.name} was assassinated; ${p.name} received ${bounty} spice.`);
|
||||
} else {
|
||||
log(g, `${p.name} revealed Assassination, but the entrant controlled no living leader. No leader died and no spice was awarded.`);
|
||||
}
|
||||
finishTerrorEntry(g);
|
||||
} else if (token.kind === 'sneakAttack') {
|
||||
entry.stage = 'sneakAttack';
|
||||
@@ -18507,14 +18492,13 @@ function offerMoritaniDuke(g: Game) {
|
||||
g.dukeAcquisitionTurn = g.turn;
|
||||
g.dukeVidal ??= createDukeVidal();
|
||||
const duke = g.dukeVidal;
|
||||
if (duke.leader.dead || duke.controller === owner.id) return false;
|
||||
if (duke.leader.capturedBy || duke.leader.gholaBy) {
|
||||
log(
|
||||
g,
|
||||
'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const captor = duke.leader.capturedBy ?? duke.leader.concealed?.captor;
|
||||
requireRule(!captor || (g.advanced && byFaction(g, 'harkonnen')?.id === captor),
|
||||
'Duke acquisition lost its original native Harkonnen capture source.');
|
||||
const publicDuke = projectedSharedDuke(g, '');
|
||||
if (!publicDuke || publicDuke.leader.dead ||
|
||||
(publicDuke.controller === owner.id && publicDuke.acquiredTurn === g.turn) ||
|
||||
duke.leader.gholaBy) return false;
|
||||
const strongholds = new Set(
|
||||
battles(g)
|
||||
.filter(
|
||||
@@ -20643,7 +20627,7 @@ function leaderHiddenFrom(g: Game, leader: Leader, viewer: string): boolean {
|
||||
if (!concealed) return false;
|
||||
const owner = g.players.find(player => player.faction === leader.faction);
|
||||
return ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) &&
|
||||
!(getPlayer(g, viewer).faction === 'tleilaxu' && leader.dead && !leader.capturedBy);
|
||||
!(g.players.find(player => player.id === viewer)?.faction === 'tleilaxu' && leader.dead && !leader.capturedBy);
|
||||
}
|
||||
function projectLeader(g: Game, l: Leader, viewer: string): Leader {
|
||||
const { concealed, capturedBy, ...visible } = l;
|
||||
@@ -23123,7 +23107,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
...(inspection ? { nexusChoamInspection: inspection } : {}),
|
||||
...(nexusRetention ? { nexusMoritaniRetention: nexusRetention } : {}),
|
||||
};
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
|
||||
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer, winner === a ? al : dl);
|
||||
if (quote.smuggler) {
|
||||
const { receipt, amount } = quote.smuggler;
|
||||
const collector = getPlayer(g, receipt.player);
|
||||
@@ -25073,17 +25057,16 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal,
|
||||
'This Duke Vidal acquisition is no longer current.',
|
||||
);
|
||||
if (!canceled) {
|
||||
g.dukeVidal = acquireDuke(
|
||||
g.dukeVidal,
|
||||
response.owner,
|
||||
g.turn,
|
||||
'moritani',
|
||||
);
|
||||
log(
|
||||
g,
|
||||
`${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`,
|
||||
);
|
||||
if (!canceled && !g.dukeVidal.leader.dead) {
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal, response.owner, g.turn, 'moritani');
|
||||
log(g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`);
|
||||
} else if (!canceled) {
|
||||
// A qualified public acquisition resolves the concealed disc's availability;
|
||||
// never revive a privately executed Duke or leak it through a private-only gate.
|
||||
requireRule(returnedDukeCapture(g.dukeVidal),
|
||||
'The unavailable acquisition lost the original executed Duke return.');
|
||||
g.dukeVidal = consumeDuke(g.dukeVidal);
|
||||
log(g, `${getPlayer(g, response.owner).name}'s Duke acquisition resolved with the shared disc in the Tanks. No acquisition occurred.`);
|
||||
} else
|
||||
log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.');
|
||||
nextPhase(g);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function mixedE3NexusRosterProfile(game: MixedE3NativeRoster, skills = fa
|
||||
if (expansion !== 'ecaz' && expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
for (let j = 0; j < i; j++) if (expansion === game.expansions[j]) return false;
|
||||
}
|
||||
let ix = false, choam = false, e3 = false, moritani = false, harkonnen = false;
|
||||
let ix = false, choam = false, e3 = false, harkonnen = false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
const player = players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === players[j].faction) return false;
|
||||
@@ -96,9 +96,8 @@ export function mixedE3NexusRosterProfile(game: MixedE3NativeRoster, skills = fa
|
||||
if (family === 'ix') ix = true;
|
||||
if (family === 'choam') choam = true;
|
||||
if (family === 'ecaz') e3 = true;
|
||||
if (player.faction === 'moritani') moritani = true;
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return e3 && ix === game.expansions.includes('ix') && choam === game.expansions.includes('choam') &&
|
||||
!(game.advanced && harkonnen && (skills || moritani));
|
||||
!(game.advanced && harkonnen && skills);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { leaders as printedLeaders, type Leader } from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
|
||||
export type MoritaniAssassinateReceipt = {
|
||||
event: string;
|
||||
@@ -63,6 +64,7 @@ const opponents: readonly FactionId[] = [
|
||||
'beneGesserit',
|
||||
'guild',
|
||||
'emperor',
|
||||
'harkonnen',
|
||||
'fremen',
|
||||
'ecaz',
|
||||
'ixians',
|
||||
@@ -95,6 +97,13 @@ function printed(faction: FactionId, id: string) {
|
||||
function ordinaryIdentity(id: string) {
|
||||
return FACTIONS.some((f) => printed(f.id, id));
|
||||
}
|
||||
/** Harkonnen may fight with a captive without changing its printed identity.
|
||||
* Actual control and survival are bound by the engine, not by this receipt. */
|
||||
function opposingDiscIdentity(faction: FactionId, id: string) {
|
||||
return !!printed(faction, id) ||
|
||||
(faction === 'harkonnen' &&
|
||||
(id === DUKE_VIDAL_ID || ordinaryIdentity(id)));
|
||||
}
|
||||
/** Consistency proof only; the engine separately binds the battle event and
|
||||
* phase continuation. No secret card identity enters this public trigger. */
|
||||
export function moritaniAssassinateTrigger(
|
||||
@@ -113,7 +122,7 @@ export function moritaniAssassinateTrigger(
|
||||
input.turn > 0 &&
|
||||
opponents.includes(input.faction) &&
|
||||
identifier(input.opposingLeader) &&
|
||||
!!printed(input.faction, input.opposingLeader) &&
|
||||
opposingDiscIdentity(input.faction, input.opposingLeader) &&
|
||||
input.opposingLeaderSurvived === true &&
|
||||
input.normalBattle === true &&
|
||||
input.lostBattle === true &&
|
||||
@@ -215,7 +224,7 @@ export function validateMoritaniAssassinate(
|
||||
(!custody || r.turn <= custody.turn) &&
|
||||
(index === 0 || r.turn >= state.opportunities[index - 1].turn) &&
|
||||
identifier(r.opposingLeader) &&
|
||||
!!printed(r.faction, r.opposingLeader) &&
|
||||
opposingDiscIdentity(r.faction, r.opposingLeader) &&
|
||||
['skills', 'choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
|
||||
r.signature === moritaniAssassinateSignature(r),
|
||||
'Assassinate has a changed, duplicated or malformed battle opportunity.',
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
CHOAM_AUDITOR_ID,
|
||||
createAuditorLeader,
|
||||
leaders,
|
||||
type Leader,
|
||||
} from './cards';
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import {
|
||||
createDukeVidal,
|
||||
DUKE_VIDAL_ID,
|
||||
dukeController,
|
||||
type DukeState,
|
||||
} from './duke-vidal';
|
||||
import { controlsLeader } from './leader-control';
|
||||
|
||||
export type MoritaniTerrorAssassinationContext = Readonly<{
|
||||
players: readonly Readonly<{
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
leaders: readonly Leader[];
|
||||
}>[];
|
||||
advanced?: boolean;
|
||||
dukeVidal?: Readonly<DukeState>;
|
||||
}>;
|
||||
|
||||
// Identity metadata only: candidates always come from the actual server state.
|
||||
const printed: Readonly<Record<string, Leader | undefined>> = Object.fromEntries(
|
||||
[
|
||||
...FACTIONS.flatMap((faction) => leaders(faction.id)),
|
||||
createAuditorLeader(),
|
||||
createDukeVidal().leader,
|
||||
].map((leader) => [leader.id, leader] as const),
|
||||
);
|
||||
const nativeIds: Readonly<Partial<Record<FactionId, readonly string[]>>> = Object.fromEntries(
|
||||
FACTIONS.map((faction) => [
|
||||
faction.id,
|
||||
leaders(faction.id).map((leader) => leader.id),
|
||||
] as const),
|
||||
);
|
||||
function requirePool(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function physical(leader: Leader): Leader {
|
||||
const original = printed[leader.id];
|
||||
requirePool(
|
||||
original && leader.name === original.name &&
|
||||
leader.faction === original.faction && leader.strength === original.strength &&
|
||||
typeof leader.dead === 'boolean' &&
|
||||
Number.isSafeInteger(leader.deaths) && leader.deaths >= 0 &&
|
||||
(leader.usedAt === undefined ||
|
||||
(typeof leader.usedAt === 'string' && !!leader.usedAt.trim())),
|
||||
'Terror Assassination requires an unaltered physical leader identity and history.',
|
||||
);
|
||||
return original;
|
||||
}
|
||||
|
||||
/** Server-only provisional effective-control pool; never project or gate an offer on it.
|
||||
* Battle use does not remove a living disc. The caller alone draws, reveals and kills. */
|
||||
export function moritaniTerrorAssassinationPool(
|
||||
g: MoritaniTerrorAssassinationContext,
|
||||
entrantId: string,
|
||||
): Leader[] {
|
||||
const seats = new Map(g.players.map((player) => [player.id, player]));
|
||||
requirePool(
|
||||
seats.size === g.players.length &&
|
||||
g.players.every((player) => typeof player.id === 'string' && !!player.id.trim()) &&
|
||||
new Set(g.players.map((player) => player.faction)).size === g.players.length,
|
||||
'Terror Assassination requires unique native player custody.',
|
||||
);
|
||||
const entrant = seats.get(entrantId);
|
||||
requirePool(entrant, 'Terror Assassination requires the actual entrant.');
|
||||
const seen = new Set<string>();
|
||||
const pool: Leader[] = [];
|
||||
function custody(leader: Leader, owner: string | null) {
|
||||
physical(leader);
|
||||
requirePool(!seen.has(leader.id), 'Terror Assassination found a duplicated physical leader.');
|
||||
seen.add(leader.id);
|
||||
if (leader.gholaBy !== undefined)
|
||||
requirePool(
|
||||
g.advanced === true && leader.id !== DUKE_VIDAL_ID &&
|
||||
leader.id !== CHOAM_AUDITOR_ID && leader.gholaBy !== owner &&
|
||||
seats.get(leader.gholaBy)?.faction === 'tleilaxu',
|
||||
'Terror Assassination requires original foreign-Ghola custody; Duke revival is Ecaz-only.',
|
||||
);
|
||||
if (leader.capturedBy !== undefined)
|
||||
requirePool(
|
||||
g.advanced === true && leader.id !== CHOAM_AUDITOR_ID &&
|
||||
leader.capturedBy !== (leader.gholaBy ?? owner) &&
|
||||
seats.get(leader.capturedBy)?.faction === 'harkonnen',
|
||||
'Terror Assassination requires the actual native Harkonnen captor.',
|
||||
);
|
||||
}
|
||||
for (const player of g.players) {
|
||||
const ordinary = nativeIds[player.faction];
|
||||
const hasAuditor = player.leaders.some((leader) => leader.id === CHOAM_AUDITOR_ID);
|
||||
requirePool(
|
||||
ordinary?.length === 5 &&
|
||||
player.leaders.length === 5 + (hasAuditor ? 1 : 0) &&
|
||||
ordinary.every((id) => player.leaders.some((leader) => leader.id === id)) &&
|
||||
(!hasAuditor || (g.advanced === true && player.faction === 'choam')),
|
||||
'Terror Assassination requires the original five native discs and only the actual optional Auditor.',
|
||||
);
|
||||
for (const leader of player.leaders) {
|
||||
requirePool(
|
||||
leader.id !== DUKE_VIDAL_ID && leader.faction === player.faction &&
|
||||
(leader.controller === undefined || leader.controller === player.id),
|
||||
'Terror Assassination requires each disc in its original native roster.',
|
||||
);
|
||||
custody(leader, player.id);
|
||||
if (!leader.dead && controlsLeader(entrant, leader)) pool.push(leader);
|
||||
}
|
||||
}
|
||||
if (g.dukeVidal) {
|
||||
const duke = g.dukeVidal;
|
||||
requirePool(
|
||||
duke.leader.id === DUKE_VIDAL_ID &&
|
||||
(duke.leader.controller === undefined || duke.leader.controller === duke.controller) &&
|
||||
(duke.controller === null
|
||||
? duke.acquiredTurn === null && duke.source === null
|
||||
: seats.has(duke.controller) && Number.isSafeInteger(duke.acquiredTurn) &&
|
||||
duke.acquiredTurn! > 0 &&
|
||||
['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(duke.source!)),
|
||||
'Terror Assassination requires the canonical separate shared Duke custody.',
|
||||
);
|
||||
custody(duke.leader, duke.controller);
|
||||
if (!duke.leader.dead && dukeController(duke) === entrantId) pool.push(duke.leader);
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** Printed bounty, never Zoal's copied battle strength or a battle-skill bonus. */
|
||||
export function moritaniTerrorAssassinationValue(leader: Leader): number {
|
||||
const original = physical(leader);
|
||||
if (original.id === 'tleilaxu-0') return 3;
|
||||
if (original.id === DUKE_VIDAL_ID) return 6;
|
||||
return original.strength;
|
||||
}
|
||||
@@ -122,10 +122,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
|
||||
const eventCustody = masked && duke?.captureVisibility ? duke.captureVisibility : duke;
|
||||
const roster = g.players.map(player => player.faction);
|
||||
let blocked: string | null = null;
|
||||
if (g.advanced && g.players.some(player => player.faction === 'harkonnen') &&
|
||||
!standaloneE3NexusProfile(g))
|
||||
blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen in this profile.';
|
||||
else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
|
||||
if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
|
||||
blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.';
|
||||
else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.';
|
||||
else if (g.status !== 'playing')
|
||||
@@ -162,8 +159,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
|
||||
export function nexusEcazDukeModeSupported(g: Game): boolean {
|
||||
if ((mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
|
||||
g.players.some(player => player.faction === 'ecaz') &&
|
||||
!g.sandtrout && (!(g.advanced && g.players.some(player => player.faction === 'harkonnen')) ||
|
||||
standaloneE3NexusProfile(g)))
|
||||
!g.sandtrout)
|
||||
return !!g.nexusCards?.cards && g.players.length >= 3;
|
||||
if (g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) &&
|
||||
g.players.some(player => player.faction === 'ecaz') && !g.sandtrout)
|
||||
|
||||
@@ -78,7 +78,7 @@ export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
|
||||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
||||
let native: 'ecaz' | 'moritani' | null = null, harkonnen = false;
|
||||
let native: 'ecaz' | 'moritani' | null = null;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
|
||||
@@ -91,9 +91,8 @@ export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
|
||||
return native !== null;
|
||||
}
|
||||
|
||||
/** Exactly one original E1/E2 native/classics without Skills, with optional original modules. */
|
||||
@@ -170,7 +169,6 @@ export function pairedE3NexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (player.faction === 'ecaz') ecaz = true;
|
||||
else if (player.faction === 'moritani') moritani = true;
|
||||
else {
|
||||
if (game.advanced && player.faction === 'harkonnen') return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
|
||||
+24
-17
@@ -2921,15 +2921,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
|
||||
'Ordinary Ecaz returns now use an explicit provisional native cohort. Five actual Tanked discs including Duke, or the existing unavailable-native-roster exception, opens it; completed native IDs preserve the remainder and later openings. A late first death can join a later cohort. Independent Duke deaths do not set or hold its ordinary number, and a real Duke resurrection preserves an already satisfied opening. Original native prices and one ordinary leader slot remain.',
|
||||
'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
|
||||
'Standalone Ecaz/classic no-Skills Nexus connects Advanced Harkonnen capture of the same shared disc, including a surviving just-used losing Duke, original Keep/Execute and captive returns. Capture priority and restoring only valid unused prior tenure are provisional compositions. Five native discs, no Duke Traitor, strength six and physical death history remain.',
|
||||
'Development support also includes Moritani acquisition, Ecaz self-acquisition, consensual alliances, Basic optional loan, Advanced automatic new-ally grant, one-battle use and Ecaz-only revival to living set-aside custody. Loan release is provisional. Native ordinary-disc skills coexist without assigning Duke a skill. Broader captured acquisition, shared training, paired Advanced Harkonnen and wider interactions remain for refinement; complete modes are not certified.',
|
||||
'Original standalone, paired and mixed-family no-Skills E3 Nexus connects Advanced Harkonnen capture of the same shared disc, surviving just-used losing Duke, Keep/Execute and captive returns. Qualifying Moritani can take a living captive permanently, preserving native identity and death/use history; one battle or unused expiry never restores the captor. Takeover, capture priority and return destinations are provisional compositions.',
|
||||
'Public Moritani qualification uses the public Duke snapshot, not secret execution status. Karama preserves existing capture if it cancels the grant. Allowed resolution publishes actual unavailability: an executed Duke stays dead in Ecaz Tanks, never revived by Moritani. This provisional public-effect policy does not promise permanent execution secrecy. Ecaz-only revival, Ambassador exclusions, Skills/Harkonnen and shared-training guards remain; complete modes are not certified.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Explicit native Ecaz cohort tracks completed disc identities instead of freezing repeat history or equating independent Duke deaths. Paid, Ghola, Ambassador/foreign returns and first-execution exclusion connect; Duke normal/Ghola/Nexus resurrection preserves opening. Native five-disc identity, Ecaz-only set-aside return, prices, slots and existing guards remain. Wider interactions/assurance are open.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/nexus-tleilaxu-revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original native Revival selector/buttons show remaining and second-cohort candidates with visible provisional policy. Actual styled390px Ilesa11 to8 leaves the repeat dead; Bindikk12 to10 preserves Whitmore alive/deaths0 and Duke Tanked/deaths1. One ordinary slot and original histories remain; no overflow/browser errors. Full journeys remain open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles in both original cohort positions use actual Cunning, ordinary force revival and a paid native return. Independent Duke resurrection retains open cohort permission. No hidden rival data or native duplication is used; strategic calibration remains deferred.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts', 'tests/ecaz-duke-bots.test.ts', 'tests/normal-revival-bots.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed five-Tanks/own unavailable-roster opening and Ecaz-only Duke5/set-aside are separate from the provisional identity-cohort interpretation. Pending requests do not consume new history; independent card costs/allowances remain distinct. No publisher six-disc example or new numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Affected19-file union230/230 and types/lint pass. Two original native death/return chronologies plus all four profiles in each and actual390px11 to8/12 to10 prove unreturned and second-cohort use. Actual Ghola completes a cohort inside the original ten-turn game; bribes, not wallet staging, fund proof. Late first-death boundary and local Tleilaxu waiver regressions remain. No saved/deployed rooms touched; broad assurance/full acceptance remain open.', evidence: ['tests/ecaz-revival-cohort.test.ts', 'tests/fixture-ecaz-revival-cohort.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/tleilaxu-ambassador-engine.test.ts', 'tests/foreign-gholas.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Native Ecaz identity cohorts and independent Duke return remain connected. Original no-Skills E3 Nexus admits Harkonnen; qualifying Moritani takes living captive Duke without restoring the captor after use/expiry. Public snapshot qualification resolves actual execution availability without resurrection. Native five-disc identity, Ecaz-only revival, costs and Skills/shared-training guards remain.', evidence: ['game/ecaz-revival-cohort.ts', 'game/revival.ts', 'game/ecaz-duke-revival.ts', 'game/duke-vidal.ts', 'game/nexus-ecaz-duke.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Actual styled390px table finishes original movement, shows reclaimed Duke6 Ready and removes him after actual battle use. Original Karama cancels takeover; a real captive-Duke winner opens native Harkonnen Traitor reveal4 and Mentat replacement. Earlier original cohort11 to8/12 to10 controls remain. No overflow/browser errors; full journeys open.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx', 'components/moritani-assassinate.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four original Harkonnen response policies legally pass the native acquisition window; Terror policies legally reveal or form an accepted alliance. Earlier four-profile cohort returns remain. These are minimal legal paths, not optimal counterplay, four Duke kills or strategic calibration.', evidence: ['game/bots.ts', 'game/nexus-ecaz-duke-options.ts', 'game/revival.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed native identity, take-from-controller and Ecaz-only Duke5/set-aside remain distinct from provisional takeover/return, public availability and identity-cohort compositions. Moritani never revives execution; Ambassador captive exclusion remains. No missing publisher priority FAQ or numerical user ruling is claimed.', evidence: ['docs/ECAZ_REVIVAL_RULES.md', 'docs/DUKE_VIDAL_RULES.md', 'docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Current affected22-file union195/195, types/lint/build and14 actual API/policy paths pass. Real native capture, takeover/use/expiry, private execution, original Karama, captive-Duke winning identity and Terror death6 have actual390px proof. Empty Terror boundary stages preceding Guild deaths. Earlier cohort19-file230/230 proof remains historical. No saved/deployed rooms touched; combinations, assurance and full acceptance remain open.', evidence: ['tests/moritani-duke-custody.test.ts', 'tests/fixture-moritani-duke-capture.ts', 'tests/moritani-duke-captured-runtime.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/ecaz-revival-cohort-runtime.test.ts', 'tests/ecaz-duke-revival.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'faction-ecaz',
|
||||
@@ -2975,8 +2975,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.',
|
||||
steps: [
|
||||
'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
|
||||
'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.',
|
||||
'Robbery, Sabotage, Sneak Attack, effective-control Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.',
|
||||
'Assassination randomly kills one actual living effectively controlled disc: native, captive, ordinary foreign Ghola, shared Duke or optional Advanced Auditor, once each. Native discs controlled elsewhere are excluded; living used discs remain eligible. The provisional public-victim policy reveals the actual killed disc and pays native printed value, Zoal3 or Duke6. Empty revelation spends the token without victim, bounty or RNG and resumes arrival. No pool/size is projected or used to gate availability; Moritani cannot choose a victim. These exceptional/empty policies are product compositions, not a located publisher FAQ.',
|
||||
'Atomics sends every faction’s forces in the territory to the Tanks and places public Aftermath. Supported revelation remains bounded to classic-plus-Moritani without Guild, Ecaz or optional modules. Later alliances now retain the original affected hand-limit targets under the explicit provisional interpretation in the Aftermath topic.',
|
||||
'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.',
|
||||
'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.',
|
||||
@@ -2985,6 +2985,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves. A positive entry during Shipment and Movement is treated as an off-planet shipment for the Heighliners token, which accrues its once-per-turn income for its owner, and the committed entry finishes before Bene Gesserit may answer it with an Intrusion choice or a free spiritual-advisor shipment.',
|
||||
'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Focused canonical effective-control pool reuses original custody; original token resolution kills/releases actual discs, pays printed bounty and resumes committed arrival. Empty pool spends without RNG; malformed physical identities reject before mutation. Other Terror effects retain their own scope.', evidence: ['game/moritani-terror-assassination.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Original actual390px Reveal Assassination resolves a captive Duke death6, displays Tanks inspection and resumes native Harkonnen movement. No selectable victim or projected pool; no overflow/browser errors.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four policies make accepted original decisions: Easy reveals, Medium/Hard/Brutal form an accepted Enemy of My Enemy alliance in the actual native position. Legal participation only; no calibrated strategy claim.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Effective-control exceptional pool, public victim and spent-empty behavior are labelled provisional and separate from Advanced battle-loss Assassinate Leaders. Native printed values and no foreign-Ghola Duke remain binding.', evidence: ['docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/MORITANI_TERROR_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Affected22-file195/195 and types/lint/build pass. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'moritani-terror',
|
||||
'moritani-aftermath',
|
||||
@@ -3137,15 +3144,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
|
||||
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
|
||||
'If Harkonnen has captured your own leader and you hold its Traitor Card, you may call that traitor normally; you may not use Assassinate Leaders on that basis.',
|
||||
'A victorious Harkonnen may have played its own native disc, a captured foreign native disc including Moritani, or shared Duke. In the original no-Skills E3 Nexus composition, a surviving actual Harkonnen battle disc opens the original private ability, but eligible cards remain printed Harkonnen identities. Captivity never changes a Moritani disc or Ecaz Duke into native Harkonnen; Duke has no Traitor Card. Non-Harkonnen borrowed/shared winners and exceptional targets remain separate.',
|
||||
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Moritani starts remain unavailable in Basic and Advanced.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Explicit Advanced Moritani/base-opponent preview before any normal traitor call; native disc deaths, printed bounty, dead-target zero bounty and exact Mentat replacement.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Uniform private choice/decline, named target and bounty, public face-up markers and readable inspection; normal activation awaits private-step UX decision.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles choose from the owner-only quote or decline; no hidden rival data or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Printed effect and authorized Advanced game-long normal-reveal forfeiture are explicit; exceptional leader custody, private-step UX and optional-module boundaries remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Rules, controls, genuine-setup staged battles, private views, authenticated recovery, concurrent writes and physical replacement chains; full faction acceptance remains open.',evidence:['tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-assassinate-recovery.test.ts']},
|
||||
{area:'Implementation',status:'Partial',detail:'Original native Advanced loss uses surviving pre-release battle identity, including Harkonnen captive/Duke winners in no-Skills E3 Nexus. Targets remain native opposing printed-faction Traitors; game-long normal-call forfeiture, printed/dead bounty and distinct physical Mentat replacement remain.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||
{area:'Player controls',status:'Partial',detail:'Actual390px original Karama cancellation preserves capture; after real Harkonnen Duke6 victory, actual Reveal Beast Rabban pays4 and original Mentat shows distinct private Esmar Tuek. Uniform private choice/decline remains; private-step UX decision is separate.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
|
||||
{area:'AI',status:'Partial',detail:'All four profiles use the owner-only choice or decline; original four-profile Harkonnen response smoke is legal pass. No optimal strategy or strength-calibration claim.',evidence:['game/bots.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:'Authorized Advanced game-long forfeiture, printed battle-disc identity versus target faction and distinct random Terror policy are explicit. Exceptional targets, non-Harkonnen borrowed/shared winners, private-step UX and Skills/Harkonnen remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
|
||||
{area:'Verification',status:'Partial',detail:'Affected22-file195/195, types/lint/build and original native capture/cancel/Duke winner/reveal4/Mentat replacement plus actual390px controls pass. Pure native/foreign/Moritani/Duke identity and dead/once-use boundaries remain. No saved/deployed or complete-faction acceptance claim.',evidence:['tests/moritani-harkonnen-assassinate.test.ts','tests/moritani-duke-captured-runtime.test.ts','tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-engine.test.ts']},
|
||||
],
|
||||
related: [
|
||||
'faction-moritani',
|
||||
@@ -4569,13 +4576,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
|
||||
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
|
||||
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
|
||||
'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes are classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Advanced paired E3/Moritani excludes Harkonnen; shared Duke never trains. Public starts, played-save conversion, mixed E3/three-family and pending effects remain separate.',
|
||||
'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes include classics/base33, selected E1/E2 natives/union47 and standalone, paired or mixed E3 native families. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Original no-Skills E3 Nexus now admits Advanced Harkonnen; Skills retains its Harkonnen exclusion and shared Duke never trains. Public starts, played-save conversion and pending effects remain separate.',
|
||||
'Fresh nexus --discoveries uses the original no-Skills envelopes above with optional Homeworlds, Tech3+ and Advanced Strongholds. Seven Discovery Spice Cards/eight tokens, protected nested sites, next-turn physical entry and actual Maker losses/votes/reserve ride remain. The card draw follows both Advanced piles and actual settled alliance change; earlier Shai-Hulud losses never repeat. Source-clear quotas, borrowed Guild tariffs and signed native one-invoice pairs at revealed sites use original controls, not new transport adapters.',
|
||||
'Without Skills, the existing generic Nexus initializer handles single and mixed selected E1/E2 natives, while paired one-family and paired E3 entries use their original paired initializer and require BOTH original natives. Native HMS/Face Dancers/Auditor/Richese cache, Ecaz ordinary discs/Loyalty/shared Duke and Advanced Moritani assassination remain physical. New overlays do not create a first Nexus hand, free native return, arbitrary marker companion or played-game conversion.',
|
||||
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.',
|
||||
'The original all14 skill/Nexus envelope admits single or paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47, and standalone OR paired E3/ecaz33 with classics, Basic/Advanced2–6, optional Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds. Full Suboid support composes winning typed fixed-loss Suk before equal actual Cyborg Tank substitution. Held subsidy changes debit, not rescue entitlement. Original cleanup/winner Tech precedes Face Dance; the replaced trainer dies/returns its skill once without another bounty, while earlier held rewards stay with the original winner. Original Auditor exclusion, signed Richese invoices, CHOAM fuel, native cache and pending-effect guards remain.',
|
||||
'Standalone OR paired Ecaz/Moritani original skill/Nexus entries use ecaz33/all14/all12 and classics, Basic/Advanced2–6, optional Homeworlds/Discovery/Tech3+/Advanced Strongholds. Each original roster/capture guard remains, including Advanced Harkonnen exclusion and no shared-Duke training. Quiet living/unclaimed Duke Cunning and native Advanced assassination use original distinct discs and exact continuation. In paired tables, native Ecaz may be a different opposing-faction assassination target; normal foreign-world Traitor prohibition does not become an assassination ban. Exceptional Duke custody, mixed E3/three-family, enhanced Terror/HMS/Grumman and pending effects remain separate.',
|
||||
'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. No-Skills Ecaz facing Advanced Harkonnen now connects shared-Duke acquisition, original capture/return and Cunning liberation without shared-disc training. Advanced Moritani still excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.',
|
||||
'Original standalone, paired and mixed-family no-Skills E3 Nexus admits Advanced Harkonnen within its existing optional-module envelope. Native capture/Keep/Execute, qualifying Moritani captive takeover, original Karama, one-battle/turn release and Ecaz Cunning use one separate Duke. Takeover/public availability resolution is provisional; dead execution never becomes Moritani revival. Actual Harkonnen captive-Duke victory opens native Harkonnen Traitor assassination, not an Ecaz/Moritani target. Terror separately uses provisional living effective control/public-victim/spent-empty policies. Ecaz-only revival, Ambassador exclusions, native five-disc identity, original payments/Tech and supply-only Terror Cunning remain. Skills/Harkonnen, shared training, unrelated guards, public starts and save conversion stay separate.',
|
||||
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
|
||||
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
|
||||
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
|
||||
|
||||
@@ -139,7 +139,7 @@ void test('Cunning liberates actual captivity, never saves a captor to restore a
|
||||
assert.equal(liberated.leader.deaths, 4);
|
||||
assert.equal(dukeController(expireDuke(liberated, 3)), null);
|
||||
assert.equal(dukeController(captive), 'h');
|
||||
for (const source of ['ecaz', 'moritani', 'ally'] as const)
|
||||
for (const source of ['ecaz', 'ally'] as const)
|
||||
assert.throws(() => acquireDuke(captive, 'other', 3, source));
|
||||
const foreign = structuredClone(captive);
|
||||
foreign.leader.gholaBy = 'tleilaxu';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, initializeNexusGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
@@ -14,7 +14,7 @@ import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advanceMoritaniNexus as advance, assertMoritaniNexusPhysical as physical,
|
||||
closeMoritaniNexus as close, createMoritaniNexusClosing as closing,
|
||||
createMoritaniNexusDiscovery as discovery, createMoritaniNexusLobby as lobby,
|
||||
createMoritaniNexusDiscovery as discovery,
|
||||
moritaniNexusAct as act, moritaniNexusClean as clean, moritaniNexusPlayer as player,
|
||||
moritaniNexusPolicy as policy, moritaniNexusReload as reload,
|
||||
openMoritaniNexusBattle as openBattle, openMoritaniNexusLoss as loss,
|
||||
@@ -331,9 +331,3 @@ void test('original Discovery supply, reveal and free nested entry survive the l
|
||||
physical(done);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen original lobbies remain inadmissible without mutating declared source pieces', () => {
|
||||
const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
||||
let ready = reload(native);
|
||||
for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } });
|
||||
const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before);
|
||||
});
|
||||
@@ -105,8 +105,7 @@ export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Ga
|
||||
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
|
||||
assert.ok(g.players.some(p => p.faction === 'moritani'));
|
||||
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
|
||||
assert.ok(g.players.every(p => ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', 'harkonnen'].includes(p.faction)));
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.length >= (options.tech ? 3 : 2) && g.players.length <= 6);
|
||||
assert.ok(!options.strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
|
||||
@@ -120,19 +119,25 @@ export function createMoritaniNexusLobby(options: MoritaniNexusOptions = {}): Ga
|
||||
assert.ok(g.nexusCards.cards === null && g.nexusCards.phase === null);
|
||||
return g;
|
||||
}
|
||||
export function createMoritaniNexusSetup(options: MoritaniNexusOptions = {}): MoritaniNexusSetup {
|
||||
export function createMoritaniNexusSetup(options: MoritaniNexusOptions & {
|
||||
opponentFaction?: 'guild' | 'harkonnen'; harkonnenKarama?: boolean;
|
||||
} = {}): MoritaniNexusSetup {
|
||||
let g = createMoritaniNexusLobby(options);
|
||||
const initial = moritaniNexusReload(g), actions: MoritaniNexusStep[] = [];
|
||||
const owner = g.players.find(p => p.faction === 'moritani')!.id;
|
||||
const opponent = g.players.find(p => p.faction === 'guild')!.id; assert.ok(opponent);
|
||||
const target = leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id;
|
||||
const opponentFaction = options.opponentFaction ?? 'guild';
|
||||
const opponent = g.players.find(p => p.faction === opponentFaction)!.id; assert.ok(opponent);
|
||||
const target = opponentFaction === 'guild'
|
||||
? leaders('guild').find(l => l.name === (options.normalCall ? 'Staban Tuek' : 'Master Bewt'))!.id
|
||||
: leaders('harkonnen')[1].id;
|
||||
for (const p of g.players) if (!p.ready) g = moritaniNexusAct(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
if (options.initial) g = initializeNexusGameForAudit(g);
|
||||
else {
|
||||
const nx = options.nexus ?? 'moritani', cards = treacheryDeck(['ecaz']), remaining = [...cards];
|
||||
const front = g.players.flatMap(p => {
|
||||
const kinds = [p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
|
||||
const kinds = [options.harkonnenKarama && p.faction === 'harkonnen' ? 'karama'
|
||||
: p.id === opponent ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => { const i = remaining.findIndex(c => c.kind === kind || c.effect === kind); assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; });
|
||||
});
|
||||
const traitors = traitorDeck(g.players), desired = [target, ...traitors.filter(c => c !== target)];
|
||||
const index = nx === 'harkonnen' ? g.players.length * 4 : g.players.findIndex(p => p.id === owner) * 4;
|
||||
|
||||
@@ -608,27 +608,14 @@ export function revealMixedE3MoritaniPlans(g: Game, plans: readonly MixedE3Morit
|
||||
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
|
||||
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min',
|
||||
extra: { weapon?: string; defense?: string } = {}): Action {
|
||||
const steps: MixedE3MoritaniStep[] = [];
|
||||
let base = g;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const decision = base.decision;
|
||||
if (decision?.kind === 'moritaniPlacement') {
|
||||
const decline: MixedE3MoritaniStep = { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
steps.push(decline); base = stepMixedE3Moritani(base, decline); continue;
|
||||
}
|
||||
if (decision?.kind !== 'leaderSkillVisibility') break;
|
||||
const posture: MixedE3MoritaniStep = { actor: decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === actor } };
|
||||
steps.push(posture); base = stepMixedE3Moritani(base, posture);
|
||||
}
|
||||
const spice = mixedE3MoritaniPlayer(base, actor).spice;
|
||||
const spice = mixedE3MoritaniPlayer(g, actor).spice;
|
||||
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, i) => spice - i) : [0];
|
||||
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, i) => 12 - i) : [0];
|
||||
const reasons = new Set<string>();
|
||||
for (const support of supports) for (const dial of dials) {
|
||||
for (const card of [extra, {}]) {
|
||||
const action: Action = { type: 'battlePlan', leader, dial, support, ...card };
|
||||
try { applyAction(mixedE3MoritaniReload(base), actor, action); return action; }
|
||||
try { applyAction(mixedE3MoritaniReload(g), actor, action); return action; }
|
||||
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
}
|
||||
@@ -643,13 +630,10 @@ export function stageMixedE3MoritaniLoss(f: MixedE3MoritaniSetup, state: Game,
|
||||
const arrival = shipMixedE3Moritani(mixedE3MoritaniReload(state), f.native, f.location, 1, 0, f.actions);
|
||||
const ready = advanceMixedE3Moritani(arrival.settled, s => !s.decision, f.actions);
|
||||
let g = openMixedE3MoritaniBattle(ready, f.moritani, f.native, f.actions);
|
||||
// A pending posture declaration is a real private choice; conceal it first so
|
||||
// both plans are submitted against the same genuine state.
|
||||
for (let n = 0; n < 4 && g.decision?.kind === 'leaderSkillVisibility'; n++) {
|
||||
const posture = g.decision;
|
||||
g = stepMixedE3Moritani(g, { actor: posture.player,
|
||||
action: { type: 'leaderSkillVisibility', event: posture.event, hide: true } }, f.actions);
|
||||
}
|
||||
// Finish original Voice, Mentat/posture and preparation acknowledgements
|
||||
// before either validator probe; never validate against an undispatched clone.
|
||||
g = advanceMixedE3Moritani(g, s => !!s.battle && mixedE3MoritaniClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, f.actions);
|
||||
const arrangements = mixedE3MoritaniReload(g);
|
||||
const trained = new Set((g.leaderSkills?.assignments ?? []).map(a => a.leader));
|
||||
const ownLeader = mixedE3MoritaniPlayer(g, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
|
||||
import { moritaniTerrorAssassinationPool } from '../game/moritani-terror-assassination';
|
||||
import { createMoritaniNexusSetup } from './fixture-e3-nexus-moritani';
|
||||
import {
|
||||
advanceNexusDuke, assertNexusDukeCustody, emptyNexusDukePlan, nextNexusDukeStep,
|
||||
nexusDukeClean, nexusDukePlayer, openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
|
||||
type NexusDukePlan, type NexusDukeStep,
|
||||
} from './fixture-nexus-duke-capture';
|
||||
|
||||
type Frame = { before: Game; after: Game };
|
||||
export type MoritaniDukeCaptureFixture = {
|
||||
game: Game; owner: string; captor: string; guild: string; target: string; home: string;
|
||||
plan: NexusDukePlan; actions: NexusDukeStep[]; acquisition: Frame; capture: Frame;
|
||||
terrorToken?: string;
|
||||
};
|
||||
function nativeLeader(game: Game, actor: string, territory: string, strong: boolean): string {
|
||||
const pool = nexusDukePlayer(game, actor).leaders
|
||||
.filter(l => !l.dead && !l.capturedBy && !l.gholaBy && (!l.usedAt || l.usedAt === territory))
|
||||
.sort((a, b) => strong ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(pool[0]);
|
||||
return pool[0].id;
|
||||
}
|
||||
function winGuildStronghold(game: Game, owner: string, guild: string, plan: NexusDukePlan,
|
||||
actions: NexusDukeStep[]): Game {
|
||||
const battle = openNexusDukeBattle(game, plan, actions,
|
||||
{ territory: 'tueks_sietch', first: owner, second: guild });
|
||||
const sealed = sealNexusDukeBattle(battle.game, plan, actions, [
|
||||
{ actor: owner, leader: nativeLeader(battle.game, owner, 'tueks_sietch', true), dial: 0, support: 0 },
|
||||
{ actor: guild, leader: nativeLeader(battle.game, guild, 'tueks_sietch', false), dial: 0, support: 0 },
|
||||
]);
|
||||
return advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), plan, actions);
|
||||
}
|
||||
|
||||
/** Real native setup, two prospective stronghold battles, original Moritani
|
||||
* acquisition, an actual Harkonnen victory/lottery and original Keep/Execute.
|
||||
* Returns before final movement completion, or at an actual optional Terror entry. */
|
||||
export function createMoritaniDukeCaptureFixture(options: {
|
||||
karama?: boolean; execute?: boolean; terror?: boolean; terrorEntrant?: 'harkonnen' | 'guild';
|
||||
} = {}): MoritaniDukeCaptureFixture {
|
||||
const setup = createMoritaniNexusSetup({ roster: ['moritani', 'harkonnen', 'guild'],
|
||||
opponentFaction: 'harkonnen', harkonnenKarama: options.karama });
|
||||
const owner = setup.owner, captor = setup.opponent, guild = 'guild';
|
||||
const home = splitLocation(Object.keys(nexusDukePlayer(setup.afterSetup, owner).forces)[0]).territory;
|
||||
const plan: NexusDukePlan = { ...emptyNexusDukePlan(), owner, captor, entrant: guild,
|
||||
others: [captor, guild], capture: 'accept', mode: options.execute ? 'execute' : 'keep' };
|
||||
const actions = setup.actions;
|
||||
plan.ship = { [owner]: { territory: 'tueks_sietch', amount: 1 },
|
||||
[captor]: { territory: home, amount: 1 } };
|
||||
const acquisitionBefore = advanceNexusDuke(setup.afterSetup,
|
||||
g => g.phase === 5 && nexusDukeClean(g), plan, actions);
|
||||
let game = advanceNexusDuke(acquisitionBefore,
|
||||
g => !!g.dukeVidal && dukeController(g.dukeVidal) === owner, plan, actions);
|
||||
const acquisition = { before: acquisitionBefore, after: structuredClone(game) };
|
||||
const battle = openNexusDukeBattle(game, plan, actions,
|
||||
{ territory: home, first: owner, second: captor });
|
||||
const sealed = sealNexusDukeBattle(battle.game, plan, actions, [
|
||||
{ actor: owner, leader: nativeLeader(battle.game, owner, home, false), dial: 0, support: 0 },
|
||||
{ actor: captor, leader: nativeLeader(battle.game, captor, home, true), dial: 0, support: 0 },
|
||||
]);
|
||||
game = advanceNexusDuke(sealed, g => g.decision?.kind === 'capturedLeader', plan, actions);
|
||||
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader, DUKE_VIDAL_ID);
|
||||
const before = structuredClone(game);
|
||||
game = stepNexusDuke(game, plan, { actor: captor,
|
||||
action: { type: 'decision', mode: options.execute ? 'execute' : 'keep' } }, actions);
|
||||
const capture = { before, after: structuredClone(game) };
|
||||
plan.capture = 'decline';
|
||||
game = winGuildStronghold(game, owner, guild, plan, actions);
|
||||
let terrorToken: string | undefined;
|
||||
const terrorEntrant = options.terrorEntrant === 'guild' ? guild : captor;
|
||||
const terrorLocation = terrorEntrant === guild ? 'arrakeen' : 'tueks_sietch';
|
||||
if (options.terror) {
|
||||
game = advanceNexusDuke(game, g => g.decision?.kind === 'moritaniPlacement', plan, actions);
|
||||
const token = game.moritaniTerror!.tokens.find(t => t.kind === 'assassination' && t.status === 'available');
|
||||
assert.ok(token);
|
||||
terrorToken = token.id;
|
||||
game = stepNexusDuke(game, plan, { actor: owner,
|
||||
action: { type: 'decision', token: token.id, territory: terrorLocation } }, actions);
|
||||
}
|
||||
plan.ship = { [owner]: { territory: 'carthag', amount: 1 },
|
||||
[captor]: { territory: 'tueks_sietch', amount: 1 } };
|
||||
if (options.terrorEntrant === 'guild') plan.ship[guild] = { territory: terrorLocation, amount: 1 };
|
||||
game = advanceNexusDuke(game, g => options.terror
|
||||
? g.decision?.kind === 'moritaniTerror' && g.pendingTerrorEntry?.token === terrorToken
|
||||
: g.turn === 2 && g.phase === 5 && nexusDukeClean(g) &&
|
||||
!!g.active && g.movementRemaining?.length === 1 &&
|
||||
plan.shipped[owner] === String(g.turn) && plan.shipped[captor] === String(g.turn), plan, actions);
|
||||
assertNexusDukeCustody(game);
|
||||
return { game, owner, captor, guild, target: setup.target, home, plan, actions, acquisition, capture, terrorToken };
|
||||
}
|
||||
|
||||
export function reclaimMoritaniDuke(f: MoritaniDukeCaptureFixture): Frame {
|
||||
const before = structuredClone(f.game);
|
||||
const last = nextNexusDukeStep(f.game, f.plan);
|
||||
assert.ok(last && last.action.type === 'endMovement');
|
||||
let game = stepNexusDuke(f.game, f.plan, last, f.actions);
|
||||
game = advanceNexusDuke(game, g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
|
||||
assertNexusDukeCustody(game);
|
||||
return { before, after: game };
|
||||
}
|
||||
|
||||
export function cancelMoritaniDukeReclaim(f: MoritaniDukeCaptureFixture): Frame {
|
||||
const before = structuredClone(f.game), last = nextNexusDukeStep(f.game, f.plan);
|
||||
assert.ok(last && last.action.type === 'endMovement');
|
||||
let game = stepNexusDuke(f.game, f.plan, last, f.actions);
|
||||
game = advanceNexusDuke(game, g => g.response?.kind === 'moritaniDuke', f.plan, f.actions);
|
||||
const karama = nexusDukePlayer(game, f.captor).hand.find(c => c.effect === 'karama');
|
||||
assert.ok(karama);
|
||||
game = stepNexusDuke(game, f.plan, { actor: f.captor,
|
||||
action: { type: 'card', card: karama.id, mode: 'cancel' } }, f.actions);
|
||||
game = advanceNexusDuke(game, g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
|
||||
assertNexusDukeCustody(game);
|
||||
return { before, after: game };
|
||||
}
|
||||
|
||||
/** The native captor really uses Duke; its returned disc cannot erase the
|
||||
* surviving battle identity before Moritani's distinct printed-faction choice. */
|
||||
export function openCapturedDukeAssassinateWindow(game: Game, f: MoritaniDukeCaptureFixture): Game {
|
||||
const carthag = openNexusDukeBattle(game, f.plan, f.actions,
|
||||
{ territory: 'carthag', first: f.owner, second: f.captor });
|
||||
const sealed = sealNexusDukeBattle(carthag.game, f.plan, f.actions, [
|
||||
{ actor: f.owner, leader: nativeLeader(carthag.game, f.owner, 'carthag', false), dial: 0, support: 0 },
|
||||
{ actor: f.captor, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
|
||||
]);
|
||||
game = advanceNexusDuke(sealed, g => g.decision?.kind === 'moritaniAssassinate', f.plan, f.actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function assassinateAfterCapturedDukeUse(game: Game, f: MoritaniDukeCaptureFixture): {
|
||||
window: Game; after: Game; replaced: Game;
|
||||
} {
|
||||
game = openCapturedDukeAssassinateWindow(game, f);
|
||||
const offer = viewGame(game, f.owner).moritaniAssassinate?.pending;
|
||||
assert.ok(offer);
|
||||
assert.ok(offer.cards.some(c => c.card === f.target), 'The actual held native Harkonnen Traitor remains a legal different-disc target.');
|
||||
const window = structuredClone(game);
|
||||
game = stepNexusDuke(game, f.plan, { actor: f.owner,
|
||||
action: { type: 'decision', event: offer.event, card: f.target } }, f.actions);
|
||||
const after = structuredClone(game);
|
||||
game = advanceNexusDuke(game, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
|
||||
const tueks = openNexusDukeBattle(game, f.plan, f.actions,
|
||||
{ territory: 'tueks_sietch', first: f.owner, second: f.captor });
|
||||
const rest = sealNexusDukeBattle(tueks.game, f.plan, f.actions, [
|
||||
{ actor: f.owner, leader: nativeLeader(tueks.game, f.owner, 'tueks_sietch', true), dial: 0, support: 0 },
|
||||
{ actor: f.captor, leader: nativeLeader(tueks.game, f.captor, 'tueks_sietch', false), dial: 0, support: 0 },
|
||||
]);
|
||||
game = advanceNexusDuke(rest, g => g.phase === 8 && nexusDukeClean(g), f.plan, f.actions);
|
||||
assertNexusDukeCustody(game);
|
||||
return { window, after, replaced: game };
|
||||
}
|
||||
|
||||
export function useMoritaniReclaimedDuke(game: Game, f: MoritaniDukeCaptureFixture, use = true): Game {
|
||||
const carthag = openNexusDukeBattle(game, f.plan, f.actions,
|
||||
{ territory: 'carthag', first: f.owner, second: f.captor });
|
||||
let sealed = sealNexusDukeBattle(carthag.game, f.plan, f.actions, [
|
||||
{ actor: f.owner, leader: use ? DUKE_VIDAL_ID : nativeLeader(carthag.game, f.owner, 'carthag', true), dial: 0, support: 0 },
|
||||
{ actor: f.captor, leader: nativeLeader(carthag.game, f.captor, 'carthag', false), dial: 0, support: 0 },
|
||||
]);
|
||||
game = advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
|
||||
const tueks = openNexusDukeBattle(game, f.plan, f.actions,
|
||||
{ territory: 'tueks_sietch', first: f.owner, second: f.captor });
|
||||
sealed = sealNexusDukeBattle(tueks.game, f.plan, f.actions, [
|
||||
{ actor: f.owner, leader: nativeLeader(tueks.game, f.owner, 'tueks_sietch', true), dial: 0, support: 0 },
|
||||
{ actor: f.captor, leader: nativeLeader(tueks.game, f.captor, 'tueks_sietch', false), dial: 0, support: 0 },
|
||||
]);
|
||||
game = advanceNexusDuke(sealed, g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
|
||||
if (!use) game = advanceNexusDuke(game, g => g.turn > 2, f.plan, f.actions);
|
||||
assertNexusDukeCustody(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Controls only the original random permutation, never the effect, candidate
|
||||
* body or assigned leader state. UUID and non-scalar entropy remain native. */
|
||||
export function revealMoritaniTerrorVictim(f: MoritaniDukeCaptureFixture, victim?: string): Frame {
|
||||
const before = structuredClone(f.game);
|
||||
const entry = f.game.pendingTerrorEntry!;
|
||||
assert.ok(f.game.decision?.kind === 'moritaniTerror' && entry.token === f.terrorToken);
|
||||
const pool = moritaniTerrorAssassinationPool(f.game, entry.entrant).map(l => l.id);
|
||||
const desired = victim ? [victim, ...pool.filter(id => id !== victim)] : pool;
|
||||
assert.deepEqual(pool.slice().sort(), desired.slice().sort());
|
||||
const working = pool.slice(), rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]);
|
||||
assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
let index = 0;
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
const action = { type: 'decision' as const, reveal: true };
|
||||
f.actions.push({ actor: f.owner, action });
|
||||
let after: Game;
|
||||
try { after = applyAction(structuredClone(f.game), f.owner, action); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
assertNexusDukeCustody(after);
|
||||
return { before, after };
|
||||
}
|
||||
@@ -718,7 +718,8 @@ export function assertNexusDukeCustody(game: Game): void {
|
||||
}
|
||||
const traitors = [...(game.traitorReserve ?? []),
|
||||
...game.players.flatMap((player) => [...player.traitors, ...player.traitorChoices]),
|
||||
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
|
||||
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
|
||||
...(game.moritaniAssassinate?.opportunities.filter(record => record.stage === 'replaced').map(record => record.card!) ?? [])];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
|
||||
assert.ok(!traitors.includes(DUKE_VIDAL_ID), 'The shared disc has no Traitor identity.');
|
||||
const physical = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
|
||||
|
||||
@@ -104,7 +104,7 @@ void test('original mixed E3 native Moritani admission conserves decks, marks, t
|
||||
}
|
||||
});
|
||||
|
||||
void test('two-through-six original seats, the combined five/six-native roster and the Harkonnen guards stay original', () => {
|
||||
void test('two-through-six original seats and the combined five/six-native roster keep original physical setup', () => {
|
||||
const two = setup({ families: ['ix'], roster: ['moritani', 'tleilaxu'] });
|
||||
assert.equal(two.afterSetup.players.length, 2); assert.equal(two.partner, null);
|
||||
assert.equal(two.afterSetup.nexusCards!.cards!.deck.length, 12);
|
||||
@@ -123,11 +123,10 @@ void test('two-through-six original seats, the combined five/six-native roster a
|
||||
assert.equal(six.entry, 'combined'); assert.equal(six.afterSetup.richeseCache!.length, 10);
|
||||
assert.ok(six.afterSetup.expansions.includes('ix'));
|
||||
physical(six.afterSetup);
|
||||
// Basic Harkonnen remains a legal classic seat; Advanced retains its guard.
|
||||
// Basic Harkonnen retains its original native setup.
|
||||
const basic = setup({ rules: 'basic', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] });
|
||||
assert.equal(player(basic.afterSetup, basic.seats.harkonnen!).traitors.length, 4);
|
||||
assert.equal(basic.afterSetup.moritaniAssassinate, undefined);
|
||||
assert.throws(() => setup({ rules: 'advanced', families: ['ix'], roster: ['moritani', 'tleilaxu', 'harkonnen'] }));
|
||||
assert.throws(() => setup({ rules: 'basic', families: ['ix'], strongholds: true }));
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur
|
||||
{ opposingLeader: null },
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
{ opposingLeader: 'emperor-0' },
|
||||
{ faction: 'harkonnen', opposingLeader: 'harkonnen-0' },
|
||||
{ turn: 0 },
|
||||
{ turn: 1.5 },
|
||||
{ event: '' },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { dukeController } from '../game/duke-vidal';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
assassinateAfterCapturedDukeUse, cancelMoritaniDukeReclaim,
|
||||
createMoritaniDukeCaptureFixture, reclaimMoritaniDuke, useMoritaniReclaimedDuke,
|
||||
} from './fixture-moritani-duke-capture';
|
||||
import { nextNexusDukeStep, nexusDukePlayer, stepNexusDuke } from './fixture-nexus-duke-capture';
|
||||
|
||||
void test('original Moritani qualification takes its real Harkonnen captive and releases after one native battle', () => {
|
||||
const f = createMoritaniDukeCaptureFixture();
|
||||
assert.equal(dukeController(f.game.dukeVidal!), f.captor);
|
||||
const reclaimed = reclaimMoritaniDuke(f);
|
||||
const duke = reclaimed.after.dukeVidal!;
|
||||
assert.equal(dukeController(duke), f.owner);
|
||||
assert.equal(duke.leader.capturedBy, undefined);
|
||||
assert.equal(duke.leader.concealed, undefined);
|
||||
assert.equal(duke.captureVisibility, undefined);
|
||||
assert.equal(duke.leader.dead, false);
|
||||
assert.equal(duke.leader.deaths, f.game.dukeVidal!.leader.deaths);
|
||||
assert.equal(duke.source, 'moritani');
|
||||
assert.equal(nexusDukePlayer(reclaimed.after, f.owner).spice, nexusDukePlayer(reclaimed.before, f.owner).spice);
|
||||
const used = useMoritaniReclaimedDuke(reclaimed.after, f);
|
||||
assert.equal(dukeController(used.dukeVidal!), null);
|
||||
assert.equal(used.dukeVidal!.leader.dead, false);
|
||||
assert.equal(used.dukeVidal!.leader.usedAt, 'carthag');
|
||||
assert.equal(used.dukeVidal!.leader.capturedBy, undefined);
|
||||
});
|
||||
|
||||
void test('unused original Moritani reclamation expires without restoring Harkonnen custody', () => {
|
||||
const f = createMoritaniDukeCaptureFixture();
|
||||
const reclaimed = reclaimMoritaniDuke(f);
|
||||
const expired = useMoritaniReclaimedDuke(reclaimed.after, f, false);
|
||||
assert.equal(dukeController(expired.dukeVidal!), null);
|
||||
assert.equal(expired.dukeVidal!.leader.dead, false);
|
||||
assert.equal(expired.dukeVidal!.leader.capturedBy, undefined);
|
||||
assert.equal(expired.dukeVidal!.leader.deaths, f.game.dukeVidal!.leader.deaths);
|
||||
});
|
||||
|
||||
void test('a qualified public acquisition cannot revive an actually executed captive Duke', () => {
|
||||
const f = createMoritaniDukeCaptureFixture({ execute: true });
|
||||
assert.equal(f.game.dukeVidal!.leader.dead, true);
|
||||
const resolved = reclaimMoritaniDuke(f);
|
||||
assert.equal(dukeController(resolved.after.dukeVidal!), null);
|
||||
assert.equal(resolved.after.dukeVidal!.leader.dead, true);
|
||||
assert.equal(resolved.after.dukeVidal!.leader.deaths, 2);
|
||||
assert.equal(resolved.after.dukeVidal!.leader.concealed, undefined);
|
||||
assert.equal(nexusDukePlayer(resolved.after, f.owner).spice, nexusDukePlayer(resolved.before, f.owner).spice);
|
||||
});
|
||||
|
||||
void test('original Karama preserves the captive and its actual winning use still opens native Harkonnen Assassinate', () => {
|
||||
const f = createMoritaniDukeCaptureFixture({ karama: true });
|
||||
const karama = nexusDukePlayer(f.game, f.captor).hand.find(c => c.effect === 'karama')!;
|
||||
const canceled = cancelMoritaniDukeReclaim(f);
|
||||
assert.equal(dukeController(canceled.after.dukeVidal!), f.captor);
|
||||
assert.deepEqual(canceled.after.dukeVidal, canceled.before.dukeVidal);
|
||||
assert.ok(canceled.after.discard.some(c => c.id === karama.id));
|
||||
const used = assassinateAfterCapturedDukeUse(canceled.after, f);
|
||||
assert.equal(dukeController(used.window.dukeVidal!), null);
|
||||
assert.equal(used.window.dukeVidal!.leader.dead, false);
|
||||
const targetBefore = nexusDukePlayer(used.window, f.captor).leaders.find(l => l.id === f.target)!;
|
||||
const targetAfter = nexusDukePlayer(used.after, f.captor).leaders.find(l => l.id === f.target)!;
|
||||
assert.equal(targetAfter.dead, true);
|
||||
assert.equal(targetAfter.deaths, targetBefore.deaths + 1);
|
||||
assert.equal(nexusDukePlayer(used.after, f.owner).spice,
|
||||
nexusDukePlayer(used.window, f.owner).spice + targetBefore.strength);
|
||||
assert.equal(nexusDukePlayer(used.after, f.owner).traitors.includes(f.target), true);
|
||||
assert.equal(nexusDukePlayer(used.replaced, f.owner).traitors.includes(f.target), false);
|
||||
assert.equal(used.replaced.moritaniAssassinate!.opportunities.filter(r =>
|
||||
r.card === f.target && r.stage === 'replaced').length, 1);
|
||||
});
|
||||
|
||||
void test('a private execution does not remove or identify the public qualifying acquisition window', () => {
|
||||
const living = createMoritaniDukeCaptureFixture({ karama: true });
|
||||
const executed = createMoritaniDukeCaptureFixture({ karama: true, execute: true });
|
||||
const open = (f: typeof living) => {
|
||||
const next = nextNexusDukeStep(f.game, f.plan);
|
||||
assert.ok(next);
|
||||
return stepNexusDuke(f.game, f.plan, next, f.actions);
|
||||
};
|
||||
const livePending = open(living), deadPending = open(executed);
|
||||
assert.equal(livePending.response?.kind, 'moritaniDuke');
|
||||
assert.equal(deadPending.response?.kind, 'moritaniDuke');
|
||||
const livePublic = viewGame(livePending, living.guild);
|
||||
const deadPublic = viewGame(deadPending, executed.guild);
|
||||
assert.deepEqual(deadPublic.response, livePublic.response);
|
||||
assert.deepEqual(deadPublic.dukeVidal, livePublic.dukeVidal);
|
||||
assert.equal(deadPending.dukeVidal!.leader.dead, true);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController, expireDuke } from '../game/duke-vidal';
|
||||
|
||||
void test('Moritani takes the actual living captive without restoring the captor after its tenure', () => {
|
||||
const initial = acquireDuke(createDukeVidal(), 'm', 1, 'moritani');
|
||||
initial.leader.deaths = 2;
|
||||
const captive = captureDuke(initial, 'h', 'm', 1, 'arrakeen');
|
||||
const before = structuredClone(captive);
|
||||
const taken = acquireDuke(captive, 'm', 2, 'moritani');
|
||||
assert.deepEqual(captive, before);
|
||||
assert.equal(dukeController(taken), 'm');
|
||||
assert.equal(taken.source, 'moritani');
|
||||
assert.equal(taken.leader.capturedBy, undefined);
|
||||
assert.equal(taken.leader.concealed, undefined);
|
||||
assert.equal(taken.captureVisibility, undefined);
|
||||
assert.equal(taken.leader.dead, false);
|
||||
assert.equal(taken.leader.deaths, 2);
|
||||
assert.equal(taken.leader.faction, 'ecaz');
|
||||
assert.equal(taken.leader.strength, 6);
|
||||
const unused = expireDuke(taken, 2);
|
||||
assert.equal(dukeController(unused), null);
|
||||
assert.equal(unused.leader.capturedBy, undefined);
|
||||
const used = consumeDuke(taken);
|
||||
assert.equal(dukeController(used), null);
|
||||
assert.equal(used.leader.deaths, 2);
|
||||
});
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from '../game/engine';
|
||||
import { type FactionId } from '../game/catalog';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
@@ -119,7 +117,7 @@ void test('each native leader is eligible when alone alive; death count and batt
|
||||
}
|
||||
});
|
||||
|
||||
void test('Assassination pays three for Zoal but leaves unaudited empty live-leader pools unchanged', () => {
|
||||
void test('Assassination pays the printed three for Zoal', () => {
|
||||
const initial = fixture('assassination', 'tleilaxu');
|
||||
const zoal = player(initial, 'e').leaders.find(
|
||||
(leader) => leader.name === 'Zoal',
|
||||
@@ -133,33 +131,15 @@ void test('Assassination pays three for Zoal but leaves unaudited empty live-lea
|
||||
player(done, 'e').leaders.find((leader) => leader.id === zoal.id)!.dead,
|
||||
true,
|
||||
);
|
||||
const empty = fixture();
|
||||
player(empty, 'e').leaders.forEach((leader) => {
|
||||
leader.dead = true;
|
||||
leader.deaths = 2;
|
||||
});
|
||||
const entered = enter(empty);
|
||||
assert.equal(viewGame(entered, 'm').terrorEntry!.canReveal, false);
|
||||
rejected(entered, { type: 'decision', reveal: true });
|
||||
assert.equal(
|
||||
entered.moritaniTerror!.tokens.find(
|
||||
(token) => token.kind === 'assassination',
|
||||
)!.status,
|
||||
'placed',
|
||||
);
|
||||
const declined = decide(entered, { decline: true });
|
||||
assert.deepEqual(declined.players, entered.players);
|
||||
assert.equal(declined.pendingTerrorEntry, null);
|
||||
});
|
||||
|
||||
void test('unresolved native and foreign custody disables Assassination only in the owner projection and rejects atomically', () => {
|
||||
void test('malformed capture and Ghola assignments reject atomically without disclosing rival token faces', () => {
|
||||
for (const field of ['capturedBy', 'gholaBy'] as const) {
|
||||
for (const foreign of [false, true]) {
|
||||
const initial = fixture();
|
||||
if (foreign) player(initial, 'a').leaders[0][field] = 'e';
|
||||
else player(initial, 'e').leaders[0][field] = 'a';
|
||||
const g = enter(initial);
|
||||
assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, false);
|
||||
assert.equal('canReveal' in viewGame(g, 'e').terrorEntry!, false);
|
||||
assert.equal('kind' in viewGame(g, 'a').terrorEntry!, false);
|
||||
rejected(g, { type: 'decision', reveal: true });
|
||||
@@ -260,30 +240,11 @@ void test('Sneak Attack reinforces a stronghold already occupied by Moritani wit
|
||||
assert.equal(totalForces(done, 'm'), 20);
|
||||
});
|
||||
|
||||
void test('all four AI profiles offer legal Assassination and Sneak Attack decisions', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
for (const state of [
|
||||
offered(),
|
||||
offered('sneakAttack'),
|
||||
decide(offered('sneakAttack'), { reveal: true }),
|
||||
]) {
|
||||
player(state, 'm').bot = difficulty;
|
||||
const actions = botActions(viewGame(state, 'm'));
|
||||
assert.ok(
|
||||
actions.length > 0,
|
||||
difficulty + ':' + state.pendingTerrorEntry?.stage,
|
||||
);
|
||||
for (const action of actions)
|
||||
assert.doesNotThrow(() => applyAction(state, 'm', action));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('unknown native leader identities cannot bypass Assassination; newly verified rosters are eligible', () => {
|
||||
const unknown = fixture();
|
||||
player(unknown, 'e').leaders[0].id = 'not-a-catalog-leader';
|
||||
const offeredUnknown = enter(unknown);
|
||||
assert.equal(viewGame(offeredUnknown, 'm').terrorEntry!.canReveal, false);
|
||||
rejected(offeredUnknown, { type: 'decision', reveal: true });
|
||||
for (const faction of ['choam', 'richese'] as const) {
|
||||
const known = enter(fixture('assassination', faction));
|
||||
@@ -291,29 +252,10 @@ void test('unknown native leader identities cannot bypass Assassination; newly v
|
||||
const malformed = fixture('assassination', faction);
|
||||
player(malformed, 'e').leaders[0].id = 'unverified-expansion-leader';
|
||||
const missing = enter(malformed);
|
||||
assert.equal(viewGame(missing, 'm').terrorEntry!.canReveal, false);
|
||||
rejected(missing, { type: 'decision', reveal: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('advanced Harkonnen tables uniformly disable Assassination without exposing hidden capture changes', () => {
|
||||
const initial = fixture();
|
||||
initial.advanced = true;
|
||||
const harkonnen = newPlayer('h', 'Harkonnen', 'harkonnen');
|
||||
initial.players.push(harkonnen);
|
||||
initial.order.push('h');
|
||||
const empty = enter(initial);
|
||||
assert.equal(viewGame(empty, 'm').terrorEntry!.canReveal, false);
|
||||
rejected(empty, { type: 'decision', reveal: true });
|
||||
const captured = structuredClone(initial);
|
||||
player(captured, 'e').leaders[0].capturedBy = 'h';
|
||||
const held = enter(captured);
|
||||
assert.deepEqual(
|
||||
viewGame(held, 'm').terrorEntry,
|
||||
viewGame(empty, 'm').terrorEntry,
|
||||
);
|
||||
rejected(held, { type: 'decision', reveal: true });
|
||||
});
|
||||
|
||||
void test('Sneak Attack rejects an altered client sector and accrues Heighliners once for a positive entry while allowing zero', () => {
|
||||
const revealed = decide(offered('sneakAttack'), { reveal: true });
|
||||
@@ -328,12 +270,6 @@ void test('Sneak Attack rejects an altered client sector and accrues Heighliners
|
||||
assert.equal(player(accrued, 'm').forces['arrakeen:10'], 1);
|
||||
assert.equal(accrued.techTokens!.heighliners.triggeredTurn, accrued.turn);
|
||||
assert.equal(accrued.techTokens!.heighliners.spice, 1);
|
||||
assert.equal(
|
||||
accrued.log.some((line) =>
|
||||
line.text.includes('Heighliners accrued 1 spice for Atreides'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
const passed = decide(technology, { amount: 0 });
|
||||
assert.deepEqual(passed.techTokens, technology.techTokens);
|
||||
assert.deepEqual(passed.players, technology.players);
|
||||
@@ -384,59 +320,3 @@ void test('declining the Bene Gesserit intrusion still offers the free advisor s
|
||||
);
|
||||
});
|
||||
|
||||
void test('all four AI profiles complete blocked effects by declining instead of retrying illegal choices', () => {
|
||||
const noLeaders = fixture();
|
||||
player(noLeaders, 'e').leaders.forEach((leader) => {
|
||||
leader.dead = true;
|
||||
});
|
||||
const blockedAssassination = enter(noLeaders);
|
||||
const blockedSneak = decide(offered('sneakAttack'), { reveal: true });
|
||||
blockedSneak.storm = 10;
|
||||
const blockedOffer = offered('sneakAttack');
|
||||
blockedOffer.storm = 10;
|
||||
const emptyOffer = offered('sneakAttack');
|
||||
player(emptyOffer, 'm').reserves = 0;
|
||||
assert.ok(viewGame(blockedOffer, 'm').terrorEntry!.sneakAttack?.blocked);
|
||||
assert.equal(viewGame(blockedOffer, 'm').terrorEntry!.stage, 'offer');
|
||||
assert.equal(viewGame(emptyOffer, 'm').terrorEntry!.sneakAttack?.maximum, 0);
|
||||
for (const state of [blockedOffer, emptyOffer]) {
|
||||
for (const id of ['e', 'a']) {
|
||||
assert.equal('sneakAttack' in viewGame(state, id).terrorEntry!, false);
|
||||
assert.equal('kind' in viewGame(state, id).terrorEntry!, false);
|
||||
}
|
||||
}
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
for (const state of [
|
||||
blockedAssassination,
|
||||
blockedSneak,
|
||||
blockedOffer,
|
||||
emptyOffer,
|
||||
]) {
|
||||
const g = reload(state);
|
||||
player(g, 'm').bot = difficulty;
|
||||
const actions = botActions(viewGame(g, 'm'));
|
||||
assert.ok(actions.length > 0);
|
||||
for (const action of actions) {
|
||||
const done = applyAction(g, 'm', action);
|
||||
assert.equal(done.pendingTerrorEntry, null);
|
||||
assert.equal(done.decision, null);
|
||||
assert.deepEqual(done.players, g.players);
|
||||
if (state === blockedOffer || state === emptyOffer) {
|
||||
assert.equal(action.decline, true);
|
||||
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
|
||||
assert.equal(
|
||||
done.moritaniTerror!.tokens.find(
|
||||
(token) => token.kind === 'sneakAttack',
|
||||
)!.status,
|
||||
'placed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(viewGame(blockedSneak, 'm').terrorEntry!.sneakAttack?.blocked);
|
||||
assert.equal(
|
||||
'sneakAttack' in viewGame(blockedSneak, 'e').terrorEntry!,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaders } from '../game/cards';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import {
|
||||
createMoritaniAssassinateOpportunity,
|
||||
moritaniAssassinateChoices,
|
||||
moritaniAssassinateSignature,
|
||||
moritaniAssassinateTrigger,
|
||||
quoteMoritaniAssassinate,
|
||||
validateMoritaniAssassinate,
|
||||
type MoritaniAssassinateContext,
|
||||
type MoritaniAssassinateReceipt,
|
||||
type MoritaniAssassinateTrigger,
|
||||
} from '../game/moritani-assassinate';
|
||||
|
||||
function trigger(opposingLeader = 'harkonnen-0'): MoritaniAssassinateTrigger {
|
||||
return {
|
||||
advanced: true,
|
||||
owner: 'm',
|
||||
ownerFaction: 'moritani',
|
||||
opponent: 'h',
|
||||
faction: 'harkonnen',
|
||||
event: 'battle:2:arrakeen',
|
||||
turn: 2,
|
||||
territory: 'arrakeen',
|
||||
opposingLeader,
|
||||
opposingLeaderSurvived: true,
|
||||
normalBattle: true,
|
||||
lostBattle: true,
|
||||
traitorCalled: false,
|
||||
};
|
||||
}
|
||||
function fixture(opposingLeader = 'harkonnen-0'): MoritaniAssassinateContext {
|
||||
const receipt = createMoritaniAssassinateOpportunity(trigger(opposingLeader));
|
||||
return {
|
||||
state: { version: 1, owner: 'm', normalTraitorCall: false, opportunities: [receipt] },
|
||||
receipt,
|
||||
held: ['harkonnen-0', 'harkonnen-1', 'moritani-0', 'atreides-0', DUKE_VIDAL_ID, 'cheap-hero-traitor'],
|
||||
normallyRevealed: [],
|
||||
leaders: leaders('harkonnen'),
|
||||
};
|
||||
}
|
||||
function revised(
|
||||
receipt: MoritaniAssassinateReceipt,
|
||||
patch: Partial<MoritaniAssassinateReceipt>,
|
||||
): MoritaniAssassinateReceipt {
|
||||
const result = { ...receipt, ...patch };
|
||||
result.signature = moritaniAssassinateSignature(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void test('a surviving native Harkonnen battle disc excludes only its own card and permits a different printed Harkonnen victim', () => {
|
||||
const context = fixture();
|
||||
assert.equal(moritaniAssassinateTrigger(trigger()), true);
|
||||
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(card => card.card), ['harkonnen-1']);
|
||||
assert.throws(() => quoteMoritaniAssassinate(context, 'harkonnen-0'));
|
||||
const quote = quoteMoritaniAssassinate(context, 'harkonnen-1');
|
||||
assert.equal(quote.kill, true);
|
||||
assert.equal(quote.bounty, 4);
|
||||
validateMoritaniAssassinate({ ...context.state, opportunities: [quote.receipt] }, {
|
||||
turn: 2, held: context.held, removed: [],
|
||||
});
|
||||
assert.throws(() => validateMoritaniAssassinate({
|
||||
...context.state,
|
||||
opportunities: [revised(quote.receipt, { card: 'harkonnen-0', bounty: 6 })],
|
||||
}));
|
||||
assert.equal(moritaniAssassinateChoices({
|
||||
...context, receipt: quote.receipt,
|
||||
state: { ...context.state, opportunities: [quote.receipt] },
|
||||
}).cards.length, 0);
|
||||
});
|
||||
|
||||
void test('a Harkonnen win with a captured foreign disc, including Moritani, still exposes only printed Harkonnen targets', () => {
|
||||
for (const battleDisc of ['atreides-0', 'moritani-0', DUKE_VIDAL_ID]) {
|
||||
const context = fixture(battleDisc);
|
||||
// The authoritative engine supplies the actual controlled surviving disc.
|
||||
assert.equal(moritaniAssassinateTrigger(trigger(battleDisc)), true);
|
||||
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(c => c.card), [
|
||||
'harkonnen-0', 'harkonnen-1',
|
||||
]);
|
||||
const foreignCard = battleDisc === 'moritani-0' ? 'atreides-0' : 'moritani-0';
|
||||
assert.throws(() => quoteMoritaniAssassinate(context, foreignCard));
|
||||
const quote = quoteMoritaniAssassinate(context, 'harkonnen-0');
|
||||
assert.equal(quote.bounty, 6);
|
||||
assert.equal(quote.kill, true);
|
||||
const state = { ...context.state, opportunities: [quote.receipt] };
|
||||
validateMoritaniAssassinate(state, { turn: 2, held: context.held, removed: [] });
|
||||
// A signed receipt cannot reclassify the captured disc or Duke as the victim.
|
||||
assert.throws(() => validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [revised(quote.receipt, { card: battleDisc, bounty: 0 })],
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Cheap Hero, missing disc, death, traitor calls and nonnormal outcomes cannot create a Harkonnen opportunity', () => {
|
||||
for (const patch of [
|
||||
{ opposingLeader: 'cheap-hero-traitor' },
|
||||
{ opposingLeader: 'cheap-hero' },
|
||||
{ opposingLeader: 'choam-auditor' },
|
||||
{ opposingLeader: 'harkonnen-5' },
|
||||
{ opposingLeader: null },
|
||||
{ opposingLeaderSurvived: false },
|
||||
{ advanced: false },
|
||||
{ normalBattle: false },
|
||||
{ lostBattle: false },
|
||||
{ traitorCalled: true },
|
||||
]) {
|
||||
const input = { ...trigger(), ...patch };
|
||||
assert.equal(moritaniAssassinateTrigger(input), false);
|
||||
assert.throws(() => createMoritaniAssassinateOpportunity(input));
|
||||
}
|
||||
const context = fixture();
|
||||
assert.throws(() => validateMoritaniAssassinate({
|
||||
...context.state,
|
||||
opportunities: [revised(context.receipt, { opposingLeader: 'cheap-hero-traitor' })],
|
||||
}));
|
||||
});
|
||||
|
||||
void test('dead native Harkonnen targets consume the faction use without a second death or bounty', () => {
|
||||
const context = fixture('moritani-0');
|
||||
context.leaders[0].dead = true;
|
||||
context.leaders[0].deaths = 2;
|
||||
const before = structuredClone(context);
|
||||
const quote = quoteMoritaniAssassinate(context, 'harkonnen-0');
|
||||
assert.equal(quote.kill, false);
|
||||
assert.equal(quote.bounty, 0);
|
||||
const next = fixture(DUKE_VIDAL_ID);
|
||||
next.receipt = revised(next.receipt, { event: 'battle:2:carthag' });
|
||||
next.state.opportunities = [quote.receipt, next.receipt];
|
||||
assert.deepEqual(moritaniAssassinateChoices(next).cards, []);
|
||||
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('the normal own-card reveal forfeits Harkonnen assassination across turns while a decline does not consume it', () => {
|
||||
const context = fixture('atreides-0');
|
||||
const declined = revised(context.receipt, { stage: 'declined' });
|
||||
const next = fixture('moritani-0');
|
||||
next.receipt = revised(next.receipt, { event: 'battle:3:carthag', turn: 3 });
|
||||
next.state.opportunities = [declined, next.receipt];
|
||||
assert.equal(quoteMoritaniAssassinate(next, 'harkonnen-1').bounty, 4);
|
||||
next.state.normalTraitorCall = true;
|
||||
assert.deepEqual(moritaniAssassinateChoices(next).cards, []);
|
||||
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
|
||||
});
|
||||
|
||||
void test('Harkonnen reveal retains its physical card until one distinct Mentat replacement and never resets faction use', () => {
|
||||
const context = fixture(DUKE_VIDAL_ID);
|
||||
const revealed = quoteMoritaniAssassinate(context, 'harkonnen-1').receipt;
|
||||
const state = { ...context.state, opportunities: [revealed] };
|
||||
assert.throws(() => validateMoritaniAssassinate(state, {
|
||||
turn: 2, held: ['moritani-0'], removed: ['harkonnen-1'],
|
||||
}));
|
||||
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID, 'cheap-hero-traitor'])
|
||||
assert.throws(() => validateMoritaniAssassinate({
|
||||
...state, opportunities: [revised(revealed, { stage: 'replaced', replacement })],
|
||||
}));
|
||||
const replaced = revised(revealed, { stage: 'replaced', replacement: 'guild-2' });
|
||||
validateMoritaniAssassinate({ ...state, opportunities: [replaced] }, {
|
||||
turn: 3, held: ['guild-2'], removed: ['harkonnen-1'],
|
||||
});
|
||||
const next = fixture('harkonnen-0');
|
||||
next.receipt = revised(next.receipt, { event: 'battle:3:carthag', turn: 3 });
|
||||
next.state.opportunities = [replaced, next.receipt];
|
||||
assert.throws(() => quoteMoritaniAssassinate(next, 'harkonnen-1'));
|
||||
assert.throws(() => validateMoritaniAssassinate({
|
||||
...state,
|
||||
opportunities: [replaced, revised(revealed, {
|
||||
event: 'battle:3:carthag', turn: 3, card: 'harkonnen-0', opposingLeader: 'moritani-0', bounty: 6,
|
||||
})],
|
||||
}));
|
||||
});
|
||||
|
||||
void test('admitting Harkonnen battle captives does not relax exceptional target custody or other factions battle identities', () => {
|
||||
for (const patch of [
|
||||
{ capturedBy: 'other' },
|
||||
{ gholaBy: 'other' },
|
||||
{ controller: 'other' },
|
||||
{ concealed: { captor: 'other', dead: false, deaths: 0 } },
|
||||
]) {
|
||||
const context = fixture('moritani-0');
|
||||
Object.assign(context.leaders[1], patch);
|
||||
assert.deepEqual(moritaniAssassinateChoices(context).cards.map(c => c.card), ['harkonnen-0']);
|
||||
assert.throws(() => quoteMoritaniAssassinate(context, 'harkonnen-1'));
|
||||
}
|
||||
for (const battleDisc of ['harkonnen-0', 'moritani-0', DUKE_VIDAL_ID])
|
||||
assert.equal(moritaniAssassinateTrigger({ ...trigger(battleDisc), faction: 'atreides' }), false);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
|
||||
import { createMoritaniDukeCaptureFixture, revealMoritaniTerrorVictim } from './fixture-moritani-duke-capture';
|
||||
import { advanceNexusDuke, nexusDukeClean, nexusDukePlayer } from './fixture-nexus-duke-capture';
|
||||
|
||||
void test('the actual native Terror lottery kills the entrant’s captured Duke once and resumes its paid shipment', () => {
|
||||
const f = createMoritaniDukeCaptureFixture({ terror: true });
|
||||
assert.equal(f.game.pendingTerrorEntry!.entrant, f.captor);
|
||||
assert.equal(f.game.dukeVidal!.leader.capturedBy, f.captor);
|
||||
const revealed = revealMoritaniTerrorVictim(f, DUKE_VIDAL_ID);
|
||||
assert.equal(revealed.after.dukeVidal!.leader.dead, true);
|
||||
assert.equal(revealed.after.dukeVidal!.leader.deaths, revealed.before.dukeVidal!.leader.deaths + 1);
|
||||
assert.equal(dukeController(revealed.after.dukeVidal!), null);
|
||||
assert.equal(revealed.after.dukeVidal!.leader.capturedBy, undefined);
|
||||
assert.equal(revealed.after.dukeVidal!.leader.concealed, undefined);
|
||||
assert.equal(revealed.after.dukeVidal!.captureVisibility, undefined);
|
||||
assert.equal(nexusDukePlayer(revealed.after, f.owner).spice,
|
||||
nexusDukePlayer(revealed.before, f.owner).spice + 6);
|
||||
assert.ok(nexusDukePlayer(revealed.after, f.captor).leaders.every(l => !l.dead));
|
||||
assert.equal(nexusDukePlayer(revealed.after, f.captor).forces['tueks_sietch:5'], 1);
|
||||
assert.equal(revealed.after.moritaniTerror!.tokens.find(t => t.id === f.terrorToken)!.status, 'removed');
|
||||
assert.ok(!revealed.after.pendingTerrorEntry);
|
||||
const settled = advanceNexusDuke(revealed.after,
|
||||
g => g.phase === 6 && nexusDukeClean(g), f.plan, f.actions);
|
||||
assert.equal(settled.dukeVidal!.leader.dead, true);
|
||||
assert.equal(dukeController(settled.dukeVidal!), null);
|
||||
});
|
||||
|
||||
void test('an explicit empty-pool checkpoint spends the original revealed token with no death or bounty', () => {
|
||||
const f = createMoritaniDukeCaptureFixture({ terror: true, terrorEntrant: 'guild' });
|
||||
assert.equal(f.game.pendingTerrorEntry!.entrant, f.guild);
|
||||
// Controlled boundary: the original arrival/token are genuine; only the
|
||||
// entrant's preceding native-death history is staged for this empty edge.
|
||||
for (const leader of nexusDukePlayer(f.game, f.guild).leaders) {
|
||||
leader.dead = true;
|
||||
leader.deaths = 1;
|
||||
}
|
||||
const before = structuredClone(nexusDukePlayer(f.game, f.guild).leaders);
|
||||
const revealed = revealMoritaniTerrorVictim(f);
|
||||
assert.deepEqual(nexusDukePlayer(revealed.after, f.guild).leaders, before);
|
||||
assert.equal(nexusDukePlayer(revealed.after, f.owner).spice,
|
||||
nexusDukePlayer(revealed.before, f.owner).spice);
|
||||
assert.equal(revealed.after.moritaniTerror!.tokens.find(t => t.id === f.terrorToken)!.status, 'removed');
|
||||
assert.equal(nexusDukePlayer(revealed.after, f.guild).forces['arrakeen:10'], 1);
|
||||
assert.ok(!revealed.after.pendingTerrorEntry);
|
||||
assert.equal(revealed.after.active, f.guild);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
battleLeaderStrength,
|
||||
createAuditorLeader,
|
||||
leaders,
|
||||
type Leader,
|
||||
} from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { acquireDuke, captureDuke, createDukeVidal, type DukeState } from '../game/duke-vidal';
|
||||
import {
|
||||
moritaniTerrorAssassinationPool,
|
||||
moritaniTerrorAssassinationValue,
|
||||
} from '../game/moritani-terror-assassination';
|
||||
|
||||
type TerrorFixture = {
|
||||
advanced: boolean;
|
||||
players: { id: string; faction: FactionId; leaders: Leader[] }[];
|
||||
dukeVidal: DukeState;
|
||||
};
|
||||
function fixture(): TerrorFixture {
|
||||
return {
|
||||
advanced: true,
|
||||
players: (['atreides', 'harkonnen', 'tleilaxu', 'ecaz', 'moritani', 'choam'] as const)
|
||||
.map((faction) => ({ id: faction, faction, leaders: leaders(faction) })),
|
||||
dukeVidal: createDukeVidal(),
|
||||
};
|
||||
}
|
||||
const seat = (g: TerrorFixture, id: string) =>
|
||||
g.players.find((player) => player.id === id)!;
|
||||
const ids = (pool: readonly Leader[]) => pool.map((leader) => leader.id);
|
||||
|
||||
void test('ordinary eligibility retains all five living native discs without mutating custody', () => {
|
||||
const g = fixture();
|
||||
g.advanced = false;
|
||||
const owner = seat(g, 'atreides');
|
||||
const before = structuredClone(g);
|
||||
const first = moritaniTerrorAssassinationPool(g, owner.id);
|
||||
assert.deepEqual(ids(first), ['atreides-0', 'atreides-1', 'atreides-2', 'atreides-3', 'atreides-4']);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('native discs controlled elsewhere are excluded and the captor receives the actual foreign disc', () => {
|
||||
const g = fixture();
|
||||
const native = seat(g, 'atreides').leaders;
|
||||
native[0].capturedBy = 'harkonnen';
|
||||
native[1].gholaBy = 'tleilaxu';
|
||||
const before = structuredClone(g);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'atreides')), ['atreides-2', 'atreides-3', 'atreides-4']);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [
|
||||
'atreides-0', 'harkonnen-0', 'harkonnen-1', 'harkonnen-2', 'harkonnen-3', 'harkonnen-4',
|
||||
]);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), [
|
||||
'atreides-1', 'tleilaxu-0', 'tleilaxu-1', 'tleilaxu-2', 'tleilaxu-3', 'tleilaxu-4',
|
||||
]);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('capture overrides foreign-Ghola control without moving or copying the native physical disc', () => {
|
||||
const g = fixture();
|
||||
const ghola = seat(g, 'atreides').leaders[2];
|
||||
ghola.gholaBy = 'tleilaxu';
|
||||
ghola.capturedBy = 'harkonnen';
|
||||
ghola.concealed = { captor: 'harkonnen', controller: 'tleilaxu', dead: false, deaths: 1 };
|
||||
ghola.deaths = 1;
|
||||
ghola.usedAt = 'carthag';
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'tleilaxu')), ids(seat(g, 'tleilaxu').leaders));
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [ghola.id, ...ids(seat(g, 'harkonnen').leaders)]);
|
||||
});
|
||||
|
||||
void test('used living discs remain eligible while dead native and foreign discs do not', () => {
|
||||
const g = fixture();
|
||||
const native = seat(g, 'harkonnen').leaders;
|
||||
native.forEach((leader) => { leader.dead = true; leader.deaths = 2; });
|
||||
native[3].dead = false;
|
||||
native[3].usedAt = 'arrakeen';
|
||||
const foreign = seat(g, 'atreides').leaders[0];
|
||||
foreign.capturedBy = 'harkonnen';
|
||||
foreign.dead = true;
|
||||
foreign.deaths = 1;
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [native[3].id]);
|
||||
foreign.dead = false;
|
||||
foreign.usedAt = 'carthag';
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')), [foreign.id, native[3].id]);
|
||||
});
|
||||
|
||||
void test('the shared Duke is included once for actual server control, never merely for Ecaz identity', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani');
|
||||
g.dukeVidal.leader.usedAt = 'arrakeen';
|
||||
const before = structuredClone(g);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')),
|
||||
[...ids(seat(g, 'moritani').leaders), g.dukeVidal.leader.id]);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('a genuinely captured shared Duke follows the native captor rather than his original tenure', () => {
|
||||
const g = fixture();
|
||||
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal, 'moritani', 2, 'moritani'),
|
||||
'harkonnen', 'moritani', 2, 'arrakeen');
|
||||
const before = structuredClone(g);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'moritani')), ids(seat(g, 'moritani').leaders));
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'harkonnen')),
|
||||
[...ids(seat(g, 'harkonnen').leaders), g.dukeVidal.leader.id]);
|
||||
assert.deepEqual(g, before, 'quoting does not reveal the captive or change tenure');
|
||||
});
|
||||
|
||||
void test('empty actual control returns an empty array without changing hidden custody or requiring a victim', () => {
|
||||
const g = fixture();
|
||||
for (const leader of seat(g, 'atreides').leaders) {
|
||||
leader.dead = true;
|
||||
leader.deaths = 1;
|
||||
}
|
||||
const concealed = seat(g, 'atreides').leaders[0];
|
||||
concealed.dead = false;
|
||||
concealed.capturedBy = 'harkonnen';
|
||||
concealed.concealed = { captor: 'harkonnen', controller: 'atreides', dead: false, deaths: 1 };
|
||||
const before = structuredClone(g);
|
||||
assert.deepEqual(moritaniTerrorAssassinationPool(g, 'atreides'), []);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('set-aside and dead Duke do not receive faction fallback control', () => {
|
||||
const g = fixture();
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
||||
g.dukeVidal = acquireDuke(g.dukeVidal, 'ecaz', 1, 'ecaz');
|
||||
g.dukeVidal.leader.dead = true;
|
||||
g.dukeVidal.leader.deaths = 1;
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'ecaz')), ids(seat(g, 'ecaz').leaders));
|
||||
});
|
||||
|
||||
void test('the actual optional Auditor participates without replacing any of the native five', () => {
|
||||
const g = fixture();
|
||||
const choam = seat(g, 'choam');
|
||||
const ordinary = [...choam.leaders];
|
||||
const auditor = createAuditorLeader();
|
||||
auditor.usedAt = 'sietch_tabr';
|
||||
choam.leaders.push(auditor);
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), [...ids(ordinary), auditor.id]);
|
||||
assert.equal(moritaniTerrorAssassinationValue(auditor), 2);
|
||||
auditor.dead = true;
|
||||
auditor.deaths = 1;
|
||||
assert.deepEqual(ids(moritaniTerrorAssassinationPool(g, 'choam')), ids(ordinary));
|
||||
g.advanced = false;
|
||||
assert.throws(() => moritaniTerrorAssassinationPool(g, 'choam'));
|
||||
});
|
||||
|
||||
void test('printed bounty preserves Zoal three and shared Duke six, not copied battle values', () => {
|
||||
const zoal = leaders('tleilaxu')[0];
|
||||
const opponent = leaders('fremen')[0];
|
||||
assert.equal(battleLeaderStrength(zoal, opponent), 7);
|
||||
assert.equal(moritaniTerrorAssassinationValue(zoal), 3);
|
||||
assert.equal(moritaniTerrorAssassinationValue(createDukeVidal().leader), 6);
|
||||
assert.throws(() => moritaniTerrorAssassinationValue({ ...zoal, strength: 7 }));
|
||||
});
|
||||
|
||||
void test('missing, duplicated, extra and altered physical native discs reject rather than distort probability', () => {
|
||||
const changes: ((g: TerrorFixture) => void)[] = [
|
||||
(g) => { seat(g, 'atreides').leaders.pop(); },
|
||||
(g) => { seat(g, 'atreides').leaders[1] = { ...seat(g, 'atreides').leaders[0] }; },
|
||||
(g) => { seat(g, 'atreides').leaders.push({ ...seat(g, 'atreides').leaders[0] }); },
|
||||
(g) => { seat(g, 'atreides').leaders[0].strength = 99; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].name = 'Invented disc'; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].faction = 'ecaz'; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].id = 'atreides-invented'; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].controller = 'ecaz'; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].capturedBy = 'missing-seat'; },
|
||||
(g) => { seat(g, 'atreides').leaders[0].gholaBy = 'ecaz'; },
|
||||
(g) => { g.players[1].id = g.players[0].id; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const g = fixture();
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => moritaniTerrorAssassinationPool(g, 'atreides'));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('shared-Duke roster copies, altered identity and forbidden foreign-Ghola sources reject', () => {
|
||||
const changes: ((g: TerrorFixture) => void)[] = [
|
||||
(g) => { seat(g, 'ecaz').leaders.push(g.dukeVidal.leader); },
|
||||
(g) => { seat(g, 'ecaz').leaders[0] = { ...g.dukeVidal.leader }; },
|
||||
(g) => { g.dukeVidal.leader.id = 'fake-duke'; },
|
||||
(g) => { g.dukeVidal.leader.strength = 7; },
|
||||
(g) => { g.dukeVidal.leader.gholaBy = 'tleilaxu'; },
|
||||
(g) => { g.dukeVidal.controller = 'missing-seat'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const g = fixture();
|
||||
change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => moritaniTerrorAssassinationPool(g, 'ecaz'));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
@@ -150,7 +150,7 @@ void test('a privately executed return remains a Tanks resurrection without leak
|
||||
validateNexusEcazDuke(g, receipt, 0);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen admission is only the existing standalone no-Skills profile, not a broader Duke exception', () => {
|
||||
void test('Advanced Harkonnen Duke support stays inside original no-Skills E3 profiles rather than lifting Skills guards', () => {
|
||||
const g = position();
|
||||
assert.equal(nexusEcazDukeModeSupported(g), true);
|
||||
assert.equal(quoteEcazDukeAcquisition(g, 'e').source, 'ecaz');
|
||||
@@ -160,7 +160,6 @@ void test('Advanced Harkonnen admission is only the existing standalone no-Skill
|
||||
assert.throws(() => resolveEcazDukeRevival(g, 'h'));
|
||||
for (const change of [
|
||||
(saved: Game) => { saved.leaderSkills = {} as Game['leaderSkills']; },
|
||||
(saved: Game) => { saved.players[2].faction = 'moritani'; },
|
||||
]) {
|
||||
const excluded = structuredClone(g);
|
||||
change(excluded);
|
||||
|
||||
@@ -2,9 +2,8 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { acquireDuke } from '../game/duke-vidal';
|
||||
import { drawNexusCard } from '../game/nexus-cards';
|
||||
import { nexusEcazDukePosition as position } from './fixture-nexus-ecaz-duke';
|
||||
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { nexusReady } from './fixture-nexus-cards';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
@@ -65,23 +64,6 @@ void test('a stale offer, busy table, invalid retained Tanks custody, captured o
|
||||
reject(busy, 'p', action);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen battle lock rejects native Cunning before spending its card', () => {
|
||||
const game = nexusTraitorFixture({ ownerFaction: 'ecaz', opponentFaction: 'moritani',
|
||||
observerFaction: 'harkonnen', advanced: true, phase: 6 });
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const old = cards.hands.p!;
|
||||
cards.hands.p = null;
|
||||
cards.deck.push(old);
|
||||
const index = cards.deck.indexOf('ecaz');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(...cards.deck.splice(index, 1));
|
||||
game.nexusCards!.cards = drawNexusCard(cards, 'p', game.players, () => 0);
|
||||
game.dukeVidal = acquireDuke(game.dukeVidal!, 'q', game.turn, 'moritani');
|
||||
const offer = viewGame(game, 'p').nexusEcazDuke!;
|
||||
assert.match(offer.blocked!, /Advanced Harkonnen/);
|
||||
reject(game, 'p', { type: 'nexusEcazDuke', event: offer.event });
|
||||
assert.equal(game.nexusCards!.cards!.hands.p, 'ecaz');
|
||||
});
|
||||
|
||||
void test('saved Ecaz Duke receipts detect changed historical ownership, event and missing physical spend', () => {
|
||||
const { game, action } = position();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
@@ -147,14 +147,6 @@ void test('original paired E3 Basic/Advanced two-to-six-seat setup conserves eca
|
||||
}
|
||||
const basic = setup({ advanced: false, skills: true, roster: ['ecaz', 'moritani', 'harkonnen'] });
|
||||
assert.equal(player(basic.afterSetup, 'harkonnen').traitors.length, 4); inventory(basic.afterSetup);
|
||||
let blocked = createGame('PAIRHARKGUARD', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(blocked, newPlayer('m', 'Moritani', 'moritani'));
|
||||
joinGame(blocked, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
for (const p of blocked.players) blocked = applyAction(blocked, p.id, { type: 'ready' });
|
||||
blocked.nexusCards = { cards: null, phase: null };
|
||||
const before = structuredClone(blocked);
|
||||
assert.throws(() => initializePairedNexusGameForAudit(blocked));
|
||||
assert.deepEqual(blocked, before, 'Rejected capture composition cannot redeal original pieces.');
|
||||
});
|
||||
|
||||
void test('original paired E3 skill offers reject shared Duke and foreign training immutably', () => {
|
||||
|
||||
@@ -46,10 +46,10 @@ async function main() {
|
||||
'\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.',
|
||||
'\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Existing Homeworlds may be retained. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, expansion families outside their documented entries, unrelated previews, unresolved effects and public starts remain separate.',
|
||||
'\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.',
|
||||
'\nNexus additionally admits standalone Ecaz OR Moritani with classics and the original ecaz33/all12 deck without Skills, Basic/Advanced2–6, optionally original Homeworlds, Discovery through --discoveries, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup and qualifying end-Spice closing draw remain; no starting Nexus deal or training is added. Ecaz Arrakis Occupy and quiet living-Duke Cunning retain their own boundaries; an otherwise legal Advanced Ecaz/Harkonnen roster does not lift the separate Cunning exclusion. Advanced Moritani excludes Harkonnen and uses original post-loss assassination/normal-call forfeiture, private replacement and supply-only Terror Cunning. E3 pairs/mixed families, native free-return/relocation rulings, unrelated previews, public starts and save conversion stay separate.',
|
||||
'\nNexus admits standalone Ecaz OR Moritani with classics and original ecaz33/all12 without Skills, Basic/Advanced2–6, optionally Homeworlds, --discoveries, Tech3+ and Advanced Strongholds2+. Original setup and qualifying closing draws remain; no starting Nexus hand or training is added. Advanced Harkonnen now composes original capture, Moritani end-movement captive-Duke acquisition and printed-faction Assassinate, plus the provisional effective-control Terror pool/public-victim/spent-empty policy. Ecaz keeps its Ambassador exclusions and exclusive Duke revival. Skills/Harkonnen, unrelated previews, public starts and played-save conversion remain separate.',
|
||||
'\nNexus additionally admits exactly one native Ixians OR Tleilaxu/Ix47, or CHOAM OR Richese/CHOAM35 with its original cache, plus classic opponents, Basic/Advanced2–6. Original Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds2+ may remain; leader-skills --nexus-cards retains all14 original offers. Ix offer/HMS, Tleilaxu Face Dancers, CHOAM Auditor and signed Richese markers remain physical. Original paired entries require both natives; additional exact mixed/paired E3 compositions are below. Public starts, played-save conversion and pending effect rulings remain separate.',
|
||||
'\nThe original Nexus and Skills/Nexus entries additionally admit natives from BOTH selected Ix+CHOAM families with classics and union47 Treachery Cards, or BOTH Ecaz+Moritani with classics/ecaz33, Basic/Advanced2–6. Optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+ retain original setup and consumers. Richese cache stays separate; paired E3 preserves ordinary native training, untrained shared Duke, Arrakis Occupy and Advanced assassination, excluding Advanced Harkonnen. Existing mixed-family free-return/companion, own concealed-plan, Sandtrout, low-income/cache-count, shared/captured training and Terror relocation questions remain guarded. No public starts or played-save conversion.',
|
||||
'\nThe same original Nexus/Skills-Nexus entries now compose selected Ecaz plus Ix and/or CHOAM, with at least one actual native from EVERY selected family and optional classic opponents: two-family mixed E3 or all-three-family tables, Basic/Advanced2–6, all12 Nexus/all14 only with Skills, optional original Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+. Ordinary Ix47 if selected, otherwise CHOAM35, and the seated native Richese cache stay physical. Fresh Ecaz gets original Arrakis Occupy; Advanced native Moritani gets original assassination against supported ordinary printed native opponents. The shared Duke never trains; Advanced Skills/capture and Moritani/Harkonnen, Sandtrout, own mixed markers, unusual Gholas and pending force/payment effects retain their guards. Original hands/offers, native setup, actual closing draw and winner/cleanup/Tech timing are reused. No new initializer, return adapter, public start or played-save conversion.',
|
||||
'\nOriginal Nexus and Skills/Nexus additionally admit natives from BOTH selected Ix+CHOAM families with classics/union47, or BOTH Ecaz+Moritani with classics/ecaz33, Basic/Advanced2–6. Optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+ retain original consumers. No-Skills paired E3 now composes Advanced Harkonnen and captured-Duke/Assassination paths; all14 Skills keeps its Harkonnen/shared-training exclusions. Richese cache stays separate. Existing mixed-marker, unusual-Ghola, supply/relocation and other effect guards remain; no public start or played-save conversion.',
|
||||
'\nThe same original Nexus/Skills-Nexus entries compose selected Ecaz plus Ix and/or CHOAM, with an actual native from EVERY selected family and optional classics: two-family mixed E3 or all-three-family, Basic/Advanced2–6, all12 Nexus/all14 only with Skills and original optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+. Original selected decks/cache, offers and setup remain. No-Skills Moritani/Harkonnen now uses original capture, shared-Duke and printed-faction assassination; Skills/Harkonnen and exceptional target guards remain. Shared Duke never trains; foreign-Duke revival remains forbidden. No public start or played-save conversion.',
|
||||
'\nLeader-skills --nexus-cards adds all12 Nexus to original classic/base33, single or paired one-family E1/E2, both selected Ix+CHOAM/union47, standalone or paired E3/ecaz33, or mixed E3/two-or-three selected families with an actual native from each and classics. All14 original offers and Basic/Advanced2–6 remain; Homeworlds may be retained, Tech3+/StrongholdsAdvanced. Actual Ix pre-training hand, ordinary discs and native components remain. Each capture/exceptional shared-Duke, Banker preview, borrowed Smuggler/companion/native-return and pending-effect boundary remains; no public start or played conversion.',
|
||||
'\nThese classic, single-native or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.',
|
||||
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
|
||||
|
||||
Reference in new issue
Block a user