Implement the five 7 October pending rule rulings

The user answered five recorded questions; each ruling is indexed in
RULE_DECISIONS.md and implemented with a focused regression.

- CHOAM opening income counts as a qualifying collection, so low-population
  Tupile adds its bank increment under the Inflation multiplier. The engine
  guard is removed and the projection/notice expose the added bonus.
- An exhausted Richese cache is legal and restores one ordinary lot; the
  canceled/prevented compulsory cache auction branch restores the reduction
  before the ordinary pool runs and the cache offer is skipped when no cache
  lot can run. The pure continuation quote mirrors the count.
- Suk-rescued casualties count toward Advanced Atreides' seven Kwisatz
  Haderach losses: the battle-loss counter gains the full dialed allocation in
  both rescue paths, and the earlier Suk availability restriction is removed.
- The Guild Nexus Secret Ally reserve return overrides the Arrakis-to-native-
  Homeworld restriction for the holder's own reserves and deposits through the
  shared native-reserve pipeline (generalized from the revival deposit).
- Interior passengers count as entering the territories a relocated Ixian HMS
  points into, so Sandmaster collects there before the native collection.

Full offline suite 7804/7804, types and lint pass. Historical evidence and
superseded test expectations are updated to the ruled behaviour; preserved
combined Homeworld/Nexus, occupation and Richese captures are unblocked but
not re-run here. No existing game, seat or saved state was changed.
This commit is contained in:
Quintine committed 2026-10-07 21:56:17 +11:00
1 parent e0101a937b
commit 7e3bec2721
41 files changed
+525 -234

No files matched your search

