Integrate native Banker payments and Tleilaxu Ixian Nexus replacement

This commit is contained in:
Quintine committed 2026-10-02 21:12:52 +10:00
1 parent d72f3a2172
commit b83d75d9d3
17 files changed
+912 -46

No files matched your search

+12 -6
View File
@@ -185,8 +185,10 @@ Existing games and public start gates remain unchanged.
For local **Ixian Nexus purchased-card replacement**, use
`node --import tsx tools/start-prototype.ts --profile ixian-replacement`
on a fresh ready classic Basic/Advanced lobby with base Treachery and Nexus
alone. Exact-version/private-backup safeguards apply; existing games and
on a fresh ready classic Basic/Advanced lobby with base Treachery, or Basic
Tleilaxu plus classic opponents with the Ix deck and no Ixians; Nexus alone.
The fresh native variant omits optional Sandtrout. Exact-version/private-backup
safeguards apply; existing games and
public starts are unchanged. A real alliance-qualified Nexus must first
give an unallied buyer its card, so effect scenarios need at least three
seats. **Keep purchased card** retains both cards; eligible **Use Ixian Nexus**
@@ -224,13 +226,17 @@ See the [source contract and excluded modes](docs/NEXUS_HARKONNEN_RULES.md#bound
For local **normal Spice Banker income**, use
`node --import tsx tools/start-prototype.ts --profile banker-income`
on a fresh ready Basic/Advanced classic lobby with base Treachery and all
fourteen Leader Skills alone. Existing exact-version/private-backup safeguards
on a fresh ready classic Basic/Advanced base-deck lobby, or an already-supported
Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck.
All fourteen Leader Skills remain. Exact-version/private-backup safeguards
apply; public starts and existing games are unchanged. Another payer's one
actual bank payment of at least four gains one mandatory front-shield spice
once per phase; it stays unspendable until automatic native Mentat collection.
Original payment, recipient and separate payer legs stay intact. Earned
original-faction retention after death/capture/reassignment is provisional
Original payment, recipient and separate payer legs stay intact.
Tleilaxu's own paid revival is a bank payment; another faction's payment to
Tleilaxu is not, unless its income is canceled. The separate free-revival
bank reward never adds to the original payer's qualifying amount.
Earned original-faction retention after death/capture/reassignment is provisional
in this profile; bounded verification is Partial/Development Verified and the existing question stays open.
See the [normal-band source and scope](docs/SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026).
+3 -2
View File
@@ -1128,8 +1128,9 @@ export function GameTable({
)}
{g.nexusIxianReplacementPreview && (
<p className="notice" role="status">
Ixian Nexus purchased-card replacement development preview · classic Basic/Advanced,
base Treachery and Nexus only. Normal paid and printed Karama purchases are supported;
Ixian Nexus purchased-card replacement development preview · classic Basic/Advanced
with base Treachery, or Basic Tleilaxu/classic with Ixian Treachery; Nexus alone.
Ixians are absent. Normal paid and printed Karama purchases are supported;
Harkonnen buyers and special auctions are guarded. Not complete Ixian or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
</p>
+25
View File
@@ -25,6 +25,31 @@ authorization and deployment/publication safeguards remain intact. Use the
[bounded context index](DEVELOPMENT.md#context-index) and
[feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule).
**Native payment rules — bounded Development Verified:** `banker-income` now
admits supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill rosters with all14
skills and their original single-family decks. Tleilaxu self-revival and canceled
revival income preserve actual payer debit, not the separate free-revival award.
`ixian-replacement` adds Basic Tleilaxu/classic Ix47 with Ixians absent and Nexus
alone; only this fresh variant omits optional Sandtrout. Original payment,
actual purchased-card discard/top draw and native auction suffix stay unchanged.
Two bounded owners supplied source/fixtures; one integration owner repaired
the two real revival bank-hook gaps and the premature Ix Mentat smoke endpoint.
Types/lint, affected110/110, final native/guide26/26 and build pass.
Twenty-eight actual programs cover12 Banker cases,12 replacement cases and4
post-income policy continuations. Human390px **86WQMSZV v18** proves actual
four-spice self-auction, deferred1 with Ix wallet10, then real Mentat11;
**NMFTVV6N v8** proves Thumper→Amal, spent Ixian Nexus, unchanged buyer3
and original Emperor10→12 before phase4 opening. Genuine CLI **7B55PLA5 v8**
and **XLFRHKPF v6** preserve original native setup. No new assurance campaign,
public start, pending ruling answer or old-game retrofit. See
[native Banker](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026)
and [native replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-basic-tleilaxu-integration--2-october-2026).
Final reference10/10 and build pass; both390px Partial/Development Verified
guides show the native boundary, and eleven changed-region local links/anchors
are valid. All owned tabs/workers are closed and five smoke drivers removed;
private inputs/CLI backups remain outside the checkout.
**Native Mentat preview — bounded integration:** explicit fresh
`--profile leader-skills --mentat-question` connects the existing private
question to supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill starts.
+51
View File
@@ -1,5 +1,56 @@
# Dune implementation status
## 2 October 2026 — native Banker income and Ixian Nexus replacement
Two independent bounded owners supplied native original setup/payment fixtures
and exact Ix47 descriptor support; one integration owner owns shared runtime,
controls, CLI, reference and this checkpoint. Existing initializer/action/view/
source APIs remain. No public start, old-game retrofit, broader timing ruling,
strategic AI or new save/privacy/custody assurance wave.
`banker-income` adds already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill
families and their original selected decks/all14 skills. The exact native
self-revival and canceled-income cases first failed after profile admission:
Tleilaxu's own cost was skipped, and a canceled native payout had no original
bank invoice. The repair uses one actual positive-cost payer leg, binding
native payer/kind/recipient/cost to the original response. The separate free
reward never increases that payer's threshold amount. Paid3 plus free1
does not grant; paid4 does. Native player-recipient/free/self/trainer policies
and provisional original-earned-faction retention stay distinct and unchanged.
Three native collection cases initially stopped at phase8 before the existing
Ix Amal opening initialized Mentat. The helper now completes that real opening;
engine phase order is not moved to force a passing wallet check.
`ixian-replacement` adds Basic native Tleilaxu/classic with Ix47 and Nexus alone,
Ixians absent. The existing generic Nexus initializer retains genuine setup
and alliance-qualified acquisition; the fresh native variant omits optional
Sandtrout by the existing bounded-profile convention. Exact purchased
Thumper/Tooth/Blade/Hunter and actual remaining-deck draws preserve once-only
original payment/income and next-lot/final-lot native continuation. Existing
base full-hand/depleted source cases remain rather than artificial duplicates.
Harkonnen buyers, special origins and pending Nexus questions remain gated.
Types/lint, coherent affected110/110, final native/guide26/26 and build pass.
Twenty-eight actual program cases pass:12 native Banker source-to-Mentat/
recipient/cost cases,12 replacement/card/policy cases and4 legal post-income
policy continuations. Fixtures use real original native deals and phase/source
actions; revival examples explicitly stage only conserved casualty positions.
No full-game, calibration or deployed acceptance claim.
Genuine flagged CLI setups **7B55PLA5 v8** and **XLFRHKPF v6** retain native
original setup. Human390px **86WQMSZV v18** closes the actual Emperor self-bid4,
keeps Ix available10 with deferred1, continues original phases and collects
automatically at initialized Mentat to11. **NMFTVV6N v8** inspects the
purchased Thumper, spends its actual Ixian Nexus, draws Amal, retains buyer3,
and allows original Emperor income10→12 once before the phase4 Amal opening.
Final reference10/10, build and both390px bounded guides pass; eleven
changed-region local links/anchors are valid. All owned tabs and both isolated
workers are closed; five smoke/staging/continuation drivers are removed.
Private QA/CLI backups remain outside the checkout. No game reset or supplied
PDF publication; deployment and full module/faction acceptance remain open.
## 2 October 2026 — native Mentat question integration
The existing private preview composes with already-supported Basic
+2 -2
View File
@@ -94,8 +94,8 @@ response step awaits the user's UX decision; ordinary activation remains disable
auction, shipment, Richese sale and paid-bribe families within its recorded
boundary. Other Bureaucrat payments and split funding remain incomplete.
[Banker normal income](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026)
is connected only in its separate fresh classic `banker-income` profile;
ordinary/default and combined skill starts gain no new activation.
is connected only in its separate fresh classic or supported Basic native
`banker-income` profile; ordinary/default and combined starts gain no activation.
[Banker battle spending](SPICE_BANKER_RUNTIME.md) retains its separate
sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
+59 -1
View File
@@ -54,7 +54,7 @@ November's Karama table expressly permits stopping the native Ixian ally from di
## Bounded local runtime contract
The fresh `ixian-replacement` profile is a working development integration of the independently printed Secret Ally grant. It admits two through six unique classic factions in Basic or Advanced with the base Treachery deck and Nexus alone. Native Ixians, expansion factions/decks, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other module overlays are excluded. It does not retrofit an in-play game, enable a public start, reset a saved room or change existing native Ixian allied replacement.
The original classic branch of the fresh `ixian-replacement` profile admits two through six unique classic factions in Basic or Advanced with base Treachery and Nexus alone. The native section below adds Basic Tleilaxu/classic Ix47. Native Ixians, other expansion families, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain excluded. Neither branch retrofits an in-play game, enables a public start, resets a saved room or changes existing native Ixian allied replacement.
There is no starting Nexus deal. Native draws require a qualifying closing
Nexus with a settled alliance, so a two-seat profile can continue its base
@@ -82,6 +82,64 @@ and never add seats to an admitted game or fabricate Nexus hands.
- `components/nexus-ixian-replacement.tsx` and `components/game-table.tsx` own the private inspector and neutral controls; `game/bots.ts` consumes each bot's own legal projection. Easy, Medium, Hard and Brutal have minimal legal Use/Pass policies, not calibrated Nexus strategy.
- The local CLI entry is `node --import tsx tools/start-prototype.ts --profile ixian-replacement --db PATH --room CODE --version NUMBER --out /private/new-directory`. It requires the exact current room version, a fresh supported setup and a private new backup/kit directory; it preserves room/seat/setup identity and existing saves rather than replacing or resetting a game. `tools/prototype-room.ts` owns admission and the private backup transaction.
## Native Basic Tleilaxu integration — 2 October 2026
The additional fresh admission is **Basic Tleilaxu plus unique classic
opponents, exactly the original Ix47 Treachery Deck, and Nexus alone**.
Ixians are absent. This adds to the classic base33 Basic/Advanced profile
above; it does not admit Advanced Tleilaxu, native CHOAM/Richese, mixed
expansions or other overlays. Its fresh setup omits the optional physical
Sandtrout Spice Card, following the existing bounded Ixian Betrayal setup
convention; existing games and their cards are not changed.
`game/nexus-ixian-replacement.ts` uses the original
`treacheryDeck(['ix'])` catalogue for exact descriptors. Original base33
identities remain valid alongside the fourteen original Ix cards; a matching
ID or generic `special` kind cannot authorize a different name, kind, effect
or extra field, and unrelated Richese faces do not enter this catalogue.
The existing source, event, cursor and history APIs remain the only receipt
path. In particular, buying an Ix special does not replay its payment or
turn the replacement draw into another purchase.
`tests/fixture-native-ixian-replacement.ts` exports
`createNativeIxianReplacementFixture` and
`assertNativeIxianReplacementInventory`. The fixture accepts a fresh native
lobby or its undealt admitted setup, completes native setup and turn one,
forms an actual nonrecipient alliance during the next qualifying Nexus,
and draws the Ixian Nexus through the native lifecycle. It then buys an
original physical card in a normal auction for two spice. Its default
purchase is Thumper and its ordered remaining-deck top is Amal; options
also allow an actual Tleilaxu buyer and the final native auction lot.
Only conserved undealt cards are reordered; no held Nexus, acquired
Treachery face, paid-source receipt or continuation is manufactured.
`tests/native-ixian-replacement.test.ts` covers the native special-card
Use and Pass outcomes, exact discard/top draw, preserved original payment
and income, native next-lot/final-lot continuation, single Nexus/Treachery
custody, replay rejection, and forged/unrelated descriptor rejection.
Existing base-profile full-hand and genuinely depleted-deck regressions
already exercise the same discard-first/replenishment path and are retained
rather than duplicated with an artificially emptied native deck.
This is ordinary composition of the printed replacement and existing native
setup/auction lifecycle, not a new Nexus ruling or a completed family claim.
Harkonnen buyers, special sale origins, private opportunity timing,
Richese cache scope and Harkonnen bonus ordering keep their bounds above.
No pending timing/cancellation, cache, bonus or combined-module question is
settled by this added deck/faction admission. Verification of this integration
is reported with the frozen native payment wave, separately from the earlier
base-profile evidence below.
Bounded native verification passes four meaningful regressions in the shared
110-case affected batch; final native/guide26/26, types/lint and build pass.
Twelve actual native programs cover four original Ix purchase/draw pairs,
Atreides/Tleilaxu buyers, next/final lots and all four legal policies.
Genuine CLI **XLFRHKPF v6** reaches original native setup. Human390px
**NMFTVV6N v8** inspects Thumper, spends the original Ixian Nexus, draws Amal,
keeps the buyer's already-paid wallet3, and permits Emperor's original
income10→12 once before the next phase's ordinary Amal window. Full-family
games, other modules, pending rulings and deployment remain open.
## Verification and retained release gates
**Partial development verified.** Types, lint, 6,227 offline cases and the
+2 -2
View File
@@ -171,7 +171,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
| Ixian Nexus Secret Ally replacement | Fresh classic/base-deck/Nexus-only Basic or Advanced local `ixian-replacement` profile binds one genuine normal paid/printed Karama purchase. Discard exactly that purchased physical card before privately drawing the actual deck top; full-hand and exhausted-deck same-card redraw are legal. Keep original payment, income and native auction suffix once; no refund, re-auction or new native Ixian Karama response. Every publicly possible supported unallied held-Nexus buyer gets the same neutral choice irrespective of hidden face; only the owner has Pass/entitled inspection and private eligible Use. This is an inferred development privacy convention, not publisher timing. Harkonnen buyers, Richese cache/Black Market/special origins, anytime overlays and combined modules stay guarded pending their independent source/priority questions. Existing native Ixian ally replacement is distinct. Verification Planned; no retrofit, public start or full-family certification. | [Photographed face and bounded runtime contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) |
| Ixian Nexus Secret Ally replacement | Fresh classic/base33 Basic/Advanced or Basic Tleilaxu/classic Ix47, Ixians absent and Nexus alone. Spend the singleton Nexus to discard exactly the normal paid/printed-Karama purchased card before drawing the actual deck top; preserve original payment, income and auction suffix once. Full-hand and exhausted-deck same-card redraw remain legal. Uniform buyer Use/Pass is the existing development privacy policy, not publisher timing. Harkonnen buyers, special origins, other overlays and unresolved cache/bonus priority stay guarded; no public start or retrofit. Optional Sandtrout is omitted only in the fresh native branch. | [Printed operation and base contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [native integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-basic-tleilaxu-integration--2-october-2026) |
| Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) |
| Harkonnen Nexus Betrayal local preview | Fresh classic/base33/Nexus-only `harkonnen-betrayal` admits genuine personal/allied native matching calls. Native allied Karama/BG allowance comes first; a provisional uniform acknowledgement precedes the original outcome. Use returns the exact physical traitor immediately and preserves one automatic private actual Mentat replacement. The local ordering/privacy convention is not publisher/user priority and does not answer the previously asked universal reaction policy; public starts, old-game retrofit, arbitrary stacks and combined modules remain gated. Bounded native/private/recovery/CLI/phone verification is Partial development verified, not complete modes or deployment. | [Bounded source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026), [runtime limits](NEXUS_CARD_RUNTIME.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
@@ -181,7 +181,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) |
| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) |
| Native Mentat preview | Explicit fresh `leader-skills --mentat-question` reuses the existing private uniform question for already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani profiles. Exact/default card roles, held fallback and skill-before-faction timing are unchanged. Ordinary/public activation, empty-hand handling, Advanced native skills and other modules stay gated; this is integration, not a new answer to the pending UX question. | [Native integration and existing authority](MENTAT_QUESTION.md#native-skill-integration--2-october-2026) |
| Normal Spice Banker local preview | Fresh classic/base33/all14-skills-only `banker-income` gains one additional deferred bank spice once per physical card/turn/phase from another payer's one actual bank payment of at least four. Original recipients/own-donor legs/waivers and native costs stay separate; no pooling smaller payments or using future income to fund the payment. Actual Mentat collects once to original gaining faction. Earned retention through trainer death/capture/reassignment and per-payer splitting are explicit conservative LOCAL choices, not answers to the earlier custody question or publisher examples. Public/old/combined modes stay gated; bounded verification is Partial/Development Verified, not deployed/full-module acceptance. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
| Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected Ixian/Tleilaxu and CHOAM/Richese native families with classic2..6-seat Advanced, distinct selected E1/E2 decks and six Stronghold Cards alone. CHOAM-only deck35; Ix or both47; Richese cache10 stays separate. Native No-Field/Stone/FaceDance and end-Mentat control compose existing rules: targeted16, native11-case smoke, eighteen complete paired/mixed games and human390px decisions give bounded Partial/DevelopmentVerified evidence. Original winner rewards persist through Face Dance; retained cards change only at end-Mentat. Mixed No-Field/full-plan, Stone/Poison Tooth, Ecaz and other overlays remain guarded. Save/privacy/custody assurance follows all rules. | [Expanded rule batch](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
+64 -2
View File
@@ -14,8 +14,9 @@ additional bank grant, not skimming the original payer or recipient; no new
action, Karama response or confirmation is invented. Ordinary costs and
their original counters/recipient/physical suffix finish once before the grant.
The private-backed-up profile admits unique2..6 classic factions,
Basic or Advanced, base33 Treachery and all14 Leader Skills alone. Actual
The original private-backed-up branch admits unique2..6 classic factions,
Basic or Advanced, base33 Treachery and all14 Leader Skills alone; the native
section below adds supported Basic native skill profiles. Actual
paid normal auctions, funded shipments, paid force/leader/KH/Emperor-extra
revivals and resolved bank support use original final-bank receipts. Player
income, free/waived/stopped/invalid sources and self-payment do not qualify.
@@ -87,6 +88,67 @@ storage/HTTP verification before verified-image publication. Image:
Deployment and complete skill/module acceptance remain open.
## Basic native Banker integration — 2 October 2026
This additive local integration connects the four already-supported Basic native
Leader Skill families: Ixians (alone or with Tleilaxu), Tleilaxu alone, CHOAM alone,
and Moritani alone, otherwise classic opponents. Each uses its single required
Ix, CHOAM, or Ecaz Treachery deck and all fourteen original Leader Skills.
Advanced native factions, other overlays, mixed expansion families, public
starts and old-game retrofit remain outside this boundary. The classic
Basic/Advanced base33 contract above is retained.
`tests/fixture-native-banker-income.ts` creates ready fresh native lobbies and
calls `initializeSpiceBankerIncomeGameForAudit`, never setting the income flag on
a played game. Only the original skill shuffle is controlled to obtain a real
Banker offer; native Treachery, Traitor/Face Dancer and Terror shuffles retain
their original entropy. Original Ix starting-card selection, skill assignment,
Traitor/Face Dancer setup, Moritani placement and CHOAM setup continue through
the engine. Revival examples label their staging: conserved normal/cyborg
forces move from reserves to Tanks after genuine setup; cancellation cases
move an original Karama from the remaining deck only if the owner lacks one.
No sale, payment receipt, pending continuation or deferred grant is fabricated.
The reusable `createNativeBankerIncomeFixture({ family: 'ixians',
withTleilaxu: true })` case stops before the original final auction payment:
another payer, the Emperor, buys its native lot for four paid to the Bank.
`payNativeBankerIncomeFixture` executes that action and its original response
suffix; `advanceNativeBankerIncomeToPhase(game, 7)` and the existing
`advanceSpiceBankerIncomeToMentat` continue the real Collection-to-Mentat path.
The focused regressions cover each native family, actual card delivery and
debit, unchanged pre-Mentat Banker wallet, deferred collection, Emperor player
income, and free/paid Tleilaxu-recipient revival. A Tleilaxu own-revival case
distinguishes its actual four-spice bank debit from the separate printed
free-revival bank reward; the reward is not another player's payment.
Canceled Tleilaxu income cases distinguish the actual payer's zero/three/four
bank debit from the separate free-revival reward included in the native income
response. In particular, an original three-spice cyborg debit plus one free
reward must not be mistaken for another player's four-spice bank payment.
Authority remains the photographed Banker card, the existing Leader Skill
rules and original native payment/recipient rules. This composition does not
resolve the previously open earned-spice death/capture question. Original
gaining-faction custody and separate-payer threshold interpretation remain the
explicitly provisional local policies above; no broader ruling is inferred.
**Bounded runtime evidence:** twelve actual native Banker scenarios pass,
including all five native compositions through real Mentat, native
player-recipient exclusions, self-bank revival and canceled income at actual
cost0/3/4. Three initially failing collection cases stopped at phase8 while the
existing Ix Amal opening was still pending; the helper now finishes that
native opening before observing automatic collection. Engine phase ordering
is unchanged. Final parent checks and human controls are recorded with the
native payment checkpoint; this is not whole-faction/module certification.
The affected payment rule batch110/110, final native/guide26/26, types/lint
and build pass. Four additional native post-income policy continuations are
legal. Genuine CLI **7B55PLA5 v8** reaches original native setup.
Human390px **86WQMSZV v18** closes the actual Emperor self-auction4, observes
Ix available10 and deferred1, then finishes original Collection and the
existing Amal opening before automatic Mentat collection to11. This does not
approve the pending universal earned-custody policy or enable public starts.
The historical13 September checkpoint below is **Prototyped**, with **Partial**
rules coverage, and connects the lower battle band only. The normal band is
now connected solely in the fresh profile above; combined acceptance and
+43 -18
View File
@@ -7747,8 +7747,9 @@ function bankerIncomeContext(g: Game): BankerIncomeContext {
function bankerIncomeProfile(g: Game): boolean {
return g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')) &&
g.expansions.length === 0 && !g.nexusCards && !g.homeworlds && !g.techTokens &&
((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) ||
basicExpansionLeaderSkillsProfile(g)) &&
!g.nexusCards && !g.homeworlds && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview &&
!g.moritaniAssassinatePreview && !g.moritaniAssassinate && !g.advancedPreview &&
@@ -7765,7 +7766,7 @@ function spiceBankerIncomeIntegrity(g: Game): void {
return;
}
requireRule(g.spiceBankerIncomePreview === true && bankerIncomeProfile(g) && !!g.leaderSkills && !!g.spiceBankerIncome,
'The saved Spice Banker income profile lost its classic physical setup.');
'The saved Spice Banker income profile lost its supported physical setup.');
bankerIncomeRule(() => validateBankerIncomeState(g.spiceBankerIncome!, bankerIncomeContext(g)));
const continuation = g.pendingTreacheryDiscard?.continuation;
const responses = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
@@ -7776,7 +7777,9 @@ function spiceBankerIncomeIntegrity(g: Game): void {
for (const carrier of [g.currentAuctionSale, ...responses]) {
const payment = carrier?.spiceBankerIncomePayment;
if (!payment) {
requireRule(!carrier || ('winner' in carrier ? carrier.free || carrier.origin !== 'normal' : carrier.kind !== 'guildIncome'),
requireRule(!carrier || ('winner' in carrier ? carrier.free || carrier.origin !== 'normal' :
carrier.kind !== 'guildIncome' &&
!(carrier.kind === 'revivalIncome' && carrier.recipient !== carrier.owner && (carrier.amount ?? 0) > 1)),
'The original paid transaction lost its native bank invoice.');
continue;
}
@@ -7791,10 +7794,20 @@ function spiceBankerIncomeIntegrity(g: Game): void {
payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount &&
payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally),
'The deferred bank invoice no longer matches its original normal auction.');
else if (carrier)
requireRule(carrier.kind === 'guildIncome' && payment.kind === 'shipment' &&
else if (carrier?.kind === 'guildIncome')
requireRule(payment.kind === 'shipment' &&
payment.legs.filter(leg => leg.recipient === 'player').reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount,
'The deferred bank invoice no longer matches its original Guild income.');
else if (carrier) {
const paid = payment.legs[0];
requireRule(carrier.kind === 'revivalIncome' && payment.phase === 4 &&
(payment.kind === 'force-revival' || payment.kind === 'leader-revival' ||
payment.kind === 'kh-revival' || payment.kind === 'emperor-extra-revival') &&
getPlayer(g, carrier.owner).faction === 'tleilaxu' && carrier.recipient !== carrier.owner &&
payment.legs.length === 1 && paid.payer === carrier.recipient && paid.recipient === 'player' &&
(carrier.amount === paid.amount || carrier.amount === paid.amount + 1),
'The deferred bank invoice no longer matches its original paid revival.');
}
}
}
function bankerIncomePayment(g: Game, kind: BankerIncomeAuthority['kind'],
@@ -8694,13 +8707,15 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`);
finishIxianNativeAttempt(g,frame.continuation,use);
}
/** Fresh local classic profile only; no public start or existing-game retrofit. */
/** Fresh classic or Basic native Tleilaxu profile; no public start or retrofit. */
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined &&
ixianNexusReplacementModeSupported(state),
'Ixian Nexus replacement needs a fresh classic lobby with only base Treachery and Nexus cards.');
'Ixian Nexus replacement needs a fresh classic/base or Basic Tleilaxu/Ix lobby with Nexus alone.');
const g = initializeNexusGameForAudit(state);
// Ix supplies Treachery here, not the optional Sandtrout Spice card.
if (g.expansions.length) g.spiceDeck = shuffle(spiceDeck());
g.nexusIxianReplacementPreview = true;
g.pendingNexusIxianReplacement = null;
g.nexusIxianReplacementHistory = [];
@@ -8708,9 +8723,12 @@ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
return g;
}
function ixianNexusReplacementModeSupported(g: Game): boolean {
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
return !!g.nexusCards && g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) ||
(!g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ix' &&
g.players.some(p => p.faction === 'tleilaxu') &&
g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true))) &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout &&
!g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview &&
@@ -9256,17 +9274,18 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
return initializeSetupGameForAudit(g, false, false, false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g));
}
/** Fresh private all-fourteen-card classic setup; no public action enables income. */
/** Fresh all-fourteen-card classic or Basic native skills; no public income toggle. */
export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
requireRule(bankerIncomeProfile(state) && !state.leaderSkills &&
!state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined,
'Spice Banker income requires a fresh classic lobby, base Treachery and Leader Skills alone.');
requireFreshSetup(state);
'Spice Banker income requires a fresh classic or supported Basic native lobby with Leader Skills alone.');
requireFreshSetup(state, state.expansions.length > 0);
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
g.spiceBankerIncomePreview = true;
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
return initializeSetupGameForAudit(g, false, false, false, false, true);
return initializeSetupGameForAudit(g, false, false, false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', g.expansions.length > 0);
}
/** Gated development setup for the independent three-card Ecaz variant. */
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
@@ -21997,11 +22016,13 @@ function finishRevival(
amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free,
})
: null;
payer.spice -= revival.cost;
if (g.spiceBankerIncomePreview && !byFaction(g, 'tleilaxu')) awardBankerIncome(g, bankerIncomePayment(g,
const incomePayment = g.spiceBankerIncomePreview && revival.cost > 0 ? bankerIncomePayment(g,
revival.emperorExtra ? 'emperor-extra-revival' : revival.kind === 'forces' ? 'force-revival' :
revival.kind === 'kwisatz' ? 'kh-revival' : 'leader-revival',
[{payer: payer.id, amount: revival.cost, recipient: 'bank'}]));
[{payer: payer.id, amount: revival.cost,
recipient: quote.nextResponse?.kind === 'revivalIncome' && quote.nextResponse.owner !== payer.id ? 'player' : 'bank'}]) : undefined;
payer.spice -= revival.cost;
if (incomePayment?.legs[0]?.recipient === 'bank') awardBankerIncome(g, incomePayment);
if (revival.kind === 'forces') {
const n = revival.amount!,
elite = revival.elite ?? 0;
@@ -22074,8 +22095,11 @@ function finishRevival(
}
if (quote.nextResponse) {
g.response = quote.nextResponse;
if (quote.nextResponse.kind === 'revivalIncome')
if (quote.nextResponse.kind === 'revivalIncome') {
Object.assign(g.response, stampBureaucratPayment(g,'revival',payer.id,quote.nextResponse.owner,revival.cost));
// The bank-paid free-revival award is not part of this payer's invoice.
if (incomePayment?.legs[0]?.recipient === 'player') g.response.spiceBankerIncomePayment = incomePayment;
}
}
}
function beginRevival(g: Game, revival: PendingRevival) {
@@ -22961,6 +22985,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (g.pendingRevival) finishRevival(g);
} else if (response.kind === 'revivalIncome') {
if (!canceled) getPlayer(g, response.owner).spice += response.amount! - (bureaucratDiversion ?? 0);
awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
g.pendingChoamMarketGhola.stage = 'complete';
} else if (response.kind === 'faceDancerReplacement') {
+2 -2
View File
@@ -1,4 +1,4 @@
import { baseDeck, type Card } from './cards';
import { treacheryDeck, type Card } from './cards';
export class IxianReplacementError extends Error {}
@@ -52,7 +52,7 @@ export type IxianReplacementReceipt = {
};
const canonical: Readonly<Record<string, Card>> =
Object.fromEntries(baseDeck().map(card => [card.id, card]));
Object.fromEntries(treacheryDeck(['ix']).map(card => [card.id, card]));
const genesis = 'ixian-replacement:0';
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
+9 -9
View File
@@ -4247,7 +4247,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.',
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card',
steps: [
'This is a fresh local development profile, not a public Nexus start or an upgrade to saved games. It admits two to six unique classic-faction seats in Basic or Advanced with the base Treachery deck and Nexus alone. Native Ixians, expansion factions/decks, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other optional overlays stay outside the boundary.',
'This fresh local profile admits two-to-six unique classic-faction seats in Basic or Advanced with base Treachery, or additionally Basic Tleilaxu plus classic opponents with the original Ix47 deck and Ixians absent. Nexus alone; optional Sandtrout is omitted only in the fresh native variant. Advanced Tleilaxu, other native expansions, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain guarded. No public start or saved-game upgrade.',
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
'Wait for a genuine normal-auction purchase, paid with spice or through an actual printed Karama purchase, including direct printed acquisition before a positive bid. Native Bene Gesserit Worthless/Truthtrance substitutions continue unchanged without this bounded offer; that producer limit is not a ruling that they are not purchases. A Harkonnen buyer and cache, Black Market or special-purchase origins are guarded pending bonus priority and independent Nexus cache-scope rulings. A Harkonnen rival remains allowed. Gifts, bonus draws, Technology swaps and old hand cards are not eligible purchases.',
@@ -4266,7 +4266,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only purchased-card inspection and legal Use/Pass from public waiting; own-held Nexus inspection grants no rival entitlement. Broader presentation acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume only their own projected event and eligibility while the underlying auction waits. Full strategy and difficulty calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Photographed face, exact physical target, full-hand/empty-deck rules, original settlement once, privacy convention and unresolved cache/Harkonnen/priority boundaries are recorded without PDF Nexus authority or full-family claims.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine paid/printed Karama acquisition, exact target/custody, full-hand and a captured native depleted pool cover same-card redraw, unsold-row exclusion, original ledger once, privacy, replay and four legal bots. Authenticated SQLite covers restart, races and durable rejection. Actual backed-up CLI/phone Use, Keep, takeback and refresh preserve original seats and stock. Wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/nexus-ixian-replacement-controls.test.tsx', 'tests/bot-nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-recovery.test.ts', 'tests/prototype-room.test.ts', 'docs/NEXUS_IXIAN_REPLACEMENT_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original base acquisition/full-hand/depleted-pool/native ledger and private/SQLite/phone evidence remains historical. Native Basic Tleilaxu/Ix47: four meaningful regressions, shared affected110 and final native/guide26, twelve actual card/policy programs, original CLI and390px Thumper-to-Amal/spent-Nexus/buyer3/Emperor10-to12 before phase4 opening. No new assurance campaign; full families/modules and deployment remain open.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-controls.test.tsx', 'tests/bot-nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-recovery.test.ts', 'tests/prototype-room.test.ts', 'docs/NEXUS_IXIAN_REPLACEMENT_RULES.md'] },
],
},
{
@@ -4501,10 +4501,10 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'The fresh banker-income preview gains one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collects automatically at native Mentat.',
searchText: 'Spice Banker normal income bank payment four spice phase deferred front shield original owner death capture Mentat',
steps: [
'This separate fresh local profile requires unique two-to-six classic factions, Basic or Advanced, the base Treachery Deck and all fourteen Leader Skills alone. No public start, old-game retrofit, expansion deck, Nexus, Homeworld, Discovery, Tech Token or other overlay is enabled.',
'This separate fresh local profile admits unique two-to-six classic factions in Basic or Advanced with the base deck, or supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill rosters with their single required deck. All fourteen skills and native setup remain. Advanced native rosters, mixed families, Nexus, Homeworld, Discovery, Tech Tokens and other overlays stay guarded; no public start or old-game retrofit.',
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
'Use the original actual bank portion after recipient and native-counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support retain their original money, card, force and phase continuation once. Canceled Emperor or Guild income can leave a real bank payment; a stopped shipment cannot.',
'Use the original actual bank portion after recipient and counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support keep their native continuation once. Canceled Emperor, Guild or Tleilaxu revival income can leave a real bank payment. Tleilaxu self-revival pays the Bank; other-player revival normally pays Tleilaxu. Its separate free-revival bank award is not payer debit: three paid plus that one reward never becomes four paid to the Bank. A stopped shipment supplies no payment.',
'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. These split-source conventions are explicit conservative local compositions, not a publisher split-payment example.',
'A new grant requires an actual available living native trainer. A captive grants no normal income. Hidden unselected skills do not supply their normal band; in the trainer’s own battle, its actual selected surviving leader supplies the appropriate eligibility after reveal/resolution, not private pre-reveal timing.',
'One physical Banker card has one use per turn and phase. Repeated payments, refresh, death, return or same-phase reassignment cannot reset that use; the next phase or turn permits a new grant.',
@@ -4516,11 +4516,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, native skill eligibility, one physical-card phase grant and original-owner automatic Mentat collection connect through the fresh classic skill profile.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, native skill eligibility, one physical-card phase grant and original-owner automatic Mentat collection connect through fresh classic and supported Basic native skill profiles. Tleilaxu self-revival and canceled revival income preserve actual payer cost separately from the free award.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed gain/threshold/one-payment/phase/collection, original payment separation, provisional earned custody and per-payer split composition are explicit without universal rulings.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Types/lint, 6,487 offline tests and 140 focused cases pass. Eighteen native source-to-Mentat cases, four legal-budget policies, two preserved six-seat CLI/phone rooms and two independent no-finding reviews cover this bounded profile. Local HTTP 49/55 remains limited by six 503 failures; no deployed/full-module claim.', evidence: ['tests/fixture-spice-banker-income.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/spice-banker-income-recovery.test.ts', 'tests/prototype-room.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Original classic checkpoint: types/lint,6,487 offline and140 focused cases, eighteen source programs and two six-seat CLI/phone rooms. Native integration: affected110, final native/guide26, twelve Banker cases plus four legal post-income policies, native original CLI and390px deferred10-to-Mentat11. Tleilaxu paid/free/self/canceled3-or4 boundaries are exercised. Comprehensive assurance follows all rules; no deployed/full-module claim.', evidence: ['tests/fixture-spice-banker-income.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/fixture-native-banker-income.ts', 'tests/native-banker-income.test.ts', 'tests/spice-banker-income-recovery.test.ts', 'tests/prototype-room.test.ts'] },
],
},
{
@@ -4544,17 +4544,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic banker-income profile; ordinary/default and combined starts are unchanged.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported Basic native banker-income profile; ordinary/default and combined starts are unchanged.',
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers, including supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill starts. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in the Basic Tleilaxu skill profile have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
'Other special Sandmaster relocation remains missing. Normal Banker income has a separate fresh classic source-to-Mentat preview with provisional original-earned-faction custody, not general activation. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
'Other special Sandmaster relocation remains missing. Normal Banker income has a separate fresh classic or supported Basic native source-to-Mentat preview with provisional original-earned-faction custody, not general activation. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSpiceBankerIncomeGameForAudit,
joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { forceRevivalQuote } from '../game/revival';
import {
createSpiceBankerIncomeFixture, nextSpiceBankerIncomeNativeStep,
type SpiceBankerIncomeFixture,
} from './fixture-spice-banker-income';
export const NATIVE_BANKER_FAMILIES = ['ixians', 'tleilaxu', 'choam', 'moritani'] as const;
export type NativeBankerFamily = typeof NATIVE_BANKER_FAMILIES[number];
export type NativeBankerIncomeOptions = {
/** Continue an original fresh lobby or its undealt admitted setup. */
initial?: Game;
family: NativeBankerFamily;
/** Only the Ixian family may include the original Tleilaxu setup as well. */
withTleilaxu?: boolean;
bankerFaction?: FactionId;
payerFaction?: FactionId;
kind?: 'auction' | 'force-revival';
/** Auction price, or number of real Tanks to revive. */
amount?: number;
/** Conserved cyborg subset of the revival casualties, Ixians only. */
elite?: number;
stageIncomeCounter?: boolean;
};
/** Control only the original all14 skill shuffle. Treachery, traitors, Face
* Dancers, Terror tokens and every later native shuffle retain real entropy. */
function withNativeBankerSkillShuffle<T>(initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
type RandomInput = Parameters<typeof original>[0];
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker');
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (shuffleIndex <= 0) {
original(array as RandomInput);
return array;
}
assert.ok(array instanceof Uint32Array && array.length === 1,
'The initializer must begin with the original skill shuffle.');
array[0] = shuffleIndex-- === target ? 0 : 0xffffffff;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh ready Basic native lobby admitted through the real Banker initializer;
* no played state is retrofitted and no existing deck or offer is replaced. */
export function initializeNativeBankerIncomeSetup(options: NativeBankerIncomeOptions): Game {
assert.ok(!options.withTleilaxu || options.family === 'ixians');
const native: FactionId[] = [options.family];
if (options.withTleilaxu) native.push('tleilaxu');
const banker = options.bankerFaction ?? options.family;
const seats: FactionId[] = [banker, ...native, 'atreides', 'emperor'];
const factions = seats.filter((faction, index, all) => all.indexOf(faction) === index);
assert.ok(native.includes(banker) || banker === 'atreides' || banker === 'emperor');
const expansion: Game['expansions'][number] = options.family === 'choam' ? 'choam'
: options.family === 'moritani' ? 'ecaz' : 'ix';
let game = options.initial ? structuredClone(options.initial)
: createGame('NATBANKR', newPlayer(banker, banker, banker), false, [expansion]);
if (options.initial) {
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, [expansion]);
if (game.status === 'setup') {
assert.ok(game.spiceBankerIncomePreview && game.turn === 1 && game.phase === 0);
return game;
}
assert.equal(game.status, 'lobby');
} else for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withNativeBankerSkillShuffle(() => initializeSpiceBankerIncomeGameForAudit(game));
}
function step(game: Game): Game {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
}
/** Original Ix starting-card selection, skill assignments, Traitors/Face Dancers
* and Moritani placement finish through existing own-view actions. */
export function completeNativeBankerIncomeSetup(state: Game): Game {
let game = state;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player;
const card = viewGame(game, actor).ixTechnology!.setup![0];
assert.ok(card);
game = applyAction(game, actor, { type: 'decision', card: card.id });
} else if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.includes('spice-banker') ? 'spice-banker'
: offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill);
assert.ok(view.eligibleLeaders[0]);
game = applyAction(game, actor, {
type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id,
});
} else if (game.setupStage === 'traitors' && !game.decision) {
const player = game.players.find(p => p.faction !== 'tleilaxu' && p.traitorChoices.length);
assert.ok(player);
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
} else if (game.decision?.kind === 'moritaniSetup') {
game = applyAction(game, game.decision.player, { type: 'decision', territory: 'polar_sink', sector: 0 });
} else game = step(game);
}
assert.equal(game.status, 'playing');
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!.owner, game.players[0].id);
return game;
}
/** Advance original phase controls without staging a phase or payment receipt. */
export function advanceNativeBankerIncomeToPhase(state: Game, target: number): Game {
let game = state;
const turn = state.turn;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision)
&& game.status === 'playing' && game.turn === turn && i < 1000; i++) game = step(game);
assert.equal(game.turn, turn);
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening);
assert.equal(game.response, null);
assert.equal(game.decision, null);
return game;
}
/** Reusable native runtime case: actual other-payer Emperor self-auction to the
* Bank by default. Revival stages only conserved normal/cyborg casualties after genuine
* setup; original quotes, responses, force returns and recipient credits execute. */
export function createNativeBankerIncomeFixture(options: NativeBankerIncomeOptions): SpiceBankerIncomeFixture {
const initial = initializeNativeBankerIncomeSetup(options);
if ((options.kind ?? 'auction') === 'auction') {
return createSpiceBankerIncomeFixture({
initial, kind: 'auction', payerFaction: options.payerFaction ?? 'emperor',
amount: options.amount ?? 4, stageIncomeCounter: options.stageIncomeCounter,
});
}
let game = completeNativeBankerIncomeSetup(initial);
game = advanceNativeBankerIncomeToPhase(game, 4);
const assignment = game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!;
const payer = game.players.find(p => p.faction === (options.payerFaction ?? 'emperor'))!;
assert.ok(payer);
const count = options.amount ?? 3;
assert.ok(payer.reserves >= count);
// Labelled conserved rule-unit casualty position, not fabricated earned income.
payer.reserves -= count;
payer.tanks += count;
const elite = options.elite ?? 0;
if (elite) {
assert.equal(payer.faction, 'ixians');
assert.ok(elite <= count && payer.elites && payer.elites.reserves >= elite);
payer.elites.reserves -= elite;
payer.elites.tanks += elite;
}
if (options.stageIncomeCounter) {
const owner = game.players.find(p => p.id === assignment.owner)!;
if (!owner.hand.some(card => card.effect === 'karama')) {
const index = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0, 'A genuine native Karama must remain for conserved counter staging.');
owner.hand.push(game.deck.splice(index, 1)[0]);
}
}
const amount = forceRevivalQuote(game, payer, count, elite).cost;
return {
game, beforePayment: structuredClone(game), actor: payer.id, payer: payer.id,
paymentAction: { type: 'revive', amount: count, ...(elite ? { elite } : {}) }, owner: assignment.owner,
leader: assignment.leader, event: game.code, kind: 'force-revival', amount,
bankLegs: [{ payer: payer.id, amount }],
};
}
/** Continue the real action and its original private/native response suffix. */
export function payNativeBankerIncomeFixture(fixture: SpiceBankerIncomeFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 200; i++) game = step(game);
assert.equal(game.response, null);
assert.equal(game.decision, null);
assert.ok(!game.pendingRevival);
return game;
}
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeIxianNexusReplacementGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck, type Card } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { advanceToNextStorm } from './fixture-advanced-source';
import {
settleIxianReplacementFixture, type IxianReplacementFixture,
} from './fixture-nexus-ixian-replacement';
export type NativeIxianReplacementOptions = {
initial?: Game;
buyerFaction?: 'atreides' | 'tleilaxu';
purchasedId?: string;
replacementId?: string;
endingAuction?: boolean;
};
export type NativeIxianReplacementFixture = IxianReplacementFixture & {
setup: Game;
beforeNexusDraw: Game;
nexusDrawAction: Action;
nextCard: Card;
};
export function assertNativeIxianReplacementInventory(game: Game): void {
const physical = [
...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard,
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
];
assert.deepEqual(physical.map(card => card.id).sort(), treacheryDeck(['ix']).map(card => card.id).sort());
const nexus = game.nexusCards!.cards!;
validateNexusCards(nexus, game.players);
assert.deepEqual([
...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(face => face !== null),
].sort(), [...NEXUS_FACTIONS].sort());
}
function nativeStep(game: Game): Game {
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
if (game.phaseOpening) return applyAction(game,
game.players.find(player => !game.phaseOpening!.passed.includes(player.id))!.id, { type: 'ready' });
if (game.status === 'playing' && !game.decision) {
if (game.phase === 0 && game.stormPending === null) {
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined);
assert.ok(dialer);
return applyAction(game, dialer, { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 });
}
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, { type: 'passBid' });
if (game.phase === 5) return applyAction(game, game.active!, { type: 'endMovement' });
}
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(candidate => candidate.type === 'ready') ?? actions[0];
if (action) return applyAction(game, player.id, action);
}
throw new Error(`Native replacement fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
/** Only reorder undealt original cards; never manufacture hand, sale or Nexus receipts. */
function orderStartingCards(game: Game, buyer: string, reserved: readonly string[]): void {
const pool = [...game.deck];
const front: Card[] = [];
for (const player of game.players) {
for (let count = 0; count < (player.faction === 'harkonnen' ? 2 : 1); count++) {
const index = pool.findIndex(card => !reserved.includes(card.id) &&
(player.id === buyer && count === 0 ? card.effect === 'karama' : card.effect !== 'karama'));
assert.ok(index >= 0);
front.push(pool.splice(index, 1)[0]);
}
}
// Keep the requested turn-two cards outside the native turn-one auction row.
game.deck = [...front, ...pool.filter(card => !reserved.includes(card.id)),
...pool.filter(card => reserved.includes(card.id))];
}
/** Basic native Tleilaxu + classic setup, a full first turn, an actual alliance-
* qualified closing Nexus draw, and an original paid normal-auction purchase.
* The unplayed deck is ordered before native auction allocation, not the row. */
export function createNativeIxianReplacementFixture(
options: NativeIxianReplacementOptions = {},
): NativeIxianReplacementFixture {
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
game.players.every(player => player.hand.length === 0)));
} else {
game = createGame('NATIXREP', newPlayer('a', 'Atreides', 'atreides'), false, ['ix']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
}
assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, ['ix']);
assert.ok(game.players.some(player => player.faction === 'tleilaxu'));
const buyer = game.players.find(player => player.faction === (options.buyerFaction ?? 'atreides'))!.id;
const partners = game.players.filter(player => player.id !== buyer);
assert.ok(partners.length >= 2);
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game.nexusCards ??= { cards: null, phase: null };
game = initializeIxianNexusReplacementGameForAudit(game);
}
const purchasedId = options.purchasedId ?? 'ix-thumper';
const replacementId = options.replacementId ?? 'ix-amal';
assert.notEqual(purchasedId, replacementId);
assert.ok(game.deck.some(card => card.id === purchasedId));
assert.ok(game.deck.some(card => card.id === replacementId));
orderStartingCards(game, buyer, [purchasedId, replacementId]);
const nexus = game.nexusCards!.cards!;
assert.ok(Object.values(nexus.hands).every(face => face === null));
nexus.deck = ['ixians', ...nexus.deck.filter(face => face !== 'ixians')];
const setup = structuredClone(game);
for (let step = 0; game.status === 'setup' && step < 100; step++) game = nativeStep(game);
assert.equal(game.status, 'playing');
assert.equal(game.players.find(player => player.faction === 'tleilaxu')!.faceDancers!.length, 3);
assertNativeIxianReplacementInventory(game);
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card, at) => at >= position && 'territory' in card);
assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceToNextStorm(game);
assert.equal(game.turn, 2);
for (let step = 0; game.phase !== 1 && step < 200; step++) game = nativeStep(game);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
assert.ok(worm >= 0);
game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]);
for (let step = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
!game.phaseOpening && !game.response && !game.decision) && step < 200; step++) game = nativeStep(game);
assert.equal(game.nexus, true);
game = applyAction(game, partners[0].id, { type: 'alliance', target: partners[1].id });
game = applyAction(game, partners[1].id, { type: 'alliance', target: partners[0].id });
for (let step = 0; game.nexusCards?.phase?.stage !== 'drawing' && step < 200; step++) game = nativeStep(game);
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const beforeNexusDraw = structuredClone(game);
const nexusDrawAction: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
game = applyAction(game, buyer, nexusDrawAction);
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
for (const id of game.nexusCards!.phase!.eligible)
if (!game.nexusCards!.phase!.done.includes(id))
game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 });
const rowSize = game.players.filter(player => player.hand.length < (player.faction === 'harkonnen' ? 8 : 4)).length;
const auctionOffset = options.endingAuction ? rowSize - 1 : 0;
const purchasedIndex = game.deck.findIndex(card => card.id === purchasedId);
assert.ok(purchasedIndex >= 0);
const originalPurchase = game.deck.splice(purchasedIndex, 1)[0];
const replacementIndex = game.deck.findIndex(card => card.id === replacementId);
assert.ok(replacementIndex >= 0);
const originalReplacement = game.deck.splice(replacementIndex, 1)[0];
game.deck.splice(auctionOffset, 0, originalPurchase);
// Native allocation takes one lot per non-full seat; its remaining deck top
// is the requested original replacement, not an unsold reserved lot.
game.deck.splice(rowSize, 0, originalReplacement);
for (let step = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && step < 300; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game, game.decision.player, { type: 'decision', karama: false });
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = nativeStep(game);
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
const active = game.players.find(player => player.id === game.auction!.active)!;
game = applyAction(game, active.id, active.id !== buyer && active.faction !== 'harkonnen' && game.auction.bid === 0 ?
{ type: 'bid', amount: 1 } : { type: 'passBid' });
} else game = applyAction(game, game.auction.active,
game.auction.active === buyer && game.auction.bid === 0 ? { type: 'bid', amount: 2 } : { type: 'passBid' });
}
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: buyer });
assert.equal(game.auction!.cards[game.auction!.index].id, purchasedId);
assert.equal(game.deck[0].id, replacementId);
assert.equal(game.auction!.bid, 2);
assert.equal(game.auction!.allyPayment ?? 0, 0);
const beforePayment = structuredClone(game);
const beforeSpice = game.players.find(player => player.id === buyer)!.spice;
const emperor = game.players.find(player => player.faction === 'emperor')?.id ?? null;
const beforeEmperorSpice = emperor ? game.players.find(player => player.id === emperor)!.spice : null;
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
const paymentAction: Action = { type: 'decision', karama: false };
game = applyAction(game, buyer, paymentAction);
assert.ok(game.pendingNexusIxianReplacement);
assertNativeIxianReplacementInventory(game);
return {
setup, beforeNexusDraw, nexusDrawAction, beforePayment, game, buyer,
event: game.pendingNexusIxianReplacement.event, purchased, paymentAction, beforeSpice,
paidSpice: game.players.find(player => player.id === buyer)!.spice, emperor, beforeEmperorSpice,
nextCard: structuredClone(game.deck[0]), auctionIndex: beforePayment.auction!.index,
};
}
+1 -1
View File
@@ -56,7 +56,7 @@ function step(game:Game):Game {
}
export function advanceSpiceBankerIncomeToMentat(state:Game):Game {
let game=state;const turn=game.turn;
for(let i=0;game.phase!==8&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game);
for(let i=0;(game.phase!==8||game.phaseOpening)&&game.turn===turn&&game.status==='playing'&&i<1000;i++) game=step(game);
assert.equal(game.turn,turn);assert.equal(game.phase,8);
return game;
}
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { forceRevivalQuote } from '../game/revival';
import { advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import {
advanceNativeBankerIncomeToPhase, createNativeBankerIncomeFixture,
payNativeBankerIncomeFixture, type NativeBankerIncomeOptions,
} from './fixture-native-banker-income';
const nativeCases: NativeBankerIncomeOptions[] = [
{ family: 'ixians' },
{ family: 'ixians', withTleilaxu: true },
{ family: 'tleilaxu' },
{ family: 'choam' },
{ family: 'moritani' },
];
for (const options of nativeCases) {
void test(`Basic ${options.family}${options.withTleilaxu ? '+Tleilaxu' : ''}: original bank auction buys its card once, defers Banker, and collects only at native Mentat`, () => {
const fixture = createNativeBankerIncomeFixture(options);
const buyer = fixture.game.players.find(p => p.id === fixture.payer)!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const purchased = fixture.game.auction!.cards[fixture.game.auction!.index];
assert.notEqual(buyer.id, owner.id);
const paid = payNativeBankerIncomeFixture(fixture);
assert.equal(paid.players.find(p => p.id === buyer.id)!.spice, buyer.spice - 4);
assert.equal(paid.players.find(p => p.id === buyer.id)!.hand.filter(c => c.id === purchased.id).length, 1);
assert.equal(paid.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.deepEqual(viewGame(paid, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
assert.deepEqual(paid.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: buyer.id, amount: 4 }]);
// Ordinary auction/Shipment/Battle/Collection suffixes continue naturally;
// Collection's own native income is already in this baseline wallet.
const collection = advanceNativeBankerIncomeToPhase(paid, 7);
const balance = collection.players.find(p => p.id === owner.id)!.spice;
assert.deepEqual(viewGame(collection, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
const mentat = advanceSpiceBankerIncomeToMentat(collection);
assert.equal(mentat.players.find(p => p.id === owner.id)!.spice, balance + 1);
assert.deepEqual(viewGame(mentat, owner.id).spiceBankerIncome!.deferred, []);
assert.equal(mentat.players.find(p => p.id === buyer.id)!.hand.filter(c => c.id === purchased.id).length, 1);
});
}
void test('Basic native Tleilaxu Banker does not count a real auction paid to Emperor as a bank payment', () => {
const fixture = createNativeBankerIncomeFixture({ family: 'tleilaxu', payerFaction: 'atreides' });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const emperor = fixture.game.players.find(p => p.faction === 'emperor')!;
const card = fixture.game.auction!.cards[fixture.game.auction!.index];
const paid = payNativeBankerIncomeFixture(fixture);
assert.equal(paid.players.find(p => p.id === payer.id)!.spice, payer.spice - 4);
assert.equal(paid.players.find(p => p.id === emperor.id)!.spice, emperor.spice + 4);
assert.equal(paid.players.find(p => p.id === payer.id)!.hand.filter(c => c.id === card.id).length, 1);
assert.deepEqual(viewGame(paid, fixture.owner).spiceBankerIncome!.deferred, []);
});
for (const amount of [1, 3]) {
void test(`Basic native Tleilaxu revival recipient: ${amount === 1 ? 'zero payer debit/free reward' : 'four-spice player payment'} returns original forces without Banker income`, () => {
const fixture = createNativeBankerIncomeFixture({ family: 'tleilaxu', kind: 'force-revival', amount });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const recipient = fixture.game.players.find(p => p.faction === 'tleilaxu')!;
const quote = forceRevivalQuote(fixture.game, payer, amount);
assert.equal(quote.free, 1);
assert.equal(quote.cost, amount === 1 ? 0 : 4);
const paid = payNativeBankerIncomeFixture(fixture);
const revived = paid.players.find(p => p.id === payer.id)!;
assert.equal(revived.spice, payer.spice - quote.cost);
assert.equal(revived.reserves, payer.reserves + amount);
assert.equal(revived.tanks, payer.tanks - amount);
assert.equal(revived.revived, payer.revived + amount);
// The printed free-revival bonus comes from the Bank, not from this payer.
assert.equal(paid.players.find(p => p.id === recipient.id)!.spice, recipient.spice + quote.cost + 1);
assert.deepEqual(viewGame(paid, fixture.owner).spiceBankerIncome!.deferred, []);
assert.equal(paid.spiceBankerIncome!.sources.some(row => row.grant), false);
});
}
void test('Basic native Tleilaxu own paid revival really pays the Bank: another native Banker earns one, not the free-income reward', () => {
const fixture = createNativeBankerIncomeFixture({
family: 'tleilaxu', bankerFaction: 'atreides', payerFaction: 'tleilaxu',
kind: 'force-revival', amount: 6,
});
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const quote = forceRevivalQuote(fixture.game, payer, 6);
assert.equal(quote.free, 2);
assert.equal(quote.cost, 4);
const paid = payNativeBankerIncomeFixture(fixture);
const revived = paid.players.find(p => p.id === payer.id)!;
assert.equal(revived.reserves, payer.reserves + 6);
assert.equal(revived.tanks, payer.tanks - 6);
assert.equal(revived.spice, payer.spice - 4 + 1);
assert.equal(paid.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.deepEqual(viewGame(paid, owner.id).spiceBankerIncome!.deferred, [{ owner: owner.id, amount: 1 }]);
assert.deepEqual(paid.spiceBankerIncome!.sources.find(row => row.grant)!.source.bankLegs,
[{ payer: payer.id, amount: 4 }]);
});
for (const cost of [0, 3, 4]) {
void test(`canceled native Tleilaxu income: actual payer debit ${cost}, not the extra free reward, controls the Banker threshold`, () => {
const options: NativeBankerIncomeOptions = cost === 3
? { family: 'ixians', withTleilaxu: true, bankerFaction: 'atreides', payerFaction: 'ixians', amount: 2, elite: 2 }
: { family: 'tleilaxu', bankerFaction: 'atreides', payerFaction: 'emperor', amount: cost === 0 ? 1 : 3 };
const fixture = createNativeBankerIncomeFixture({ ...options, kind: 'force-revival', stageIncomeCounter: true });
const payer = fixture.game.players.find(p => p.id === fixture.payer)!;
const recipient = fixture.game.players.find(p => p.faction === 'tleilaxu')!;
const owner = fixture.game.players.find(p => p.id === fixture.owner)!;
const counter = owner.hand.find(card => card.effect === 'karama')!;
assert.equal(fixture.amount, cost);
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let i = 0; game.response?.kind !== 'revivalIncome' && i < 100; i++) {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.response?.kind === 'revivalIncome');
assert.equal(game.response.amount, cost + 1);
assert.equal(game.players.find(p => p.id === payer.id)!.spice, payer.spice - cost);
assert.equal(game.players.find(p => p.id === recipient.id)!.spice, recipient.spice);
assert.deepEqual(viewGame(game, owner.id).spiceBankerIncome!.deferred, []);
game = applyAction(game, owner.id, { type: 'card', card: counter.id, mode: 'cancel' });
for (let i = 0; (game.response || game.decision) && i < 100; i++) {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision && !game.pendingRevival);
assert.equal(game.players.find(p => p.id === payer.id)!.spice, payer.spice - cost);
assert.equal(game.players.find(p => p.id === recipient.id)!.spice, recipient.spice);
assert.equal(game.players.find(p => p.id === owner.id)!.spice, owner.spice);
assert.equal(game.players.find(p => p.id === payer.id)!.tanks, payer.tanks - options.amount!);
if (cost === 3) {
assert.equal(game.players.find(p => p.id === payer.id)!.elites!.tanks, payer.elites!.tanks - 2);
assert.equal(game.players.find(p => p.id === payer.id)!.elites!.reserves, payer.elites!.reserves + 2);
}
assert.equal(game.discard.filter(card => card.id === counter.id).length, 1);
assert.deepEqual(viewGame(game, owner.id).spiceBankerIncome!.deferred,
cost === 4 ? [{ owner: owner.id, amount: 1 }] : []);
assert.equal(game.spiceBankerIncome!.sources.some(row => row.grant), cost === 4);
if (cost > 0) assert.deepEqual(game.spiceBankerIncome!.sources.at(-1)!.source.bankLegs,
[{ payer: payer.id, amount: cost }]);
});
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
createIxianReplacementSource, IxianReplacementError,
type IxianReplacementContext,
} from '../game/nexus-ixian-replacement';
import {
assertNativeIxianReplacementInventory, createNativeIxianReplacementFixture,
} from './fixture-native-ixian-replacement';
import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, id: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
for (const buyerFaction of ['atreides', 'tleilaxu'] as const)
void test(`Basic native ${buyerFaction} paid Ix special replacement preserves its purchase and settles the real suffix once`, () => {
const endingAuction = buyerFaction === 'tleilaxu';
const f = createNativeIxianReplacementFixture({ buyerFaction, endingAuction });
assert.equal(f.beforeNexusDraw.nexusCards!.cards!.hands[f.buyer], null);
assert.equal(owner(f.beforeNexusDraw, f.buyer).ally, null);
const partners = f.beforeNexusDraw.players.filter(player => player.id !== f.buyer);
assert.equal(partners[0].ally, partners[1].id);
assert.equal(partners[1].ally, partners[0].id);
assert.equal(applyAction(reload(f.beforeNexusDraw), f.buyer, f.nexusDrawAction).nexusCards!.cards!.hands[f.buyer], 'ixians');
assert.equal(f.purchased.id, 'ix-thumper');
assert.equal(f.nextCard.id, 'ix-amal');
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
assert.equal(offer.canUse, true);
assert.equal(offer.canPass, true);
assert.deepEqual(offer.purchased, f.purchased);
assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 2);
assert.equal(owner(f.game, f.emperor!).spice, f.beforeEmperorSpice);
const deckBefore = structuredClone(f.game.deck);
const unsold = structuredClone(f.game.auction!.cards.slice(f.auctionIndex + 1));
const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementUse', event: offer.event }));
assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]);
assert.deepEqual(use.deck, deckBefore.slice(1));
assert.deepEqual(use.discard, [...f.game.discard, f.purchased]);
assert.equal(use.nexusCards!.cards!.hands[f.buyer], null);
assert.deepEqual(use.nexusCards!.cards!.discard, [...f.game.nexusCards!.cards!.discard, 'ixians']);
assert.equal(owner(use, f.buyer).spice, f.paidSpice);
assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(use.currentAuctionSale, null);
assert.equal(use.pendingNexusIxianReplacement, null);
if (endingAuction) {
assert.equal(use.auction, null);
assert.equal(use.phase, 4);
} else {
assert.equal(use.auction!.index, f.auctionIndex + 1);
assert.deepEqual(use.auction!.cards.slice(use.auction!.index), unsold);
}
reject(use, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event });
reject(use, f.buyer, { type: 'nexusIxianReplacementPass', event: f.event });
});
void test('native Pass keeps the purchased Ix special and genuinely acquired Nexus without a draw or repayment', () => {
const f = createNativeIxianReplacementFixture();
const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!;
const pass = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer,
{ type: 'nexusIxianReplacementPass', event: offer.event }));
assert.deepEqual(owner(pass, f.buyer).hand, owner(f.game, f.buyer).hand);
assert.deepEqual(pass.deck, f.game.deck);
assert.deepEqual(pass.discard, f.game.discard);
assert.deepEqual(pass.nexusCards!.cards, f.game.nexusCards!.cards);
assert.equal(owner(pass, f.buyer).spice, f.paidSpice);
assert.equal(owner(pass, f.emperor!).spice, f.beforeEmperorSpice! + 2);
assert.equal(pass.auction!.index, f.auctionIndex + 1);
assert.equal(pass.currentAuctionSale, null);
assertNativeIxianReplacementInventory(pass);
reject(pass, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event });
});
void test('native descriptor admission rejects forged Ix effects and unrelated Richese faces even with matching row and hand', () => {
const f = createNativeIxianReplacementFixture();
const source = f.game.pendingNexusIxianReplacement!;
for (const corrupt of [
{ ...f.purchased, effect: 'amal' },
{ ...f.purchased, name: 'Amal' },
{ ...f.purchased, kind: 'worthless' },
{ ...f.purchased, extra: true },
richeseCards()[0],
] as Card[]) {
const ctx: IxianReplacementContext = {
status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence,
auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) =>
index === f.auctionIndex ? corrupt : card) },
sale: f.game.currentAuctionSale!,
players: f.game.players.map(player => ({
id: player.id, handLimit: 4,
hand: player.hand.map(card => card.id === f.purchased.id ? corrupt : card),
})),
physicalCards: [
...f.game.players.flatMap(player => player.hand), ...f.game.deck, ...f.game.discard,
...f.game.auction!.cards.slice(f.auctionIndex + 1),
].map(card => card.id === f.purchased.id ? corrupt : card),
};
assert.throws(() => createIxianReplacementSource(ctx, source.event, source.parent), IxianReplacementError);
}
const parent = reload(f.beforePayment);
parent.auction!.cards[parent.auction!.index].effect = 'amal';
reject(parent, f.buyer, f.paymentAction);
});
+1 -1
View File
@@ -22,7 +22,7 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, or supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original E3/E1/E2 offers/HMS/Auditor/Face Dancers. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. No Richese mixed planning, modules or overlays.' +
'\nLeader-skills accepts explicit --mentat-question for the private uniform Mentat preview. Existing classic/CHOAM skill starts and supported Basic Ixian/Tleilaxu/CHOAM/Moritani native rosters retain their original setup and all fourteen skills. Public activation and the empty-hand ruling remain pending; no other modules or Advanced native skill rosters are enabled.',
);