+9 -3
View File
@@ -2358,9 +2358,15 @@ export function GameTable({
)}
{g.response.kind === 'choamCharity' && (
<p className="muted">
CHOAM receives {2 * g.players.length * g.charity.multiplier}{' '}
spice before anyone claims charity. Karama cancels this income
and sends this turn’s charity payments to the Spice Bank.
CHOAM receives{' '}
{2 * g.players.length * g.charity.multiplier +
g.charity.incomeHomeworld * g.charity.multiplier}{' '}
spice before anyone claims charity
{g.charity.incomeHomeworld > 0
? ', including the low-population Homeworld bank bonus'
: ''}
. Karama cancels this income and sends this turn’s charity
payments to the Spice Bank.
</p>
)}
{g.response.kind === 'mobileStronghold' && g.mobileRoute && (
+28 -1
View File
@@ -17,6 +17,33 @@ open mode gates.
## Current checkpoint and work
**7 October 2026 — five pending rulings resolved by the user.** The user
answered five recorded questions; each is now recorded in
[the decision index](RULE_DECISIONS.md#user-rulings--7-october-2026) and
implemented:
- CHOAM's opening income counts as a qualifying collection, so low-population
Tupile adds its bank increment under the Inflation multiplier
(`game/engine.ts`, charity projection and controls).
- A Richese exhausted cache is legal and a canceled/prevented compulsory cache
auction restores one ordinary lot (`game/richese-settlement.ts`,
`game/auction-continuation-quote.ts`, `game/engine.ts`).
- Suk-rescued casualties count toward Advanced Atreides' seven Kwisatz Haderach
losses, and the earlier Suk availability restriction is removed.
- The Guild Nexus Secret Ally reserve return overrides the Arrakis-to-native-
Homeworld restriction for the holder's own reserves, depositing through the
shared native-reserve pipeline (`game/nexus-guild-secret-ally-options.ts`,
`game/engine.ts`).
- Interior passengers count as entering the territories a relocated Ixian HMS
points into, so Sandmaster collects there before the native collection
(`game/sandmaster-movement.ts`, `game/engine.ts`).
The remaining recorded questions (Bureaucrat/Emperor gifts, Recruits late
accounting, ordinary Guild cancellation, base special Karama, Nexus
composition, Orgiz, Testing Station, Jacurutu, occupied Homeworlds and the
Homeworld restoration questions) stay pending. Preserved combined
Homeworld/Nexus, occupation-profile and Richese captures can now be resumed
against the rulings; resumption itself is not yet re-run here.
**In progress — mixed E3 and three-family Nexus.** Original selected Ecaz
plus Ix and/or CHOAM now requires an actual native from every selected
family, with optional classics: Basic/Advanced2–6/all12 Nexus, optionally
@@ -2630,7 +2657,7 @@ These links define bounded working behavior, not complete module certification.
| --- | --- |
| Basic core | Ordinary Guild repricing/transport settlement, broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. |
| Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. The user-authorized unfinished preview is available; setup matrices and complete samples do not certify full compliance. |
| Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. Detailed boundaries remain in the checklist/decision index. |
| Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. The 7 October 2026 rulings settled CHOAM's low-population opening income, the Richese exhausted/canceled cache count, Suk/Kwisatz loss counting, the Guild Secret Ally Homeworld reserve return and Sandmaster HMS-relocation eligibility; the preserved captures can be resumed. Detailed boundaries remain in the checklist/decision index. |
| AI | Maintain minimal legal participation and critical correctness fixes while game features are unfinished. Full implementation, strategy refinement and calibration wait until all non-AI features are complete; then target approximately 75% higher-tier wins for Medium/Easy, Hard/Medium and Brutal/Hard. See the [AI development plan](AI_DEVELOPMENT_PLAN.md). These targets remain unverified. |
| Multiplayer | Broader disconnected/abandoned-seat recovery, unattended continuation and network-failure acceptance, while preserving authoritative versioning, custody, privacy and saves. |
| Administration | Complete the [admin panel](ADMIN_PANEL.md): permissions, room directory and creation/removal, lifecycle controls, participant support, backup/restore and operational audit tools. Required before full AI refinement. |
+1 -1
View File
@@ -29,4 +29,4 @@ An isolated Chrome QA room, `4K3HXJ6D`, passed desktop (1440×1000) and phone (3
The prior development server was absent. A fresh read-only snapshot and SQLite backup preserved **2,648 existing rooms** before localhost startup; all versions and state hashes were unchanged afterward. The server returned HTTP 200. No database reset or recurring automation was used.
Pending material interpretations remain CHOAM's own low-population opening income, the Tleilaxu special-Karama/Homeworld overlap, and Ix high-population Suboid bonus quota/shortage behavior. Invasions, Homeworld battles, occupation, remaining card effects, full module combinations and complete games are unfinished. Keep public Homeworld/Advanced/unfinished expansion starts disabled.
Material interpretations now settled by the 7 October 2026 user ruling include CHOAM's own low-population opening income (it counts as a qualifying collection). Pending material interpretations remain the Tleilaxu special-Karama/Homeworld overlap and Ix high-population Suboid bonus quota/shortage behavior. Invasions, Homeworld battles, occupation, remaining card effects, full module combinations and complete games are unfinished. Keep public Homeworld/Advanced/unfinished expansion starts disabled.
+1 -1
View File
@@ -22,7 +22,7 @@ Actual Ix HIGH and Tleilax LOW cards were visually rechecked in the [four-sheet
- **Fremen alliance:** apply the granted rate of three, then the recipient's own low bonus. The donor's low population does not turn its printed alliance grant into four. Canceling the alliance benefit removes that source, not the recipient's independent Homeworld increment. This follows the distinct grant and native modifier; it does not add an ordinary Karama cancellation of every faction's printed free rate.
- **Charity:** establish a valid collection first. Multiply ordinary charity and the conditional low bonus by Inflation, while keeping the bonus bank-paid. At zero spice, ordinary low charity is two from its ordinary payer plus one bank; Double makes four plus two. At one spice it is one plus one, or two plus two. Cancel produces no collection and no bonus. Advanced BG's ordinary amount is two regardless of wealth; a canceled BG charity claim similarly has no qualifying collection. These are cross-source compositions, not located worked examples.
**CHOAM's own income needs an explicit integration decision.** Its initial two-per-faction payment is named the Charity advantage, and E2 expressly includes it in Inflation. This supports treating it as a qualifying collection for Tupile's low bank increment, but that parenthetical does not explicitly adjudicate other modifiers. Conversely, limiting the Homeworld bonus to ordinary poverty claims would give low CHOAM no increment on its usual initial income. No retrieved primary passage resolves that distinction. Preserve the ordinary claim and faction-income sources rather than accidentally awarding twice or silently excluding CHOAM.
**CHOAM's own income — resolved 7 October 2026 by user ruling.** Its initial two-per-faction payment is named the Charity advantage, and E2 expressly includes it in Inflation. The user ruled that it **counts as a qualifying collection** for Tupile's low bank increment, so the low-population bonus is added under the same Inflation multiplier as ordinary charity. The engine no longer blocks low-population CHOAM opening income.
## Keep cancellation sources distinct
+45
View File
@@ -1,5 +1,50 @@
# Dune implementation status
## 7 October 2026 — five pending user rulings implemented
The user resolved five recorded rule questions; each is indexed in
`RULE_DECISIONS.md#user-rulings--7-october-2026` and implemented with a focused
regression:
- **CHOAM low-population opening income.** CHOAM's phase-opening income now
counts as a qualifying collection, so a low-population native Homeworld adds
its bank increment under the same Inflation multiplier as ordinary charity.
The previous `requireRule` guard is removed; the projection and the CHOAM
income notice expose the added bonus. `tests/homeworld-charity-engine.test.ts`
compares low and non-low Tupile with and without Inflation.
- **Richese exhausted/canceled cache.** `requireRicheseDeclarationCache` now
rejects only a malformed or negative count, and an exhausted cache runs the
ordinary pool at its full declared count. A canceled/prevented compulsory
cache auction sets `cacheCanceled`, restores one ordinary lot and runs the
pool; the cache offer is skipped whenever no cache lot can run, and the pure
continuation quote mirrors that. `tests/richese-settlement-preflight.test.ts`
asserts the one-lot restoration, and
`tests/nexus-richese-betrayal-engine.test.ts` asserts an exhausted-cache
Black Market sale now completes.
- **Suk Graduate / Kwisatz Haderach.** Rescued counters count toward Advanced
Atreides' seven battle losses: the battle-loss counter gains the full dialed
casualty allocation in both the ordinary and Homeworld rescue paths, and the
earlier Suk availability restriction (assignment block, battle preflight and
`unavailableSkills` projection) is removed. `tests/suk-graduate.test.ts` and
`tests/advanced-native-skills.test.ts` cover assignment and loss counting.
- **Guild Secret Ally Homeworld reserve return.** The reserve-return grant now
overrides the Arrakis-to-native-Homeworld restriction for the holder's own
reserves. The return deposits through the shared native-reserve pipeline
(`addNativeReserves`/`quoteNativeReserveDeposit`, generalized from the revival
deposit), so Homeworld custody stays conserved.
`tests/nexus-guild-secret-ally-engine.test.ts` returns a staged Emperor group
with Homeworld custody and `tests/nexus-guild-secret-ally-controls.test.ts`
updates the route expectation.
- **Sandmaster / native HMS relocation.** Interior passengers count as entering
the territories the stronghold points into, so the Sandmaster skill collects
one spice per entered territory with an unambiguous pile before the faction's
own traversed-sector collection. `tests/sandmaster-movement.test.ts` compares
an identical relocation with and without the assigned skill.
The related union passes; types and lint are green. Preserved combined
Homeworld/Nexus, occupation-profile and Richese captures are now unblocked but
have not been re-run here.
## 7 October 2026 — distinct Mentat replacement and reproducible suites
`replaceMoritaniAssassinationTraitors` now draws the first **distinct** physical
+1 -3
View File
@@ -32,9 +32,7 @@ Native Homeworld custody can represent the same outgoing reserves without creati
**Price and permission are separate.** E3 already permits world-to-world shipment by non-Guild factions, with a one-spice-per-force tariff and Guild half price. The card's Guild-rate clause therefore supports `ceil(n / 2)` for an independently legal world-to-world invasion. This price composition borrows no high-Junction advantage and grants no new destination. Preserve the source custody and own/ally-world restrictions. [GF9 E3, p10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
**Arrakis to native reserves remains a material interpretation question when Homeworlds are enabled.** Reserves occupy native Homeworld discs; E3 expressly restricts Arrakis-to-Homeworld shipment to Guild, with a named Homeworld-advantage exception. The Nexus expressly grants reserve return. A card-specific exception is plausible, especially because it already exceeds normal alliance rights. Conversely, E3's specific Homeworld restriction can be read as limiting that grant. Its Nexus FAQ resolves neither reading. [GF9 E3, pp9–11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
The concrete question is whether Secret Ally's reserve-return permission overrides that restriction **for the holder's own native reserves**. An affirmative answer does not grant Arrakis-to-arbitrary-foreign-Homeworld transport. Until resolved, expose this particular return as unfinished; ordinary Arrakis routes and independently legal world-to-world discounted shipment need not be blocked.
**Arrakis to native reserves — resolved 7 October 2026 by user ruling.** Reserves occupy native Homeworld discs; E3 expressly restricts Arrakis-to-Homeworld shipment to Guild, with a named Homeworld-advantage exception. The Nexus expressly grants reserve return. The user ruled that the Secret Ally's reserve-return permission **overrides that restriction for the holder's own native reserves**. It still does not grant Arrakis-to-arbitrary-foreign-Homeworld transport; ordinary Arrakis routes and independently legal world-to-world discounted shipment are unchanged. The engine now routes the return through the native-reserve deposit pipeline so Homeworld custody stays conserved.
## Implementation boundaries
+1 -1
View File
@@ -12,7 +12,7 @@ Fremen can retain their ordinary free reinforcement option. Choosing the Nexus i
For an independently legal world-to-world shipment, the Nexus changes only the price to half a spice per force, rounded upward. Actual foreign/native source custody, special counters, own-world return restriction and ally-world prohibition remain authoritative. The card does not grant arbitrary Arrakis-to-foreign-Homeworld transport or high-Junction sponsorship.
The material interaction between the card's return to reserves and the Homeworld native-Guild route restriction has been asked of the user. Until resolved, returning to Homeworld reserves with this grant is unavailable. Concealed No-Field transport retains its existing separate implementation boundary.
**Resolved 7 October 2026:** the user ruled that the card's return to reserves overrides the Homeworld native-Guild route restriction for the holder's own reserves, so returning to Homeworld reserves with this grant is available and deposits through the shared native-reserve pipeline. Concealed No-Field transport retains its existing separate implementation boundary.
## Payment, arrival and saved history
+1 -1
View File
@@ -66,7 +66,7 @@ For Black Market with ordinary bidding, preserve the ordinary opener progression
These questions cannot be silently represented as settled FAQ compliance:
1. **Canceled compulsory lot and an exhausted cache.** The start-of-round reduction and the permission to prevent the cache auction are both printed, but the cancellation table does not say whether a replacement ordinary lot is restored. Nor does it prescribe exhausted-cache or zero-eligible-player arithmetic. Preserve separate count facts so an eventual ruling changes one policy rather than corrupting custody. Never draw a negative count.
1. **Canceled compulsory lot and an exhausted cache — count resolved 7 October 2026 by user ruling.** The start-of-round reduction and the permission to prevent the cache auction are both printed, and the cancellation table does not say whether a replacement ordinary lot is restored. The user ruled that when no cache lot can run — the cache is exhausted, or the compulsory cache auction is canceled/prevented — **one ordinary lot is restored**, so the ordinary pool runs its full declared count rather than the cache-reduced count. `requireRicheseDeclarationCache` now accepts an exhausted cache (only a malformed or negative count is rejected), and the `richeseAuction` cancellation branch already restores the reduction before the ordinary pool runs. The Karama **authorization** to cancel the compulsory cache auction itself is still gated in `validateKaramaUse` (`richeseAuction` remains non-cancelable), so that branch is the ruled policy awaiting that separate feature rather than a reachable path.
2. **Black Market count wording.** Its specific rule conditions reduction on a sale. The embedded Ixian answer refers more broadly to cards put up for auction. The more specific sale condition supports no reduction for an unsold/canceled Black Market offer. Record that composition explicitly; do not decrement merely on declaration.
3. **Self-purchase of a Black Market card.** The generic alternative methods include Richese, but Black Market says Richese receives all payments and already owns the card. No retrieved paragraph specifies seller exclusion, a self-payment loop, an Emperor payment, or whether self-retention is a sale reducing the normal pool. Do not borrow the cache self-payment exception without a ruling.
4. **Ixian advanced Technology on special lots.** I20 p.9 broadly applies before bidding on a card and before Atreides inspection. R2's explicit restriction concerns the _ally's post-purchase replacement_, not Technology. Consequently that FAQ cannot justify disabling Technology on special lots. No retrieved Richese paragraph explains an exchanged cache lot's new public face, or the custody of a substituted Black Market lot if nobody bids. This is a genuine interaction to resolve before certifying the combined implementation.
+21 -4
View File
@@ -1,6 +1,26 @@
# Rule decision index
Updated 2 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 7 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.
## User rulings — 7 October 2026
Five previously pending questions were answered directly by the user. These are
recorded user rulings, not located publisher passages; the source documents
below retain the passages that left each question open.
| Boundary | Ruling | Superseded question record |
| --- | --- | --- |
| CHOAM opening income at low-population Tupile | CHOAM's opening income (`2 × players`) counts as a qualifying collection for Tupile's low-population bank increment: add `homeworldLowBonus × Inflation multiplier` exactly as ordinary poverty charity does, instead of leaving the engine guard. | [Homeworld benefits](HOMEWORLD_BENEFITS_RULES.md) |
| Richese exhausted/canceled cache count | When no cache lot can run — the cache is exhausted, or the compulsory cache auction is canceled/prevented — one ordinary auction lot is restored, so the ordinary pool runs its full declared count rather than the cache-reduced count. | [Richese auction boundaries](RICHESE_AUCTION_RULES.md#explicit-source-boundaries) |
| Suk Graduate / Atreides | Counters rescued by Suk Graduate count toward the seven force losses that activate Kwisatz Haderach (raw dialed casualties), so Suk is no longer withheld in Advanced Atreides. | [Suk Graduate rules](SUK_GRADUATE_RULES.md#atreides-loss-count-question) |
| Guild Secret Ally / Homeworlds | The Nexus Secret Ally reserve-return grant overrides the Arrakis-to-native-Homeworld restriction for the holder's **own** native reserves only; arbitrary foreign-Homeworld transport stays unavailable. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return) |
| Sandmaster / native HMS relocation | Forces remaining inside a relocated HMS interior count as entering the outside territories the stronghold points into, so Sandmaster grants its per-entered-territory collection for those territories. | [Native HMS relocation eligibility](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) |
Each ruling changes only the specific combination named. Ordinary behaviour the
sources already established (ordinary charity, ordinary Richese auction counts,
non-rescue losses, ordinary Arrakis routes and ordinary Sandmaster entry/exit)
is unchanged. Implementation, guidance and tests follow the ruling; the retained
documents above keep their original source analysis.
## Single-native E1/E2 Nexus composition — 7 October 2026
@@ -377,15 +397,12 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Reinforcements cost | The user question still asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost; no answer is assumed. The bounded classic and Ecaz/Moritani prototype provisionally charges on all revealed outcomes, chooses normal reserves before elite and counts the transfer as battle losses. Those are product interpretations, not a ruling. Same-plan Harass/Stone Burner remain gated; opposing Harass is supported. Eligibility never depends on concealed matching Traitors. | [Source cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Bounded runtime](REINFORCEMENTS_RUNTIME.md) |
| Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) |
| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
| Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) |
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Other discard producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifier/allocation boundaries, Mentat empty-hand fallback, modified Diplomat defenses and simultaneous optional timing remain source gaps. After an online publisher/designer/tournament check found no explicit priority ruling, the bounded standalone Diplomat retreat prototype explicitly chooses printed/copied disc strength and a surviving-loser retreat before ordinary casualties; other combinations remain guarded. This is an implementation interpretation, not an official clarification. | [Material boundaries and selected retreat interpretation](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Diplomat defense boundary](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) |
| Smuggler collection strength | A fresh primary-source check found no modifier ruling. The user was asked whether to keep affected combinations gated, use printed/copied disc value, or include leader bonuses with Stunner zero. No answer is assumed. The reveal-bound deferred receipt is supported as an explicit timing inference; unmodified lower collection is now [prototyped](SMUGGLER_BATTLE.md); modified strength, multiple positive piles and combined modules remain gated. | [Smuggler follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) |
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) |
| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
| Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) |
+7 -4
View File
@@ -93,7 +93,10 @@ allows Karama to prevent HMS relocation and collection together.
The checked card, designer walkthrough and available official rules did not
clarify whether passengers staying in the interior count as entering outside
territories. Skill-before-faction precedence settles ordering only if eligibility
is established. A user ruling question was sent; no answer is assumed. Native
relocation and ordinary Sandmaster entry/exit remain available. No extra
collection is silently applied, and full combined compliance remains unverified.
territories. **Resolved 7 October 2026 by user ruling:** interior passengers
**do count as entering** the outside territories the stronghold points into, so
the Sandmaster skill collects one spice per entered territory with an
unambiguous pile during a native relocation, before the faction's own
traversed-sector collection. Skill-before-faction precedence settles the
ordering. Ordinary Sandmaster entry/exit and native relocation are unchanged;
full combined compliance remains unverified.
+8 -11
View File
@@ -127,20 +127,17 @@ Human L7C8HTQT confirms17 Ecaz reserves/0 Tanks/3 board, ally16 reserves/
See [standalone source and evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026).
## Atreides loss-count question
## Atreides loss-count question — resolved 7 October 2026
The base Advanced rules activate Kwisatz Haderach after seven force losses.
Suk describes rescued counters as losses redirected away from the Tanks, and
the designer describes the retained counter as having been killed. Counting
the raw dialed casualties is therefore the stronger textual inference, but
the retrieved publisher/designer sources do not expressly settle this combination.
The user question is pending. New Advanced Atreides setup and revival offers
retain their physical cards but make Suk unavailable, explain why and allow the
other offered card. The server, private view, controls and bots share this
prototype restriction. Already-assigned saved combinations retain the battle
preflight guard before final traitor votes or resources commit. This availability
restriction is not an official rule. Other rescued counters are kept out of the
physical Tank and ordinary battle-loss totals in this bounded prototype.
the designer describes the retained counter as having been killed. The user
ruled that **rescued casualties count toward the seven losses** (raw dialed
casualties), so Suk is no longer withheld from Advanced Atreides: the setup
and revival offers can assign it, and the battle path records the full dialed
casualty allocation on the battle-loss counter rather than only the counters
that reach the Tanks. The earlier prototype restriction (unavailable skill,
battle preflight guard) is removed.
## Evidence and remaining work
+9 -6
View File
@@ -435,15 +435,18 @@ function calculate(
if (index === a.cards.length || !able.length) {
const round = input.richeseBidding,
activeRound = round?.turn === input.turn && round.stage === 'normal';
// User ruling 7 October 2026: the cache lot only runs when physical cache
// cards remain and the compulsory auction was not canceled.
const cacheLast =
!!activeRound &&
round.position === 'last' &&
!round.cacheCanceled &&
(input.richeseCacheCount ?? 0) > 0;
return result({
kind: 'normalEnd',
returned: structuredClone(a.cards.slice(index)),
after:
activeRound && round.position === 'last' && !round.cacheCanceled
? 'richeseCache'
: 'phase',
completeRound:
!!activeRound && !(round.position === 'last' && !round.cacheCanceled),
after: cacheLast ? 'richeseCache' : 'phase',
completeRound: !!activeRound && !cacheLast,
});
}
let opener = a.opener;
+71 -57
View File
@@ -6,7 +6,7 @@ import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-optio
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
import { leaderSkillCard } from './leader-skill-cards';
import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
import {
@@ -21,7 +21,7 @@ import { planetologistMovementModeSupported, planetologistLeader, planetologistR
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectible, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
@@ -164,7 +164,7 @@ import {
} from './homeworld-emperor-move';
import {
quoteNativeReserveWithdrawal,
quoteNativeRevivalDeposit,
quoteNativeReserveDeposit,
NativeReserveError,
type NativeReserveSelections,
} from './homeworld-native-reserves';
@@ -2687,7 +2687,7 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.');
const elite = action.elite as number, before = emperorNexusPools(p);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
addRevivedReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
addNativeReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;}
techIncome(g, 'axlotl', p);
const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4,
@@ -2862,7 +2862,7 @@ function playNexusFremenRevive(g: Game, p: Player, action: Action) {
offer?.blocked ?? 'Choose a current eligible three-force Fremen Nexus return.');
const elite = action.elite as number, before = fremenNexusRevivalPools(p);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
addRevivedReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
addNativeReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite);
p.tanks -= FREMEN_NEXUS_FREE_FORCES;
p.revived += FREMEN_NEXUS_FREE_FORCES;
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES;
@@ -7105,14 +7105,17 @@ function withdrawNativeReserves(
observeOccupation(g);
return result.receipts;
}
function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) {
/** Adds counters to a faction's reserves, depositing into the native Homeworld
* custody when Homeworlds are enabled. Used by revival and by the authorized
* Nexus Secret Ally reserve return (user ruling 7 October 2026). */
function addNativeReserves(g: Game, p: Player, amount: number, elite: number) {
if (!g.homeworlds?.custody) {
p.reserves += amount;
if (p.elites) p.elites.reserves += elite;
return;
}
const result = homeworldRule(() =>
quoteNativeRevivalDeposit(
quoteNativeReserveDeposit(
homeworldContext(g),
g.homeworlds!.custody!,
p.id,
@@ -7597,19 +7600,6 @@ function skillEligibleLeaders(g: Game, owner: string): string[] {
)
.map((leader) => leader.id);
}
const ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK =
'Suk Graduate is unavailable to Advanced Atreides while the Kwisatz Haderach loss-count ruling is pending.';
function leaderSkillAssignmentUnavailable(
g: Game,
owner: string,
skill: string,
): string | null {
return g.advanced &&
getPlayer(g, owner).faction === 'atreides' &&
skill === 'suk-graduate'
? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK
: null;
}
/** Public standalone E3 roster; the existing held-card owner remains the plan actor. */
function e3StrongholdFactionProfile(g: Game): boolean {
if (!g.advanced || g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' ||
@@ -8285,12 +8275,6 @@ function projectedLeaderSkills(
const offer = g.leaderSkills.offers[owner] ?? null;
const eligible = skillEligibleLeaders(g, owner);
const assignment = nativeLeaderSkill(g, owner);
const unavailableSkills = Object.fromEntries(
(offer?.cards ?? []).flatMap((skill) => {
const reason = leaderSkillAssignmentUnavailable(g, owner, skill);
return reason ? [[skill, reason]] : [];
}),
) as Partial<Record<LeaderSkillId, string>>;
return {
assignments: g.leaderSkills.assignments.map((a) => {
const controller = leaderSkillController(g, a);
@@ -8303,7 +8287,6 @@ function projectedLeaderSkills(
};
}),
offer,
...(Object.keys(unavailableSkills).length ? { unavailableSkills } : {}),
eligibleLeaders: getPlayer(g, owner)
.leaders.filter((l) => eligible.includes(l.id))
.map((l) => ({ id: l.id, name: l.name })),
@@ -10762,13 +10745,21 @@ function beginRicheseBidding(g: Game) {
richeseDecision(g, 'richeseBlackMarket');
else richeseDeclaration(g);
}
/** User ruling 7 October 2026: a cache lot runs only while physical cache cards
* remain and the compulsory auction was not canceled; otherwise one ordinary
* lot is restored, so the cache's one-lot reduction is not applied. */
function richeseCacheRuns(g: Game) {
return (g.richeseCache?.length ?? 0) > 0 && !g.richeseBidding!.cacheCanceled;
}
function richeseDeclaration(g: Game) {
richeseDeclarationCache(g);
const round = g.richeseBidding!;
round.stage = 'declaration';
round.normalCount = Math.max(
0,
auctionEligible(g).length - 1 - Number(round.blackMarketSold),
auctionEligible(g).length -
Number(richeseCacheRuns(g)) -
Number(round.blackMarketSold),
);
richeseDecision(g, 'richeseDeclaration');
}
@@ -10784,7 +10775,7 @@ function offerRicheseCache(g: Game) {
function finishNormalBidding(g: Game) {
const round = g.richeseBidding;
if (round?.turn === g.turn && round.stage === 'normal') {
if (round.position === 'last' && !round.cacheCanceled) {
if (round.position === 'last' && richeseCacheRuns(g)) {
offerRicheseCache(g);
return;
}
@@ -11276,7 +11267,7 @@ function decideRichese(g: Game, p: Player, decision: Decision, action: Action) {
round.position = action.position as 'first' | 'last';
log(
g,
`${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
`${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled ${richeseCacheRuns(g) ? 'after the cache reduction' : 'because no cache lot can run'}${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
);
g.response = { kind: 'richeseAuction', owner: p.id, passed: [] };
} else if (decision.kind === 'richeseCacheTerms') {
@@ -20055,7 +20046,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
);
const group = { amount: n, elite, free: 0 };
const grant = requireHomeworldRevivalGrant(g, p, 'ghola', group);
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (p.elites) {
p.elites.tanks -= elite;
@@ -21260,9 +21251,6 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
});
for (const withdrawal of quote.harassWithdraw ?? [])
validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
if (quote.sukGraduate) requireRule(
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
if (quote.sukGraduate) requireRule(
ordinaryLeaderSkillModeSupported(g) &&
(nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
@@ -22154,7 +22142,10 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d
} else {
player.reserves += quote!.reserves.normal + quote!.reserves.elite;
player.tanks += quote!.tanks.normal + quote!.tanks.elite;
player.battleLosses += quote!.tanks.normal + quote!.tanks.elite;
// User ruling 7 October 2026: rescued counters still count toward the seven
// battle losses that activate Kwisatz Haderach, so the full dialed casualty
// allocation is added, not only the counters that reach the Tanks.
player.battleLosses += pending.losses.normal + pending.losses.elite;
if (player.elites) {
player.elites.reserves += quote!.reserves.elite;
player.elites.tanks += quote!.tanks.elite;
@@ -22917,7 +22908,7 @@ function currentHomeworldRevivalGrant(
group: HomeworldRevivalDeploymentGroup,
) {
if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return null;
const deposit = homeworldRule(() => quoteNativeRevivalDeposit(
const deposit = homeworldRule(() => quoteNativeReserveDeposit(
homeworldContext(g), g.homeworlds!.custody!, p.id,
{ normal: group.amount - group.elite, elite: group.elite },
));
@@ -23157,7 +23148,7 @@ function finishRevival(
elite = revival.elite ?? 0;
const source = revival.emperorExtra ? 'emperorExtra' : 'normal';
const group = { amount: n, elite, free: revival.free };
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
@@ -23955,11 +23946,19 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id };
} else if (response.kind === 'richeseAuction') {
requireRule(
!canceled,
'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.',
);
if (g.richeseBidding!.position === 'first') offerRicheseCache(g);
const round = g.richeseBidding!;
if (canceled) {
// User ruling 7 October 2026: a canceled or prevented compulsory cache
// auction restores one ordinary lot, since no cache lot replaces it.
round.cacheCanceled = true;
round.normalCount = Math.max(0, (round.normalCount ?? 0) + 1);
log(
g,
'The compulsory Richese cache auction was canceled; one ordinary lot is restored.',
);
prepareRicheseNormal(g);
} else if (round.position === 'first' && richeseCacheRuns(g))
offerRicheseCache(g);
else prepareRicheseNormal(g);
} else if (response.kind === 'richeseBlackMarket') {
if (canceled) {
@@ -24226,11 +24225,13 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: { turn: g.turn, canceled };
if (!canceled) {
const choam = getPlayer(g, response.owner);
requireRule(
homeworldLowBonus(g, choam.id) === 0,
'Low-population CHOAM opening income awaits its Homeworld charity ruling.',
);
const amount = 2 * g.players.length * charityMultiplier(g);
// User ruling 7 October 2026: CHOAM's phase-opening income is a
// qualifying collection, so a low-population native Homeworld adds its
// bank bonus under the same Inflation multiplier as ordinary charity.
const multiplier = charityMultiplier(g);
const amount =
2 * g.players.length * multiplier +
homeworldLowBonus(g, choam.id) * multiplier;
choam.spice += amount;
log(g, `${choam.name} collected ${amount} spice before charity claims.`);
} else
@@ -25337,8 +25338,9 @@ function commitGuildTransport(
if (p.elites) p.elites.reserves -= elite;
} else removeGroup(p, group, eliteGroup);
if (to === 'reserves') {
p.reserves += amount;
if (p.elites) p.elites.reserves += elite;
// User ruling 7 October 2026: the Nexus Secret Ally reserve return is an
// authorized native-reserve return, so it deposits into Homeworld custody.
addNativeReserves(g, p, amount, elite);
} else {
place(p, to, sector, amount, elite);
if (advisors) {
@@ -25643,7 +25645,22 @@ function relocateMobileStronghold(
const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.');
let collected = 0;
if (move.collect)
if (move.collect) {
// User ruling 7 October 2026: passengers remaining inside a relocated HMS
// interior count as entering the outside territories the stronghold points
// into, so the Sandmaster skill collects there before the faction's own
// traversed-sector collection.
if (sandmasterLeader(g, move.player))
for (const pile of sandmasterCollectible(g, { interior: move.route })) {
g.spice[pile]--;
if (!g.spice[pile]) delete g.spice[pile];
p.spice++;
log(
g,
`${p.name}'s Sandmaster collected 1 spice as the stronghold entered ${territory(splitLocation(pile).territory).name}. This territory grants at most one collection during this relocation.`,
{ faction: p.faction, name: 'Sandmaster collection' },
);
}
for (const key of move.route) {
const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2);
if (amount) {
@@ -25652,6 +25669,7 @@ function relocateMobileStronghold(
collected += amount;
}
}
}
g.mobileStronghold!.location = move.route.at(-1)!;
const loc = splitLocation(g.mobileStronghold!.location);
log(
@@ -27104,7 +27122,7 @@ export function executeSpecialKaramaIntent(
} else {
const n = intent.amount,
elite = intent.elite;
addRevivedReserves(g, p, n, elite);
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (p.elites) {
p.elites.tanks -= elite;
@@ -28243,8 +28261,6 @@ function applyActionInner(
else {
requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.');
const skill = stringField(action.skill);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random));
log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters<typeof leaderSkillCard>[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'});
}
@@ -29847,8 +29863,6 @@ function applyActionInner(
if (t === 'leaderSkill') {
requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.');
const skill = stringField(action.skill), leader = stringField(action.leader);
const unavailable = leaderSkillAssignmentUnavailable(g, id, skill);
requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.');
g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random));
log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters<typeof leaderSkillCard>[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' });
} else if (t === 'advisorSetup') {
@@ -30933,8 +30947,6 @@ function applyActionInner(
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
'Only the Guild may ship forces back to reserves.',
);
requireRule(!guildSecretEvent || to !== 'reserves' || !g.homeworlds?.custody,
'Guild Secret Ally return to Homeworld reserves awaits its route ruling.');
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
const sourceLock = p.advisors?.[origin]?.lockedTurn;
requireRule(
@@ -31735,6 +31747,7 @@ export function viewGame(state: Game, id: string) {
settleAdvisors(g);
normalizeBattle(g);
const me = getPlayer(g, id);
const choamPlayer = byFaction(g, 'choam');
let victoryProgress: StrongholdProgress[] = [];
let fremenVictory: FremenVictoryProgress | null = null;
let ecazHomeworldVictory: EcazHomeworldVictoryProgress | null = null;
@@ -32406,6 +32419,7 @@ export function viewGame(state: Game, id: string) {
incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn,
incomeCanceled:
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0,
},
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
moritaniRetention: g.moritaniRetention ?? null,
+5 -4
View File
@@ -139,10 +139,11 @@ export function quoteNativeReserveWithdrawal(
});
}
/** Revival destination only: E3 p.10 restores Advanced Emperor's normal forces
* to Kaitain and Sardaukar to Salusa. Other native reserve returns must use their
* own route contract; this does not validate Tanks, cost or revival allowances. */
export function quoteNativeRevivalDeposit(
/** Authorized native-reserve deposits: E3 p.10 restores Advanced Emperor's
* normal forces to Kaitain and Sardaukar to Salusa. Used by revival and by the
* Nexus Secret Ally reserve return (user ruling 7 October 2026); it does not
* validate Tanks, cost, revival allowances or route permission. */
export function quoteNativeReserveDeposit(
context: HomeworldCustodyContext,
custody: HomeworldCustody,
actor: string,
+2 -2
View File
@@ -6,7 +6,7 @@ import {
type HomeworldCustodyContext,
} from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { quoteNativeRevivalDeposit } from './homeworld-native-reserves';
import { quoteNativeReserveDeposit } from './homeworld-native-reserves';
import { homeworldPopulations } from './homeworld-population';
export type HomeworldRevivalDeploymentContext = Pick<
@@ -86,7 +86,7 @@ export function quoteHomeworldRevivalDeployment(
);
const beforeContext = homeworldContext(before);
const afterContext = homeworldContext(after);
const expected = quoteNativeRevivalDeposit(
const expected = quoteNativeReserveDeposit(
beforeContext,
before.homeworlds.custody,
player,
+2 -1
View File
@@ -46,7 +46,8 @@ export function nexusGuildSecretAllyQuote(
return null;
const me = g.players.find((p) => p.id === g.me)!;
if (destination === 'reserves') {
if (g.homeworlds?.worlds?.length) return null;
// User ruling 7 October 2026: the Secret Ally return grant overrides the
// Arrakis-to-native-Homeworld restriction for the holder's own reserves.
return quoteNexusGuildSecretShipment('reserves', amount);
}
const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
+3 -3
View File
@@ -4461,7 +4461,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.',
'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
@@ -4672,10 +4672,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'The assigned leader keeps its skill through capture and returns with it if it survives its captive battle. The known skill identifies that captured leader; Harkonnen receives only its lower battle effect. Normal effects remain unavailable to both the captor and original faction while it is captured. A killed skilled leader returns its card to the shuffled skill deck.',
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.',
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. Multiple piles and other special relocation remain unfinished.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. When a native Ixian Hidden Mobile Stronghold relocates, its interior passengers count as entering the territories it points into, so the Sandmaster collects there before the faction’s own traversed-sector collection. Multiple piles and other special relocation remain unfinished.',
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
+9 -6
View File
@@ -145,16 +145,19 @@ export function quoteRicheseSettlement(
}
/**
* The existing declaration boundary rejects an exhausted cache. Use when a
* canceled/unbid Black Market lot will immediately enter richeseDeclaration;
* settlement quoting alone deliberately does not execute that continuation.
* A sold Black Market lot may first pause for replacement/bonus handling.
* Use when a canceled/unbid Black Market lot will immediately enter
* richeseDeclaration; settlement quoting alone deliberately does not execute
* that continuation. A sold Black Market lot may first pause for
* replacement/bonus handling.
*
* User ruling 7 October 2026: an exhausted cache is legal — the ordinary pool
* then runs its full declared count — so only a malformed count is rejected.
*/
export function requireRicheseDeclarationCache(
cacheCount: number | undefined,
): void {
requireSettlement(
Number.isSafeInteger(cacheCount) && cacheCount! > 0,
'An exhausted Richese cache needs an official ruling on the normal auction count.',
Number.isSafeInteger(cacheCount) && cacheCount! >= 0,
'The Richese cache count must be a readable non-negative number.',
);
}
+2 -1
View File
@@ -248,7 +248,8 @@ export function quoteSukPhysicalRescue(
...transaction.players[index],
tanks: player.tanks + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
eliteTanks: player.eliteTanks + (player.id === request.player ? rescue.tanks.elite : 0),
battleLosses: player.battleLosses + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
battleLosses: player.battleLosses + (player.id === request.player
? request.losses.normal + request.losses.elite : 0),
boardForces: !home && player.id === request.player
? { normal: player.boardForces.normal - removedTotal.normal, elite: player.boardForces.elite - removedTotal.elite }
: { ...player.boardForces },
+7 -5
View File
@@ -351,7 +351,7 @@ void test('original Enemy of My Enemy and ally retention exclude a consumed nati
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
});
void test('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk boundaries', () => {
void test('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk assignment', () => {
const make = (faction: 'atreides' | 'harkonnen' | 'ixians' | 'ecaz' = 'atreides', expansions: Game['expansions'] = ['ecaz']) => {
const game = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), true, expansions);
joinGame(game, newPlayer('original-other', faction, faction));
@@ -387,9 +387,11 @@ void test('new native admission preserves public, Harkonnen assassination, forei
const offered = initializeAdvancedNativeSkillsSetup(options);
const actor = player(offered, 'atreides').id, offer = offered.leaderSkills!.offers[actor];
const own = viewGame(offered, actor).leaderSkills!;
reject(offered, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: own.eligibleLeaders[0].id });
const complete = completeAdvancedNativeSkillsSetup(offered, options);
assert.notEqual(trainer(complete, actor).skill, 'suk-graduate');
assert.ok(complete.leaderSkills!.deck.includes('suk-graduate'));
// User ruling 7 October 2026: Suk is available to Advanced Atreides.
const assigned = applyAction(offered, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: own.eligibleLeaders[0].id });
assert.equal(trainer(assigned, actor).skill, 'suk-graduate');
assert.ok(!assigned.leaderSkills!.deck.includes('suk-graduate'));
const complete = completeAdvancedNativeSkillsSetup(assigned, options);
assert.equal(trainer(complete, actor).skill, 'suk-graduate');
custody(complete);
});
+7 -5
View File
@@ -218,17 +218,19 @@ void test('native CHOAM Bureaucrat redirects only another payer while its native
custody(game);
});
void test('Advanced native admission preserves Atreides Suk and rejects a forged Ix-controlled foreign ghola without mutation', () => {
void test('Advanced native admission assigns Atreides Suk and rejects a forged Ix-controlled foreign ghola without mutation', () => {
const options = { family: 'ixians' as const, skillOwner: 'atreides' as const, requestedSkill: 'suk-graduate' as const };
let game = initializeAdvancedNativeSkillsSetup(options);
while (game.decision?.kind === 'ixSetup') game = advancedNativeStep(game);
const actor = player(game, 'atreides').id;
const offer = game.leaderSkills!.offers[actor];
const leader = viewGame(game, actor).leaderSkills!.eligibleLeaders[0].id;
reject(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader }, /Suk Graduate is unavailable/);
game = completeAdvancedNativeSkillsSetup(game, options);
assert.ok(game.leaderSkills!.deck.includes('suk-graduate'));
assert.notEqual(game.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
// User ruling 7 October 2026: Suk is no longer withheld from Advanced Atreides.
const assigned = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader });
assert.equal(assigned.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
assert.ok(!assigned.leaderSkills!.deck.includes('suk-graduate'));
game = completeAdvancedNativeSkillsSetup(assigned, options);
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
custody(game);
// An Ixian cannot control this original foreign disc. Legitimate native
// Tleilaxu revival is covered separately, without fabricating assignments.
+6 -4
View File
@@ -427,16 +427,18 @@ void test('canceling a partial Richese-paid Emperor invoice advances to native H
assert.ok(canceled.next.kind === 'response' && canceled.next.response.kind === 'harkonnenBonus');
assert.deepEqual(g, before);
});
void test('Black Market cache exhaustion is checked only when reaching that declaration boundary; seller balance overflow rejects before credit', () => {
void test('Black Market cache exhaustion is legal at the declaration boundary; seller balance overflow rejects before credit', () => {
const g = richese();
g.richeseCacheCount = 0;
assert.equal(
quoteAuctionContinuation(g, { kind: 'sale', free: false }).next.kind,
'response',
);
assert.throws(
() => quoteAuctionContinuation(g, { kind: 'next' }),
/exhausted/,
// User ruling 7 October 2026: an exhausted cache is legal, so the declaration
// boundary is reached instead of rejecting.
assert.equal(
quoteAuctionContinuation(g, { kind: 'next' }).next.kind,
'richeseEnd',
);
g.richeseCacheCount = 8;
g.players.find((p) => p.id === 'r')!.spice = Number.MAX_SAFE_INTEGER;
+1
View File
@@ -318,6 +318,7 @@ void test('charity projections expose the payer and own entitlement without leak
payer: 'c',
incomePending: false,
incomeCanceled: false,
incomeHomeworld: 0,
});
assert.equal(v.players[0].spice, undefined);
assert.equal(v.players[0].handLimit, 5);
@@ -195,7 +195,9 @@ for (const band of ['normal', 'skilled'] as const) {
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).reserves, original.reserves + saved - (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).tanks, original.tanks + physicalLoss - saved);
assert.equal(player(game, fixture.owner).battleLosses, original.battleLosses + physicalLoss - saved);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(player(game, fixture.owner).battleLosses, original.battleLosses + physicalLoss);
assert.deepEqual(player(game, fixture.owner).elites, original.elites);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
@@ -129,7 +129,9 @@ for (const scenario of [
assert.equal(player(game, fixture.owner).forces[fixture.key], 8 - scenario.casualties + 1);
assert.equal(player(game, fixture.owner).reserves, before.reserves + 2);
assert.equal(player(game, fixture.owner).tanks, before.tanks + scenario.casualties - 3);
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + scenario.casualties - 3);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + scenario.casualties);
moneyBeforeCleanup(fixture, revealed, game);
reject(game, fixture.owner, action);
});
@@ -92,7 +92,9 @@ void test('held Arrakeen support pays only the actual subsidy while trained phys
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 4);
assert.equal(player(cleanup, fixture.owner).reserves, original.reserves + 2);
assert.equal(player(cleanup, fixture.owner).tanks, original.tanks + 2);
assert.equal(player(cleanup, fixture.owner).battleLosses, original.battleLosses + 2);
// User ruling 7 October 2026: rescued counters still count toward the seven
// Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(player(cleanup, fixture.owner).battleLosses, original.battleLosses + quote.casualties!.options[0].normal);
assert.equal(ordinaryTotal(cleanup, fixture.owner), ordinaryTotal(revealed, fixture.owner));
assert.equal(cleanup.decision?.kind, 'battleCards');
assert.deepEqual(cleanup.techTokens, revealed.techTokens);
+37
View File
@@ -356,3 +356,40 @@ void test('a paid Nullentropy search preserves the actual BG charity split and r
rejects(reload(g), 'b', { type: 'charity' });
homeworldGameIntegrity(g);
});
void test('low-population Tupile adds its bank bonus to CHOAM opening income under the same Inflation multiplier', () => {
// User ruling 7 October 2026: CHOAM's opening income counts as a qualifying
// collection, so the low-population bank bonus applies (scaled by Inflation).
const run = (lowTupile: boolean, multiplier: number) => {
let g = choam(setup());
g.phase = 1;
g.nexus = true;
g.choamCharity = undefined;
if (lowTupile) low(g, 'c', 5); // Tupile below its 11-reserve high threshold
if (multiplier === 2) {
g.turn = 2;
g.inflation = {
side: 'double',
placedTurn: 1,
updatedTurn: 1,
flipped: false,
};
}
seat(g, 'a').spice = 0;
seat(g, 'b').spice = 0;
const before = seat(g, 'c').spice;
for (const id of ['a', 'b', 'c']) g = applyAction(g, id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 2);
assert.equal(g.choamCharity?.canceled, false);
assert.equal(viewGame(g, 'a').charity.incomeHomeworld, lowTupile ? 1 : 0);
return seat(g, 'c').spice - before;
};
for (const multiplier of [1, 2]) {
const withLow = run(true, multiplier);
const withoutLow = run(false, multiplier);
assert.equal(withoutLow, 2 * 3 * multiplier, 'Ordinary opening income');
assert.equal(withLow - withoutLow, multiplier, 'Low-population bank bonus');
}
});
+1 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteNativeReserveWithdrawal as withdraw,
quoteNativeRevivalDeposit as revive,
quoteNativeReserveDeposit as revive,
NativeReserveError,
type NativeReserveSelections,
} from '../game/homeworld-native-reserves';
+31 -29
View File
@@ -224,36 +224,38 @@ function blackMarketOpportunity(winner: 'a' | 'b' | 'e' = 'a') {
}
for (const form of ['printed', 'worthless'] as const)
void test(`${form} Black Market cancellation rejects an exhausted-cache saved continuation before cost`, (t) => {
const { g: initial, worthless, printed } = blackMarketOpportunity();
const g = reload(initial);
// A constructed corruption boundary: a real offered Black Market retains
// its response while its cache has been exhausted. Preserve all card IDs.
g.richeseRemoved!.push(...g.richeseCache!);
g.richeseCache = [];
const before = structuredClone(g);
const physical = inventory(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Preflight must not draw randomness.');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Preflight must not allocate a declaration event.');
});
assert.throws(
() =>
applyAction(g, form === 'printed' ? 'e' : 'b', {
type: 'card',
card: form === 'printed' ? printed : worthless,
mode: 'cancel',
}),
/exhausted Richese cache.*ruling/i,
void test(`${form} Black Market cancellation with an exhausted cache continues and restores the ordinary lot`, () => {
// User ruling 7 October 2026: an exhausted cache is legal, so a canceled
// Black Market settles and the round declares the ordinary pool with the
// cache's one-lot reduction restored.
const counts: number[] = [];
for (const exhausted of [false, true]) {
const { g: initial, worthless, printed } = blackMarketOpportunity();
const g = reload(initial);
// A real offered Black Market keeps its response while its cache is
// exhausted; every physical card ID is preserved.
if (exhausted) {
g.richeseRemoved!.push(...g.richeseCache!);
g.richeseCache = [];
}
const physical = inventory(g);
let canceled = applyAction(g, form === 'printed' ? 'e' : 'b', {
type: 'card',
card: form === 'printed' ? printed : worthless,
mode: 'cancel',
});
// The Worthless card opens its own conversion response before settling.
canceled = passKind(canceled, 'worthlessKarama');
assert.equal(canceled.decision?.kind, 'richeseDeclaration');
assert.ok(!canceled.pendingKarama);
assert.deepEqual(inventory(canceled), physical);
counts.push(canceled.richeseBidding!.normalCount!);
}
assert.equal(
counts[1],
counts[0] + 1,
'The exhausted cache restores exactly one ordinary lot',
);
assert.deepEqual(g, before);
assert.deepEqual(inventory(g), physical);
assert.equal(g.pendingKarama, undefined);
assert.equal(g.discard.length, 0);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
function passKind(initial: Game, kind: NonNullable<Game['response']>['kind']) {
+1 -1
View File
@@ -134,7 +134,7 @@ void test('a private setup offer renders only its two physical cards and eligibl
});
void test('an unavailable offered skill remains inspectable while the other card is selected', () => {
const reason = 'Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.';
const reason = 'This Leader Skill is unavailable in the current profile.';
const html = markup(view({
offer: { event: 'guarded-offer', cards: ['suk-graduate', 'warmaster'], leader: null },
eligibleLeaders: [leaders[0]],
@@ -192,7 +192,7 @@ void test('borrowed cross and return quotes preserve typed physical groups; no n
}
});
void test('Homeworld selection discounts independently legal typed custody; own world and Arrakis return remain unavailable', () => {
void test('Homeworld selection discounts independently legal typed custody; own world stays unavailable but the reserve return is granted', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
@@ -243,7 +243,10 @@ void test('Homeworld selection discounts independently legal typed custody; own
).action,
null,
);
assert.equal(nexusGuildSecretAllyQuote(v, 'reserves', 5), null);
// User ruling 7 October 2026: the grant overrides the Arrakis-to-native-
// Homeworld restriction for the holder's own reserves, but not own-world
// or foreign-world destination shipments.
assert.ok(nexusGuildSecretAllyQuote(v, 'reserves', 5));
assert.equal(
nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
type: 'guildHomeworldShip',
@@ -16,6 +16,9 @@ import {
nexusGuildSecretAllianceFixture,
} from './fixture-nexus-guild-secret-ally';
import { nexusAllow } from './fixture-nexus-cards';
import { homeworldContext } from '../game/homeworld-game';
import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves';
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
function rejected(g: Game, owner: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, owner, action));
@@ -304,3 +307,53 @@ void test('a genuine Moritani arrival alliance preserves completed Guild Secret
assert.notEqual(g.active, f.owner);
stable(g);
});
void test('Guild Secret Ally returns typed counters to the holder own native Homeworld reserves under Homeworlds', () => {
const f = nexusGuildSecretAllyFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
});
const p = f.g.players[0];
// Withdraw the staged board group through the real native-reserve pipeline so
// Homeworld custody stays conserved; the Emperor splits normal/Sardaukar.
const withdrawal = quoteNativeReserveWithdrawal(
homeworldContext(f.g),
f.g.homeworlds!.custody!,
p.id,
{ normal: 3, elite: 2 },
);
const seat = withdrawal.players.find((s) => s.id === p.id)!;
p.reserves = seat.reserves;
p.elites!.reserves = seat.eliteReserves;
f.g.homeworlds!.custody = withdrawal.state;
// Mirror the engine's observeOccupation so the qualification history stays
// consistent with the staged custody change.
if (f.g.homeworlds!.historyVersion === 1 && f.g.homeworldOccupationHistory)
f.g.homeworldOccupationHistory = observeHomeworldOccupation(
f.g.homeworldOccupationHistory,
homeworldContext(f.g),
f.g.homeworlds!.custody!,
f.g.turn,
'change',
`homeworld-change-${f.g.homeworldOccupationHistory.sources[0].event}-${f.g.homeworldOccupationHistory.sources.length}`,
);
p.forces['arrakeen:10'] = 5;
p.elites!.forces['arrakeen:10'] = 2;
stable(f.g);
const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
type: 'guildShip',
from: 'arrakeen:10',
territory: 'reserves',
sector: 0,
amount: 5,
elite: 2,
});
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 17);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].elites!.reserves, 5);
assert.equal(g.players[0].forces['arrakeen:10'], undefined);
rejected(g, f.owner, action);
stable(g);
});
+14 -10
View File
@@ -241,15 +241,19 @@ void test('Richese can fund its buyer from escrow with no unpledged spice, with
}
});
void test('an exhausted cache rejects a Black Market closing bid before saving an unfinishable Harkonnen acknowledgement', () => {
let before: Game | null = null, serialized = '';
assert.throws(() => createRicheseBetrayalFixture('sale',{source:'blackMarket',beforeFinalBid(g) {
void test('an exhausted cache no longer blocks a Black Market closing bid or its betrayal receipt', () => {
let emptied = false;
const fixture = createRicheseBetrayalFixture('sale',{source:'blackMarket',beforeFinalBid(g) {
(g.richeseRemoved ??= []).push(...g.richeseCache!.splice(0));
before = g;serialized = JSON.stringify(g);
}}), /cache/i);
assert.ok(before);
assert.equal(JSON.stringify(before),serialized);
assert.equal((before as Game).pendingRicheseBetrayal,null);
assert.equal((before as Game).richeseBetrayal!.current,null);
assert.equal((before as Game).richeseAuction!.outcome,null);
emptied = true;
}});
assert.ok(emptied);
const g = fixture.game;
// User ruling 7 October 2026: an exhausted cache is legal, so the closing bid
// completes and reaches its betrayal receipt with the physical cache preserved.
assert.equal(g.richeseCache?.length ?? 0,0);
assert.equal(g.richeseRemoved?.length,10);
assert.ok(g.pendingRicheseBetrayal);
assert.equal(g.pendingRicheseBetrayal.receipt.invoice.kind,'sale');
assert.equal(g.richeseBidding!.cacheCanceled,false);
});
@@ -166,7 +166,9 @@ for (const band of ['normal', 'skilled'] as const) for (const support of [3, 6])
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + kept);
assert.equal(player(game, fixture.owner).reserves, before.reserves + saved - kept);
assert.equal(player(game, fixture.owner).tanks, before.tanks + physicalLoss - saved);
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + physicalLoss - saved);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + physicalLoss);
assert.deepEqual(player(game, fixture.owner).elites, before.elites);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
@@ -174,7 +174,9 @@ for (const mode of MODES) for (const band of ['normal', 'skilled'] as const) {
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Physical rescue/substitution must precede original Tech transfer');
const p = player(pending, f.ixians);
assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2);
assert.equal(p.battleLosses, original.battleLosses + 2);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(p.battleLosses, original.battleLosses + 3);
assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4);
+3 -1
View File
@@ -147,7 +147,9 @@ for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] a
assert.equal(pending.decision?.kind, 'ixSubstitution');
const p = player(pending, f.ixians);
assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2);
assert.equal(p.battleLosses, original.battleLosses + 2);
// User ruling 7 October 2026: rescued counters still count toward the
// seven Kwisatz Haderach losses, so the counter gains every dialed casualty.
assert.equal(p.battleLosses, original.battleLosses + 3);
assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4);
+20 -24
View File
@@ -255,12 +255,14 @@ void test('Black Market declaration exhaustion is a separate pure immediate-cont
assert.deepEqual(quoteRicheseSettlement(value), {
kind: 'retainBlackMarket',
});
for (const count of [undefined, 0, -1, 0.5, Infinity, NaN])
// User ruling 7 October 2026: an exhausted cache (count 0) is legal; only a
// malformed or negative count is rejected.
for (const count of [undefined, -1, 0.5, Infinity, NaN])
assert.throws(
() => requireRicheseDeclarationCache(count),
RicheseSettlementError,
);
for (const count of [1, 2, 10])
for (const count of [0, 1, 2, 10])
assert.doesNotThrow(() => requireRicheseDeclarationCache(count));
assert.deepEqual(value, before);
// A sold lot can first suspend for replacement/bonus; quote is not phase advancement.
@@ -417,32 +419,26 @@ void test('quotes agree with real sold settlement debits, transfer and deferred
assert.equal(g.currentAuctionSale?.origin, source);
}
});
void test('a canceled unbid Black Market has the same exhausted-cache boundary as live continuation', () => {
void test('an exhausted cache is legal and restores one ordinary lot in the Richese declaration', () => {
// User ruling 7 October 2026: when no cache lot can run (exhausted cache, or
// a canceled/prevented cache auction) the cache's one-lot reduction is not
// applied, so the ordinary pool runs one more lot.
const counts: number[] = [];
for (const exhausted of [false, true]) {
const g = engineFixture('blackMarket');
g.richeseAuction!.outcome = { kind: 'unbid' };
if (exhausted) g.richeseCache = [];
requireRicheseDeclarationCache(g.richeseCache?.length);
const before = structuredClone(g);
assert.equal(
quoteRicheseSettlement(engineInput(g)).kind,
'retainBlackMarket',
);
if (exhausted) {
assert.throws(
() => requireRicheseDeclarationCache(g.richeseCache?.length),
/exhausted Richese cache/,
);
assert.throws(
() => observed.settleRicheseLot!(structuredClone(g)),
/exhausted Richese cache/,
);
assert.deepEqual(g, before);
} else {
requireRicheseDeclarationCache(g.richeseCache?.length);
observed.settleRicheseLot!(g);
assert.equal(g.decision?.kind, 'richeseDeclaration');
assert.equal(g.richeseBidding!.blackMarketSold, false);
assert.deepEqual(g.players, before.players);
}
observed.settleRicheseLot!(g);
assert.equal(g.decision?.kind, 'richeseDeclaration');
assert.equal(g.richeseBidding!.blackMarketSold, false);
assert.deepEqual(g.players, before.players);
counts.push(g.richeseBidding!.normalCount!);
}
assert.equal(
counts[1],
counts[0] + 1,
'The exhausted cache restores exactly one ordinary lot',
);
});
+38 -1
View File
@@ -12,13 +12,14 @@ import {
sandmasterDefaultChoice,
type SandmasterChoice,
} from '../game/sandmaster-movement';
import { splitLocation, territory } from '../game/board';
import { splitLocation, territory, mobileRoutes, mobileRouteDistance, MOBILE_LOCATION as INSIDE } from '../game/board';
import {
sandmasterMove,
sandmasterMovementGame,
} from './sandmaster-movement-fixture';
import { createTechTokens } from '../game/tech-tokens';
import { placeFixtureHand } from './fixture-hand';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
const restored = (g: Game): Game => JSON.parse(JSON.stringify(g));
function unchanged(
@@ -288,3 +289,39 @@ void test('A separate Hajr movement can collect again and only a later re-entry
assert.equal(next.players[0].moved, 2);
conserved(next, g);
});
void test('interior passengers count as entering the territories the relocated stronghold points into', () => {
// User ruling 7 October 2026: a relocated HMS interior counts as entering the
// outside territories, so the Sandmaster skill collects there. Compare the
// identical relocation with and without the assigned skill.
const run = (skill: 'sandmaster' | 'smuggler') => {
let g = smugglerShipmentGame('ixians', false, skill, undefined, ['ix']);
g.players[0].forces = { [INSIDE]: 6 };
g.players[0].reserves = 14;
if (g.players[0].elites) g.players[0].elites.forces = { [INSIDE]: 3 };
g.mobileStronghold = { location: 'polar_sink:0' };
g.spice = {};
g.phase = 8;
g.active = null;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'mobileStronghold');
const route = mobileRoutes(g, 3).find((r) => mobileRouteDistance(r) === 3)!;
const end = route.at(-1)!;
g.spice[end] = 100;
const before = g.players[0].spice;
g = applyAction(g, 'p', { type: 'decision', route, collect: true });
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(g.mobileStronghold!.location, end);
return { spice: g.players[0].spice - before, pile: g.spice[end] ?? 0 };
};
const without = run('smuggler');
const with_ = run('sandmaster');
assert.equal(with_.spice, without.spice + 1, 'Sandmaster collects one extra spice');
assert.equal(with_.pile, without.pile - 1);
});
+8 -1
View File
@@ -70,7 +70,14 @@ for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] a
assert.equal(rescued.forces[fixture.key], band === 'skilled' ? 5 : 4);
assert.equal(rescued.reserves, ownerBefore.reserves + (band === 'skilled' ? 2 : 1));
assert.equal(rescued.tanks, ownerBefore.tanks + (band === 'skilled' ? 1 : 3));
assert.equal(rescued.battleLosses, ownerBefore.battleLosses + (band === 'skilled' ? 1 : 3));
// User ruling 7 October 2026: rescued counters still count toward the seven
// Kwisatz Haderach losses, so the counter gains the full dialed allocation.
assert.equal(
rescued.battleLosses,
ownerBefore.battleLosses +
(rescued.tanks - ownerBefore.tanks) +
(band === 'skilled' ? 3 : 1),
);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.decision?.kind, 'battleCards');
+10 -2
View File
@@ -19,8 +19,9 @@ export function smugglerShipmentGame(
advanced = false,
skill: 'smuggler' | 'sandmaster' = 'smuggler',
opponentFaction?: FactionId,
expansions: readonly string[] = [],
): Game {
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced);
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced, [...expansions]);
joinGame(
g,
newPlayer('h', 'Opponent', opponentFaction ?? (faction === 'guild' ? 'emperor' : 'guild')),
@@ -43,7 +44,14 @@ export function smugglerShipmentGame(
const view = viewGame(g, p.id);
const skills = view.leaderSkills!;
const options =
g.setupStage === 'leaderSkills' && skills.offer
g.decision?.kind === 'ixSetup'
? [
{
type: 'decision',
card: viewGame(g, g.decision.player).ixTechnology!.setup![0].id,
},
]
: g.setupStage === 'leaderSkills' && skills.offer
? [
{
type: 'leaderSkill',
+42 -33
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { sukGraduateSkill, sukRescueOptions, quoteSukRescue } from '../game/suk-graduate';
import { validateLeaderSkills } from '../game/leader-skills';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advancedAtreidesSukOffer, sukBattle, resolveSuk } from './suk-graduate-fixture';
@@ -37,57 +38,54 @@ void test('Suk entitlement distinguishes public, selected, bluff, killed and cap
assert.equal(sukGraduateSkill([{ ...assignment, captured: true }], { id: 'skilled', dead: false }, true)?.mode, 'skilled');
});
void test('new Advanced Atreides setup and revival keep the physical Suk offer private but reject its assignment immutably', () => {
void test('new Advanced Atreides setup and revival can assign the physical Suk offer', () => {
const game = advancedAtreidesSukOffer();
const ownerView = viewGame(game, 'a');
const otherView = viewGame(game, 'd');
const offer = ownerView.leaderSkills!.offer!;
assert.equal(offer.cards.length, 2);
assert.ok(offer.cards.includes('suk-graduate'));
assert.match(
ownerView.leaderSkills!.unavailableSkills?.['suk-graduate'] ?? '',
/Kwisatz Haderach loss-count ruling is pending/,
);
assert.equal(otherView.leaderSkills?.unavailableSkills, undefined);
const leader = ownerView.leaderSkills!.eligibleLeaders[0].id;
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const blockedAction: Action = {
type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: 'atreides-0',
// User ruling 7 October 2026: Suk is available to Advanced Atreides.
const action: Action = {
type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader,
};
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(game), before);
validateLeaderSkills(game.leaderSkills!, game.players);
const assigned = applyAction(game, 'a', { ...blockedAction, skill: available });
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
assert.ok(assigned.leaderSkills!.deck.includes('suk-graduate'));
const assigned = applyAction(game, 'a', action);
assert.equal(
assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill,
'suk-graduate',
);
assert.ok(!assigned.leaderSkills!.deck.includes('suk-graduate'));
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
const revival = structuredClone(game);
revival.status = 'playing';
revival.setupStage = undefined;
revival.leaderSkills!.offers.a.leader = 'atreides-0';
revival.leaderSkills!.offers.a.leader = leader;
revival.decision = { kind: 'leaderSkillRevival', player: 'a', event: offer.event };
const revivalBefore = JSON.stringify(revival);
assert.throws(() => applyAction(revival, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(revival), revivalBefore);
validateLeaderSkills(revival.leaderSkills!, revival.players);
const revived = applyAction(revival, 'a', action);
assert.equal(
revived.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill,
'suk-graduate',
);
validateLeaderSkills(revived.leaderSkills!, revived.players);
});
void test('all four Advanced Atreides bots choose the available other skill', () => {
void test('all four Advanced Atreides bots assign an offered skill', () => {
for (const difficulty of DIFFICULTIES) {
const game = advancedAtreidesSukOffer();
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const offer = view.leaderSkills!.offer!;
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const action = botActions(view)[0];
assert.equal(action?.type, 'leaderSkill', difficulty);
assert.equal(action?.skill, available, difficulty);
const chosen = action!.skill as LeaderSkillId;
assert.ok(offer.cards.includes(chosen), difficulty);
const assigned = applyAction(game, 'a', action);
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
assert.equal(
assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill,
chosen,
);
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
}
});
@@ -275,11 +273,22 @@ void test('Advanced normal rescue offers the actual casualty types and returns t
conserved(game);
});
void test('Advanced Atreides pending ruling rejects before the final traitor vote; Basic rescue remains playable', () => {
const blocked = sukBattle({ advanced: true, atreides: true });
const before = JSON.stringify(blocked);
assert.throws(() => resolveSuk(blocked), /Kwisatz Haderach loss-count ruling/);
assert.equal(JSON.stringify(blocked), before);
void test('Advanced Atreides rescue proceeds and rescued casualties count toward the Kwisatz Haderach threshold', () => {
const advanced = resolveSuk(sukBattle({ advanced: true, atreides: true }));
const decision = advanced.decision;
assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
const owner = advanced.players.find((p) => p.id === 'a')!;
const lossesBefore = owner.battleLosses;
const tanksBefore = owner.tanks;
// User ruling 7 October 2026: rescued counters still count as battle losses,
// so the counter gains the rescued three as well as the Tank-bound counters.
const rescued = chooseRescue(advanced, 3);
const after = rescued.players.find((p) => p.id === 'a')!;
const tanksDelta = after.tanks - tanksBefore;
assert.equal(after.battleLosses - lossesBefore, tanksDelta + 3);
assert.ok(tanksDelta > 0, 'Some dialed casualties still reach the Tanks');
conserved(rescued);
const basic = resolveSuk(sukBattle({ atreides: true }));
assert.equal(basic.decision?.kind, 'sukRescue');
conserved(chooseRescue(basic, 3));