From 3ed985657805fa71cc4579f4bd6e6f00623f5d2a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:58:29 +1100 Subject: [PATCH] Make normal Banker income mandatory in fresh Skills setups --- README.md | 16 +- components/leader-skills.tsx | 4 +- components/spice-banker-income.tsx | 2 +- docs/CURRENT_STATUS.md | 19 + docs/IMPLEMENTATION_STATUS.md | 35 ++ docs/LEADER_SKILLS_RUNTIME.md | 11 +- docs/RULE_DECISIONS.md | 4 +- docs/SPICE_BANKER_RUNTIME.md | 93 +++- game/discovery-module-profile.ts | 3 +- game/engine.ts | 67 +-- game/leader-skill-profile.ts | 19 +- game/reference.ts | 16 +- ...vanced-moritani-skills-assassinate.test.ts | 8 +- tests/banker-overlay-default.test.ts | 190 ++++++++ tests/banker-world-default.test.ts | 154 ++++++ tests/classic-nexus-banker-income.test.ts | 4 +- .../classic-skills-stronghold-runtime.test.ts | 6 +- tests/discovery-module-profile.test.ts | 7 +- ...discovery-native-e2-skills-runtime.test.ts | 1 - tests/ecaz-skills-modules-runtime.test.ts | 4 +- tests/fixture-advanced-native-skills.ts | 5 +- tests/fixture-banker-overlay-default.ts | 439 ++++++++++++++++++ tests/fixture-banker-world-default.ts | 210 +++++++++ tests/fixture-classic-nexus-banker-income.ts | 4 +- tests/fixture-classic-skills-stronghold.ts | 9 +- tests/fixture-discovery-classic-skills.ts | 2 +- tests/fixture-discovery-native-e2-skills.ts | 2 +- tests/fixture-discovery-native-e3-skills.ts | 2 +- ...re-discovery-standalone-nexus-e3-skills.ts | 3 +- tests/fixture-ecaz-skills-modules.ts | 4 +- tests/fixture-homeworld-banker-income.ts | 9 +- tests/fixture-ix-choam-skills-tech.ts | 16 +- tests/fixture-native-banker-income.ts | 4 +- tests/fixture-richese-skills-tech.ts | 4 +- tests/fixture-skills-tech-payments.ts | 4 +- tests/fixture-spice-banker-income.ts | 4 +- tests/ix-choam-skills-tech-runtime.test.ts | 4 +- ...us-skills-modules-payments-runtime.test.ts | 3 - tests/prototype-room.test.ts | 68 +-- tests/richese-contribution-payments.test.ts | 8 +- tests/spice-banker-income-recovery.test.ts | 2 +- tools/prototype-room.ts | 14 +- tools/start-prototype.ts | 24 +- 43 files changed, 1244 insertions(+), 263 deletions(-) create mode 100644 tests/banker-overlay-default.test.ts create mode 100644 tests/banker-world-default.test.ts create mode 100644 tests/fixture-banker-overlay-default.ts create mode 100644 tests/fixture-banker-world-default.ts diff --git a/README.md b/README.md index 4ca3ca82..e34e4ceb 100644 --- a/README.md +++ b/README.md @@ -224,11 +224,11 @@ exchange. This is a bounded provisional local timing/privacy convention; bounded verification is Partial/Development Verified; the universal reaction-policy question stays open. See the [source contract and excluded modes](docs/NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026). -For local **normal Spice Banker income**, use -`node --import tsx tools/start-prototype.ts --profile banker-income` -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 +Normal **Spice Banker income** is mandatory in fresh supported all-fourteen +Leader Skills setups. Use the ordinary +`node --import tsx tools/start-prototype.ts --profile leader-skills` +entry with the lobby's supported original modules; there is no separate +income profile or toggle. 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. @@ -236,9 +236,9 @@ 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). +Earned original-faction retention after death/capture/reassignment remains +provisional; existing games are not retrofitted and complete-mode acceptance +remains open. See the [default setup and source contract](docs/SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). For local **expansion factions + Stronghold Cards**, use `node --import tsx tools/start-prototype.ts --profile stronghold-factions` diff --git a/components/leader-skills.tsx b/components/leader-skills.tsx index d39610d4..cfe249b7 100644 --- a/components/leader-skills.tsx +++ b/components/leader-skills.tsx @@ -115,7 +115,9 @@ export function LeaderSkillsPanel({ Special-card battle use, Suk Graduate force rescue, Mentat’s private weapon question and battle bonus, Bureaucrat payment redirection and battle scores, Sandmaster ground-route collection and victory spice, Rihani inspections and exchanges, optional Smuggler - reserve shipping and provisional modifier-inclusive battle collection, Spice Banker battle spending, and Diplomat copying + reserve shipping and provisional modifier-inclusive battle collection, + mandatory deferred Spice Banker income in fresh setups, separate Banker + battle spending, and Diplomat copying an opposing canonical printed defensive role with a committed Worthless card after plans are revealed. Source-specific timing and combinations remain guarded; complete mode acceptance is unfinished. diff --git a/components/spice-banker-income.tsx b/components/spice-banker-income.tsx index 5ca362a0..d09c50e9 100644 --- a/components/spice-banker-income.tsx +++ b/components/spice-banker-income.tsx @@ -17,7 +17,7 @@ export function SpiceBankerIncome({ game }: { game: GameView }) { This earned spice is not spendable yet. The original gaining faction collects it automatically at the current Mentat opening; it cannot fund a bid, shipment, revival or Battle Plan beforehand.

- Income timing and preview limits + Income timing and skill limits ); } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7749030f..4b4d6919 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,25 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Mandatory normal Banker default — 17 October.** Fresh supported ordinary all14 +Skills now creates the existing deferred-income ledger automatically, including +its original module/native overlay envelopes. Separate initializer/CLI profile +and fixture opt-ins are removed without aliases; existing played games are not +retrofitted. Original payment, per-payer/phase, trainer and earned-faction custody +rules remain; custody is still explicitly provisional. +The ordinary native World/Nexus/Discovery/Tech/Stronghold and Semuta/Skills +programmes both pay Bank4, retain Richese wallet5/front1, and collect wallet6/front0 +only at actual Mentat1. Real Hajr→Semuta recovery creates no second invoice. +Below-band/player-recipient controls and a simulated legacy played save retain +their correct behavior. Two390px journeys/18 original actions include8 native +UI submissions, with no app errors or root overflow; all four legal policies +continue both contexts. Controlled setup entropy and conserved card staging are +explicit. The182-case related set exposed fixture-only issues, repaired with +final25/25 in affected3 files; the other157 passed initially. Actual CLI1/1 and +final types/lint/build pass with existing build warnings. Scratch proof host/files +are removed; no live room, database or deployment changed. See +[current contract and evidence](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). + **Diplomat used-defense contexts — 17 October.** Fresh capability3 copies the canonical role actually used, including late Portable Snooper after a prior false traitor choice; the recorded vote is never reopened. Native World-only diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a0b04a3f..dfe97fd5 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 17 October 2026 — Mandatory normal Banker default + +An ordinary supported all14 Skills setup previously completed a Bank-four +shipment with no income ledger or grant. Fresh ordinary setup now initializes +the existing ledger automatically, including supported original World, Nexus, +Discovery, Tech, Stronghold and native overlay envelopes. Remove the separate +initializer/CLI profile and fixture opt-ins without aliases. Classic occupied +World callers retain their original occupation entry, delegating ordinary Skills. +Played games lacking the metadata are not converted. + +Original final Bank legs, distinct payer thresholds, living trainer, physical +card phase stamp, unavailable front-shield currency and actual Mentat collection +remain. Earned-faction custody is still provisional, not a new source ruling. +Genuine native Richese/Emperor/Guild World+modules and Richese/Guild/Atreides +Semuta+Skills programmes both pay Bank4 and retain wallet5/front1 until original +Mentat1 yields wallet6/front0. Real Hajr discard/Semuta recovery neither repeats +payment nor creates another grant. Bank-three/player-recipient controls, actual +spendability boundaries and a legacy played-state shape retain their behavior. +Initial scalar entropy and conserved closed Spice Cards/printed hand staging are +explicit; no armies, wallets, phase clocks, ownership or receipts are assigned. + +Two390px journeys complete18 original actions, including8 native UI submissions, +with no app errors/root overflow. All four minimal policies legally move the +paid World army, commit actual Semuta and continue native collection; no strategy +calibration is claimed. Private local proof: `/tmp/dune-banker-default-ui-proof.json`. +The182-case related batch initially passed176 and exposed6 fixture failures. +Remove incidental null/absence and staging-text pins; restrict Hajr to its +designated holder, honor response priority and fix setup entropy. A physical +Karama swap preserves the donor's Black Market eligibility. Final affected3-file +union25/25 passes; the unchanged157 cases passed initially. Actual fresh Skills +CLI1/1, final types/lint and build pass with existing warnings. Scratch UI files +and host are removed. No live room/database/service or deployment is changed. +Comprehensive assurance, full modes and deployed acceptance remain open. +See [current source/runtime contract](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). + ## 17 October 2026 — Diplomat used-defense contexts An actual capability2 late Portable after Emperor's false traitor vote recorded diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 05f1edc5..22bdc960 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -1,5 +1,12 @@ # Leader Skills first functional prototype +**17 October default cutover:** normal Spice Banker income is mandatory in +fresh supported ordinary all-fourteen Skills setups, including their existing +module and native-overlay envelopes. The separate income initializer/profile +and fixture opt-ins are removed; existing games are not retrofitted. +Earlier dated references to a separate entry describe historical checkpoints. +See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). + 13 September 2026. This is **Prototyped**, with **Partial** rules coverage. Normal mode starts and publication remain gated. The [source contract](LEADER_SKILLS_RULES.md) contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) @@ -880,8 +887,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, supported Basic native or -the bounded Advanced Ixian/CHOAM `banker-income` profile; ordinary/default starts gain no activation. +is automatic in fresh supported ordinary all-fourteen Skills setups, without +a separate profile or toggle. Existing games and public start gates stay unchanged. [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). diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 69b11506..d269de25 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -716,12 +716,12 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | 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 as the Smuggler base; the controller's own modifiers follow the later provisional policy. Advanced foreign gholas and existing skill questions retain separate contracts. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md), [Smuggler policy](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | | Native Ecaz / Leader Skills | Basic/Advanced Ecaz plus classic opponents retains the exact ecaz deck, five native assignment discs, public skills before Loyalty/Traitors and original placement. Ordinary rescue/death/own revival and Ambassador handlers compose. A separately acquired Duke6 may receive the normal Warmaster1, never trained-disc3; surviving release or printed6 death leaves the native trainer intact. Existing-skilled Ecaz paid5 Duke revival preserves the earlier living set-aside result, without authorizing Duke assignment. Advanced Harkonnen and combined Occupy skills remain bounded. | [Native scope and source contracts](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026), [original Duke lifecycle](DUKE_VIDAL_RULES.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) | -| Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. Existing leader-skills/banker-income entry preserves selected Tech; native/other overlays stay separate. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. Mentat/Bureaucrat original bounds remain. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) | +| Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. The ordinary leader-skills entry preserves selected Tech and now includes mandatory normal Banker income; native/other overlays retain their existing admission. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) | | Native E1/E2 Leader Skills / Tech Tokens | Fresh supported Ixian/Tleilaxu/CHOAM/Richese3..6 Basic/Advanced keeps original native deck/roster limits, all14 skills, cache and printed Tech setup. Original typed payments/rescue/card cleanup precede mandatory winner token reward; Tleilaxu A.1–3 expressly preserves that reward before Face Dance. Actual Charity affects Production, not Axlotl; native CHOAM initial income is not poverty Charity. Separate Banker/Mentat bounds and unrelated guards remain. E3/other overlays, public starts and save conversion are excluded. This composes sourced rules, not a new interpretation. | [Scope, supplied physical21/38 and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026) | | Advanced Leader Skills / Stronghold Cards | Fresh classic or supported E1/E2 native2..6 Advanced preserves all14 skills, six unused cards, original decks/cache/wallets and actual end-Mentat claims; optional Tech requires3..6. Arrakeen bank subsidy is separate from actual payer debit/Banker threshold and original CHOAM income. Physical defense/normal discipline, holder tie and Tabr/Tuek income compose original skill outcomes. Suk/native cleanup and mandatory Tech precede Face Dance, retaining original rewards and one killed-skill return. Copied-Diplomat/active skill-Stone and unrelated guards remain. Basic Strongholds, E3/other overlays, public starts and save conversion are excluded. | [Authorized physical21/38, existing effects and evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026) | | Standalone E3 Skills / Tech / Stronghold Cards | Original Ecaz OR Moritani ecaz33/all14 rosters preserve Tech Basic/Advanced3..6 and Stronghold Advanced2..6, optionally both. Native Ecaz five-disc/temporary Duke/Advanced-Hark exclusion and Moritani skill-first assassination/normal-call forfeiture remain. Real claimed effects, original support/rescue, printed Duke6 or unused-disc bounty and one Mentat replacement compose native rewards without new ownership or income rules. E3 pairs, mixed families, allied Occupy skills, other overlays, public starts and save conversion remain excluded. | [Source contracts and bounded evidence](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026) | | Normal Mentat | The 11 October user-approved uniform private show step runs before posture/faction powers in new supported Leader Skills battles, without an extra opt-in. Matching and fallback preserve the same target-owned stage and held cards. Already-opened unversioned battles do not reopen; versioned receipts remain authoritative. Empty-hand interpretation and full/public acceptance remain separate. | [Approved normal flow](MENTAT_QUESTION.md#normal-activation-and-approved-ux--11-october-2026) | -| 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) | +| Normal Spice Banker default | Fresh supported ordinary all14 Skills setups automatically initialize normal income, including their existing supported original modules and native overlays. The separate initializer/profile and fixture opt-ins are removed; no public start or old-game retrofit. Another payer's one actual final Bank payment ≥4 grants one additional deferred spice per physical card/turn/phase, unavailable until actual Mentat. Distinct payers and separate free-revival awards never pool into an invoice. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. Existing same-payer diversion/rounding rulings remain. Earned-faction retention after death/capture/reassignment remains provisional, not a new answer to the pending custody question. | [Current setup and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026), [payment contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | | Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards; optional unused Tech Tokens requires3..6, without Leader Skills. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026), [native Tech composition](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026) | | Native Tech without Leader Skills | Fresh3..6 Basic/Advanced selected E1/E2 native families or standalone Ecaz OR Moritani plus classics preserve original unused three Tech Tokens through `factions`; Advanced Strongholds use the existing native entry. Physical setup, printed/first-Storm owners, phase-end income and single-player complete-set victory are unchanged. Actual Ecaz selected lead receives the original opposing token, never a joint allied holding; Basic odd Occupy remains guarded. Moritani original forfeiture/assassination and winner Tech before Face Dance reuse existing handlers. No new ruling, wallet grant, public start or save conversion. | [Original composition/source and evidence](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026), [Stronghold contract](STRONGHOLD_CARDS.md#native-tech-composition-without-skills--4-october-2026) | | Classic Nexus / Tech / Strongholds | Fresh classic/base33/all12 Nexus may preserve original Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills/HW/expansion decks or unrelated previews. Original closing deals, first-Storm Tech and actual end-Mentat card claims remain. Actual Nexus free-three returns invoke Axlotl without merging their extra/ordinary ledgers; industry collects once at native phase end. Borrowed tariffs never relocate Fremen on-planet reserves or native faction identity. Held Arrakeen pays actual support; temporary Sardaukar remain ordinary counters. Losing Moritani retention precedes original winner Tech, then CHOAM excludes every used opposing card. Bank purchase retains actual wallet/card without an industry trigger. Pending borrowed Voice, worm protection and Tleilaxu revival remain guarded; no public starts/save conversion or new ruling. | [Composition/source and bounded evidence](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026) | diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md index 2635aad2..81e5fd14 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -1,12 +1,67 @@ # Spice Banker battle commitment and normal income prototypes +## Mandatory ordinary Skills default — 17 October 2026 + +Fresh supported all-fourteen Leader Skills setups now include normal Banker +income automatically. Use `--profile leader-skills` with the original supported +lobby modules, or the existing `homeworld-occupation --leader-skills` entry for +classic occupation. The separate income initializer, `banker-income` CLI profile +and fixture opt-in options are removed, without aliases. Existing played games +are not retrofitted. Public starts and unfinished mode gates remain unchanged. + +The ordinary setup creates the existing version-one income ledger. Its final +Bank payment legs, living trainer, physical-card once-per-phase stamp, +unspendable front-shield pile and actual Mentat collection are unchanged. +Earned-faction custody remains explicitly provisional; default activation +does not settle the outstanding source question. + +Ordinary Advanced native Richese/Emperor/Guild setup retains original +Homeworlds, Nexus, Discovery, Tech and Strongholds. Real Banker training and +an explicit four-counter Junction withdrawal precede the Guild's completed +Bank-four shipment: Guild5→1, Richese wallet5/front1. Original Collection +leaves Richese5; the final Guild Ready opens Mentat1 and yields wallet6/front0 +with one collection credit. No armies, wallets, ownership or phase clocks +are assigned; controlled setup entropy and conserved closed Spice Card order +are disclosed in `tests/fixture-banker-world-default.ts`. +Actual390px Ship/Allow/Ready controls reproduce that path in an isolated +in-memory host, with root width390 and no app errors. Private local proof: +`/tmp/dune-banker-default-ui-proof.json`. Four minimal policies legally move +the paid Guild army and use the original final Ready to collect; this is +not strategic calibration, deployed evidence or complete-mode acceptance. + +The Semuta overlay retains the same ordinary default in Basic and Advanced, +including its original Tech/Stronghold envelopes. A completed Guild Bank-four +shipment grants front1 without changing Richese wallet5. Atreides then plays +its conserved printed Hajr; Richese commits the real Semuta, discards it once +and privately acquires Hajr, without another Bank invoice or income grant. +The final original Collection Ready credits Richese5→6 at Mentat1. A Bank-three +shipment and a four-spice shipment paid to Guild grant nothing. +Initial setup scalar entropy is controlled, UUIDs remain native, and two +original closed Spice Cards plus conserved Semuta/Hajr hand staging are explicit. +No armies, balances, clocks, owners or receipts are assigned. + +Two390px journeys complete18 original actions, including8 native UI submissions; +both have no app errors or root overflow. Four legal policies commit Semuta and +continue to the same single Mentat credit. The182-case related set initially +exposed fixture-only null/absence, Hajr/response ordering and donor-hand issues. +After repairs, the final affected3-file union passes25/25; the remaining157 +passed in the initial batch. The actual fresh Skills CLI case passes1/1. +Final types/lint and build pass, with existing module-registration, chunk-size +and route-classification warnings. Scratch UI files and host are removed. +Evidence: `tests/banker-world-default.test.ts`, +`tests/banker-overlay-default.test.ts`, their fixture modules, and the existing +payment/module consumers. Full assurance and deployed acceptance remain open. + +The dated checkpoints below describe their original verification scope. +References to the former separate entry are historical, not current commands. + ## Classic Homeworld final Bank income — 14 October 2026 -Fresh classic Basic/Advanced `banker-income --homeworlds` composes original -occupation setup and all14 Skills with income-v1. This branch excludes Nexus, -Discovery, Tech, Strongholds and native expansion/Betrayal overlays. Other -existing Banker entries remain unchanged; no public activation or played-game -conversion is added. +At the 14 October checkpoint, a separate classic Basic/Advanced income entry +composed original occupation setup and all14 Skills with income-v1. +That branch excluded Nexus, Discovery, Tech, Strongholds and native overlays. +The ordinary-default cutover above supersedes its activation mechanism; +the accounting and original occupation behavior below remain. Three user rulings define accounting: combine only the same payer’s final Bank portions of one original invoice; attribute Bureaucrat’s two to the first @@ -46,19 +101,19 @@ Evidence: `tests/fixture-homeworld-banker-income.ts`, combinations, public starts and deployed acceptance remain open. ```sh -node --import tsx tools/start-prototype.ts --profile banker-income --homeworlds \ +node --import tsx tools/start-prototype.ts --profile homeworld-occupation --leader-skills \ --db /private/local.sqlite --room ROOMCODE --version 7 \ --out /private/new-homeworld-banker-entry ``` ## Classic Nexus income composition — 13 October 2026 -`banker-income --nexus-cards` now connects the existing mandatory normal band -to fresh classic Basic/Advanced Nexus Skills games. Original Tech requires3+; -Strongholds require Advanced2+, optionally both. This does not admit Homeworlds, -Discovery, expansion-family Nexus, Semuta or Betrayal previews, change ordinary -skill defaults, or retrofit played games. All33 Treachery, all14 Skills and -all12 undealt Nexus remain native. There is no fabricated starting Nexus hand. +The 13 October separate income composition connected the mandatory normal band +to fresh classic Basic/Advanced Nexus Skills games. Original Tech required3+; +Strongholds required Advanced2+, optionally both. That checkpoint excluded +Homeworlds, Discovery, expansion-family Nexus, Semuta and Betrayal previews. +All33 Treachery, all14 Skills and all12 undealt Nexus remained native. +There was no fabricated starting Nexus hand. The previous entry rejected this fresh composition before offers. The shared classic Nexus skill predicate now recognizes the explicit income overlay, and @@ -84,7 +139,7 @@ Original earned-custody and per-payer policies remain provisional; wider printed effects, combination and deployed acceptance are not certified. ```sh -node --import tsx tools/start-prototype.ts --profile banker-income --nexus-cards \ +node --import tsx tools/start-prototype.ts --profile leader-skills --nexus-cards \ --db /private/local.sqlite --room ROOMCODE --version 7 \ --out /private/new-classic-nexus-banker-entry ``` @@ -251,7 +306,7 @@ 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 +calls `initializeLeaderSkillsGameForAudit`, 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, @@ -335,8 +390,8 @@ Mentat Pause. Neither publisher nor designer material located in the renewed search settles custody of that accumulated income if the trainer dies or is captured first. The user has been asked whether to retain the pending boundary or rule that already-earned spice stays with the original faction. No answer -is assumed; only the explicitly provisional fresh profile above implements -normal income. This historical question remains open outside that profile. +is assumed: the explicitly provisional earned-faction policy now applies in +fresh ordinary Skills setups. Default activation does not settle that question. ## Connected behavior @@ -381,6 +436,6 @@ values and the same private Crysknife, Baliset and Burseg while card cleanup awaited the player. This targeted scenario is not a complete human game. Required broad results are recorded with the checkpoint. -Remaining work includes normal income, all economic trigger routes and ordering, -its public deferred custody, combined modules, wider full-game interaction and -strategy refinement. The existing development gates remain unchanged. +Remaining work includes exhaustive economic-trigger and ordering integration, +wider full-game interactions and strategy refinement. Normal income is no longer +a separate activation path; complete-mode and deployment gates remain unchanged. diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index 4da230df..56363c9e 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -65,8 +65,7 @@ export function classicDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): b game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || - game.nexusHarkonnenBetrayalPreview || - game.spiceBankerIncomePreview) return false; + game.nexusHarkonnenBetrayalPreview) return false; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; let classic = false; diff --git a/game/engine.ts b/game/engine.ts index 3e4e7b8a..b9696136 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2119,7 +2119,7 @@ export type Game = { /** Absent on legacy rooms whose starting cards were already dealt. */ setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces'; leaderSkills?: LeaderSkillsState; - /** Fresh local classic profile; earned custody remains with its original faction. */ + /** Fresh Skills normal income; already-earned spice stays with its original faction. */ spiceBankerIncomePreview?: true; spiceBankerIncome?: BankerIncomeState; bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{ @@ -8857,22 +8857,6 @@ function bankerIncomeContext(g: Game): BankerIncomeContext { }))})), assignments: g.leaderSkills?.assignments ?? []}; } -function bankerIncomeProfile(g: Game): boolean { - const classicNexus = classicNexusLeaderSkillsProfile(g); - const homeworlds = !!g.homeworlds && !g.expansions.length && !g.nexusCards && - !g.techTokens && !g.strongholdCards && homeworldLeaderSkillsProfile(g); - return g.players.length >= 2 && g.players.length <= 6 && - new Set(g.players.map(p => p.faction)).size === g.players.length && - ((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) || - nativeExpansionLeaderSkillsProfile(g)) && - (!g.nexusCards || classicNexus) && (!g.homeworlds || homeworlds) && - (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || strongholdLeaderSkillsProfile(g) || classicNexus) && - (!g.strongholdCards || strongholdLeaderSkillsProfile(g) || classicNexus) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && - !g.greatMaker && !g.ecazTreachery && !g.semutaPreview && - ((!g.moritaniAssassinatePreview && !g.moritaniAssassinate) || advancedMoritaniLeaderSkillsProfile(g)) && !g.advancedPreview && - !g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview && - !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview; -} function bankerIncomePaymentSignature(payment: SpiceBankerIncomePayment): string { return JSON.stringify([payment.event, payment.turn, payment.phase, payment.kind, payment.legs]); } @@ -8882,8 +8866,8 @@ function spiceBankerIncomeIntegrity(g: Game): void { 'Deferred Spice Banker income requires its fresh local profile.'); return; } - requireRule(g.spiceBankerIncomePreview === true && bankerIncomeProfile(g) && !!g.leaderSkills && !!g.spiceBankerIncome, - 'The saved Spice Banker income profile lost its supported physical setup.'); + requireRule(g.spiceBankerIncomePreview === true && ordinaryLeaderSkillModeSupported(g) && !!g.leaderSkills && !!g.spiceBankerIncome, + 'The saved Spice Banker income lost its supported Leader Skills setup.'); bankerIncomeRule(() => validateBankerIncomeState(g.spiceBankerIncome!, bankerIncomeContext(g))); const continuation = g.pendingTreacheryDiscard?.continuation; const responses = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, @@ -10676,27 +10660,6 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { if ((mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; return initialized; } -/** Fresh all-fourteen-card classic or supported 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 or supported native skill lobby without unsupported optional modules.'); - requireFreshSetup(state, state.expansions.length > 0); - if (state.homeworlds) { - const initialized = initializeHomeworldOccupationGameForAudit(state, true); - initialized.spiceBankerIncomePreview = true; - initialized.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(initialized))); - return initialized; - } - const g = structuredClone(state); - g.leaderSkills = createLeaderSkills(random); - g.spiceBankerIncomePreview = true; - g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g))); - const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g), false, false, true, - g.expansions.length === 1 && g.expansions[0] === 'choam', g.expansions.length > 0); - if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); - return initialized; -} /** Independent cards keep the actual selected base/E1/E2 physical families. */ function independentCardRoster(g: Game): boolean { if (g.expansions.length > 2 || @@ -10802,6 +10765,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f ? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech or Advanced Strongholds and no other overlays.' : 'The audit initializer supports base factions without expansions or optional modules.', ); + if (leaderSkills) { + requireRule(g.leaderSkills && g.spiceBankerIncomePreview === undefined && g.spiceBankerIncome === undefined, + 'Normal Spice Banker income starts only with fresh Leader Skills setup.'); + g.spiceBankerIncomePreview = true; + g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g))); + } initializeSetup(g, strongholdFactions || !!g.strongholdCards); if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; return normalizeAutomaticGame(g); @@ -17600,8 +17569,8 @@ function occupiedPercentageIntegrity(g: Game) { g.pendingHomeworldOccupiedBonus === undefined, 'Occupied source receipts require their original fresh profile.'); return; } - requireRule(!g.spiceBankerIncomePreview || bankerIncomeProfile(g), - 'Occupied percentage and deferred Banker routing require their supported classic Homeworld source profile.'); + requireRule(!g.spiceBankerIncomePreview || ordinaryLeaderSkillModeSupported(g), + 'Occupied percentage and deferred Banker routing require their supported Leader Skills source context.'); if (!g.homeworldOccupiedPercentageLedger) { requireRule(!g.pendingHomeworldOccupiedPercentage && !g.pendingHomeworldOccupiedBonus, 'The original occupied percentage ledger is missing.'); @@ -29667,20 +29636,6 @@ export function applyAction(state: Game, id: string, action: Action): Game { state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); if (state.spiceBankerIncomePreview) { - const keysBySource: Record = { - bid: ['type','amount','allyPayment'], passBid: ['type'], - ship: ['type','territory','sector','amount','elite','allyPayment','smuggler'], - guildShip: ['type','from','forces','eliteForces','territory','sector','amount','elite','allyPayment'], - revive: ['type','amount','elite'], reviveLeader: ['type','leader'], reviveKwisatz: ['type'], - emperorRevival: ['type','amount','elite'], passResponse: ['type'], - traitorCall: ['type','call','leader'], - }; - const allowed = action.type === 'ship' && action.noField !== undefined && getPlayer(state, id).faction === 'richese' - ? ['type','territory','sector','amount','elite','allyPayment','smuggler','noField','event'] - : action.type === 'decision' && state.decision?.kind === 'auctionPayment' - ? ['type','karama','card'] : keysBySource[action.type]; - requireRule(!allowed || Object.keys(action).every(key => allowed.includes(key)), - 'Use only the original native payment fields; Spice Banker income is automatic.'); requireRule(!Object.keys(action).some(key => ['actor','bankerIncome','paymentSource','spiceBankerIncome','bankLegs','trainer'].includes(key)), 'Spice Banker payment authority comes only from the original native transaction.'); } diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 800e90b2..2bbe5722 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -34,7 +34,7 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false, betrayal = false, bankerIncome = false): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false, betrayal = false): boolean { return !!game.nexusCards && typeof game.advanced === 'boolean' && !!game.players && game.players.length >= 2 && game.players.length <= 6 && ((discovery && game.discoveryEnabled === true) || @@ -48,15 +48,12 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && (!game.guildBetrayalPreview || betrayal) && !game.richeseBetrayalPreview && !game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview && - !game.nexusHarkonnenBetrayalPreview && - (!game.spiceBankerIncomePreview || bankerIncome); + !game.nexusHarkonnenBetrayalPreview; } /** Fresh classic Nexus/Skills, optionally Homeworlds/Discovery/Tech/Advanced Strongholds. */ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true, false, true)) return false; - if (game.spiceBankerIncomePreview && (game.homeworlds || game.discoveryEnabled || - game.discoveries || game.discoveryStash || game.greatMaker)) return false; + if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true, false)) return false; const players = game.players!; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; @@ -208,13 +205,12 @@ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && !game.guildBetrayalPreview && !game.richeseBetrayalPreview && !game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview && - !game.nexusHarkonnenBetrayalPreview && - !game.spiceBankerIncomePreview; + !game.nexusHarkonnenBetrayalPreview; } /** Recognize only the explicit overlay; native predicates still own roster/module legality. */ function semutaLeaderSkillContext(game: LeaderSkillProfile): boolean { - return game.semutaPreview === true && !game.spiceBankerIncomePreview && + return game.semutaPreview === true && game.expansions.includes('choam') && !!game.players?.some(p => p.faction === 'richese'); } @@ -491,10 +487,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean game.expansions[0] !== 'ecaz' || !players.some(player => player.faction === 'moritani'))) || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || - game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview || - (game.spiceBankerIncomePreview && (game.expansions.length || game.nexusCards || - game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || - game.techTokens || game.strongholdCards))) return false; + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; return true; } diff --git a/game/reference.ts b/game/reference.ts index 9e9adaf5..754ba4a0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4892,12 +4892,12 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Verified', - 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.', + summary: 'Fresh supported all-fourteen Leader Skills setups automatically gain one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collect 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 profile admits classic Basic/Advanced, supported Basic native families, bounded Advanced Ixian/Tleilaxu/CHOAM/Richese plus classic seats with required decks, or separate non-Harkonnen Advanced Moritani. All fourteen skills and native setup remain. Richese cache self-payments and authorized Advanced donor legs count only when actually paid to bank; allowed Emperor or seller income does not. Basic receipts and unrelated module/public gates remain.', - 'The explicit banker-income Nexus composition admits fresh classic Basic/Advanced all14/all12 setup, optionally original Tech at three or more seats and Advanced Strongholds. Actual bank payment four gains one deferred spice; player-recipient four and a separate Tech payout do not. Original Mentat collects automatically. Homeworlds, Discovery, expansion-family Nexus and Betrayal overlays remain outside this income entry; no starting Nexus hand or played-game conversion is added.', - 'The separate fresh banker-income --homeworlds composition admits classic Basic/Advanced original occupation and all fourteen Skills, without Nexus, Discovery, Tech, Strongholds or expansion overlays. It uses actual final native/occupied receipt Bank amounts. Bureaucrat diversion combines only with the same payer’s other Bank portions of the same original invoice. Low native income rounds the whole eligible invoice once; extra Bank rounding units and the first eligible diversion contributor are attributed primary-payer-first internally, without funding disclosure. Original occupied shares remain protected and Mentat collects automatically.', + 'Normal income is mandatory in new ordinary supported all-fourteen Leader Skills setups, including their original supported Homeworld, Nexus, Discovery, Tech, Stronghold and native overlay envelopes. No separate income profile or toggle remains. This adds no new module combination, public start or played-game conversion.', + 'Original native setup, decks, offers, phase controls and payment recipients remain. Only actual final Bank portions count; authorized donor contributions remain separate payer legs, and allowed Emperor, Guild or seller income does not become a Bank payment.', + 'Original native/occupied receipts supply final Bank amounts. Bureaucrat diversion combines only with the same payer’s other Bank portions of one original invoice. Low native income rounds the eligible invoice once, with Bank rounding and the first eligible diversion contribution attributed primary-payer-first internally. Funding identities are not disclosed.', '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 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.', @@ -4908,15 +4908,15 @@ export const RULE_TOPICS: RuleTopic[] = [ 'At the real current Mentat opening, collect the uncollected grants automatically once before optional actions and victory. No extra response, counter, confirmation or collect action is added. Collection itself is not another player-to-bank payment.', 'This fresh local prototype provisionally keeps already-earned spice with the original gaining faction when the trainer dies, is captured or the skill changes owner. That is a conservative earned-currency custody choice, not an answer to the previously asked universal death/capture question.', 'Public projection shows only physical front-shield owner/count and current-phase use. Private payment receipts, exact hidden source amounts, plans, and future skill entitlement are not exposed. Current and collected history survive JSON/recovery without replaying either original payment or collection.', - 'Bounded development verification covers actual payment families, threshold/recipient/trainer eligibility, deferred budgets, lifecycle custody and authenticated payment/collection races. Native smoke preserves all 33 Treachery Cards and 14 skills; two original-backed-up six-seat Basic/Advanced rooms verify human phone payment, refresh and actual Mentat collection. Local HTTP remains 49/55 with six upstream 503 failures, not green. Existing lower battle spending stays separate; complete skill/module and deployment acceptance remain open.', + 'Current bounded proof exercises ordinary native World/Nexus/Discovery/Tech/Stronghold and Semuta/Skills setup, real Bank-four versus below-threshold or player-recipient payments, unavailable deferred budgets, native Hajr recovery and original Mentat collection. A played Skills save without income metadata retains its legacy behavior. Two 390px journeys complete 18 original actions, including eight native UI submissions, without app errors or root overflow. Four minimal policies legally continue both contexts. Earlier payment/custody evidence remains historical; comprehensive assurance, complete modes and deployment acceptance remain open.', ], related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Implemented', detail: 'Actual final Bank payer legs, one physical Banker phase grant and original-owner Mentat collection connect through fresh classic and supported native profiles, including the separate classic Homeworld occupation entry. Native/occupied receipts supply actual retained Bank amounts; user-ruled diversion and whole-invoice attribution preserve payer costs. Paid revival remains distinct from free awards; free Terror is not a payment.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts', 'game/homeworld-payment-income.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts'] }, + { area: 'Implementation', status: 'Implemented', detail: 'Fresh ordinary all-fourteen Skills initializes the existing deferred-income ledger automatically; original supported module and native overlay setup stays authoritative. Actual final Bank payer legs, one physical Banker phase grant and original-owner Mentat collection remain. Existing games are not retrofitted; the separate initializer/profile and opt-ins are removed.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts', 'game/leader-skill-profile.ts', 'game/homeworld-payment-income.ts', 'tools/prototype-room.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 threshold/phase/collection, original payment separation, provisional earned custody and per-payer composition are explicit. The user-ruled same-payer diversion, primary-first private attribution and whole-invoice low rounding are documented without universal publisher claims.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'The fresh Homeworld income entry passes292/292 in the frozen related18-file union, types/lint and build. Actual original world-to-world and Junction return Bank tariffs, low Kaitain auction income, isolated HWBANKQA CLI, private390px diversion and native wallet13-to-Mentat14/front0 are exercised. Existing classic/native and Tleilaxu paid/free/self/canceled boundaries remain source-bound evidence. Comprehensive assurance and deployed/full-module acceptance stay open.', evidence: ['tests/fixture-homeworld-banker-income.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/native-banker-income.test.ts', 'tests/prototype-room.test.ts', 'docs/SPICE_BANKER_RUNTIME.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Ordinary native World and Semuta programmes cover actual payment, physical trainer, deferred spendability, threshold/recipient exclusions, overlay continuation and once-only Mentat collection. Legacy played saves are not retrofitted. Two actual phone journeys and four legal policies exercise these contexts; this is not exhaustive combination, recovery, strategy or deployed acceptance.', evidence: ['tests/fixture-banker-world-default.ts', 'tests/banker-world-default.test.ts', 'tests/fixture-banker-overlay-default.ts', 'tests/banker-overlay-default.test.ts', 'tests/homeworld-banker-income.test.ts', 'tests/homeworld-banker-transport.test.ts', 'tests/homeworld-bank-payment.test.ts', 'tests/spice-banker-income.test.ts', 'tests/native-banker-income.test.ts', 'tests/prototype-room.test.ts', 'docs/SPICE_BANKER_RUNTIME.md'] }, ], }, { @@ -4951,7 +4951,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher 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 or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default 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. Mandatory normal deferred income is automatic in fresh supported ordinary all-fourteen Skills setups; existing games are not retrofitted. Earned-faction custody remains provisional.', 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way/Chemistry, or sealed/actually late-played Portable Snooper. Named protection stays distinct; copying the used role imports no activation, numeric modifier or late-play right. Sealed sources offer after reveal; a new late source may offer before the unresolved outcome, preserving prior false traitor decisions. No fresh opportunity follows a successful traitor declaration. The selected Worthless discards even after a win. Older base/printed-only battles keep their original source windows. Current World/Stronghold contexts retain independent original effects; own earned Carthag may add Snooper to a copied Shield but cannot create protection without a used source. A native Diplomat can protect its selected disc; captives grant no normal band. Surviving trained losers retain the separate printed/copied-strength retreat policy. Mirror/numeric inheritance remains separate.', 'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.', diff --git a/tests/advanced-moritani-skills-assassinate.test.ts b/tests/advanced-moritani-skills-assassinate.test.ts index 50659243..f58da6bf 100644 --- a/tests/advanced-moritani-skills-assassinate.test.ts +++ b/tests/advanced-moritani-skills-assassinate.test.ts @@ -51,10 +51,10 @@ function holdPhysicalTraitor(game: Game, identity: string): void { /** All14 and the original assassination state come from the fresh production * initializer. Only board/card positions are controlled after native setup. */ -function stagedGame(bankerIncome = false) { +function stagedGame() { const native = completedAdvancedNativeSkillsGame({ family: 'moritani', skillOwner: 'guild', requestedSkill: 'warmaster', - opponents: ['guild', 'emperor'], bankerIncome, + opponents: ['guild', 'emperor'], }); assert.equal(assignment(native, player(native, 'guild').id).skill, 'warmaster'); assert.ok(native.moritaniAssassinate); @@ -332,8 +332,8 @@ void test('an actual normal Moritani loss cannot assassinate when the winning tr custody(game); }); -void test('the fresh private Banker wrapper retains normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => { - let game = stagedGame(true); +void test('ordinary Skills retain normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => { + let game = stagedGame(); const guild = player(game, 'guild').id; const moritani = player(game, 'moritani').id; const trainer = assignment(game, guild).leader; diff --git a/tests/banker-overlay-default.test.ts b/tests/banker-overlay-default.test.ts new file mode 100644 index 00000000..4ee4c39e --- /dev/null +++ b/tests/banker-overlay-default.test.ts @@ -0,0 +1,190 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction } from '../game/engine'; +import { + advanceBankerOverlayToMentat, advanceBankerOverlayToPhase, bankerOverlayDeferred, + bankerOverlayInventory, bankerOverlayLedger, bankerOverlayPlayer, bankerOverlayWallets, + claimBankerOverlaySemuta, createBankerOverlayFixture, openBankerOverlayHajr, + passBankerOverlaySemuta, shipBankerOverlayIncome, type BankerOverlayOptions, +} from './fixture-banker-overlay-default'; + +const profiles: BankerOverlayOptions[] = [ + { advanced: false, tech: false, strongholds: false }, + { advanced: true, tech: false, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: true, strongholds: true }, +]; + +for (const profile of profiles) { + const label = `${profile.advanced ? 'Advanced' : 'Basic'}${profile.tech ? ' Tech' : ''}${profile.strongholds ? ' Strongholds' : ''}`; + void test(`${label} Semuta + Skills: the ordinary Richese Banker keeps its printed income for a real Guild self-shipment paid to the Bank until the native Mentat Pause`, () => { + const fixture = createBankerOverlayFixture(profile); + const before = bankerOverlayWallets(fixture.game); + const payer = before.find(row => row.id === fixture.payer)!; + const owner = before.find(row => row.id === fixture.owner)!; + assert.equal(fixture.kind, 'bank'); + assert.equal(fixture.amount, 4); + assert.equal(fixture.cost, 4, 'Four normal forces into empty sand cost four spice at the Guild half rate'); + assert.equal(fixture.payee, 'bank'); + assert.equal(fixture.grant, true); + assert.equal(fixture.game.semutaPreview, true); + assert.equal(fixture.game.spiceBankerIncomePreview, true); + assert.equal(bankerOverlayLedger(fixture.game, fixture.owner).preview, true); + assert.deepEqual(bankerOverlayLedger(fixture.game, fixture.owner).deferred, [], + 'No income exists before the real payment'); + assert.equal(payer.shipped, false); + assert.equal(fixture.game.active, fixture.payer); + + const paid = shipBankerOverlayIncome(fixture); + const ledger = bankerOverlayLedger(paid, fixture.owner); + const receipt = paid.spiceBankerIncome!.sources.at(-1)!; + assert.equal(paid.spiceBankerIncome!.sources.length, 1, 'Only the one real Bank payment is recorded'); + assert.equal(paid.spiceBankerIncome!.collections.length, 0); + assert.equal(receipt.source.kind, 'shipment'); + assert.equal(receipt.source.turn, 1); + assert.equal(receipt.source.phase, 5); + assert.deepEqual(receipt.source.bankLegs, [{ payer: fixture.payer, amount: fixture.cost }], + 'The deferred invoice keeps the exact real Bank leg of this payment'); + assert.equal(receipt.grant!.owner, fixture.owner, 'Another payer paid the Bank, so the printed grant is Richese’s'); + assert.equal(receipt.grant!.amount, 1); + assert.deepEqual(receipt.source.trainer!.assignment, + { skill: 'spice-banker', leader: fixture.trainer, owner: fixture.owner }); + assert.equal(receipt.source.trainer!.faceUp, true, 'The actual Banker trainer must be face up'); + assert.equal(receipt.source.trainer!.survived, true); + assert.deepEqual(ledger.deferred, [{ owner: fixture.owner, amount: 1 }], + 'The printed spice sits in front of the shield, unavailable until the Mentat Pause'); + assert.deepEqual(ledger.usedThisPhase, [fixture.owner]); + assert.equal(bankerOverlayDeferred(paid, fixture.owner), 1); + + // The original payment itself is unchanged and already spent this phase. + const after = bankerOverlayWallets(paid); + assert.equal(after.find(row => row.id === fixture.payer)!.spice, payer.spice - 4); + assert.equal(after.find(row => row.id === fixture.payer)!.reserves, payer.reserves - 4); + assert.equal(after.find(row => row.id === fixture.payer)!.shipped, true); + assert.equal(after.find(row => row.id === fixture.payer)!.moved, payer.moved); + assert.equal(after.find(row => row.id === fixture.owner)!.spice, owner.spice, + 'Deferred income is not spendable and must not change the Banker’s wallet'); + assert.equal(bankerOverlayPlayer(paid, fixture.payer).forces[fixture.key], fixture.amount); + assert.equal(paid.response, null, 'The Guild’s own shipment owes no Guild income response'); + assert.throws(() => applyAction(paid, fixture.payer, fixture.shipment), + 'The same native shipment cannot repeat its payment or income'); + + // The printed overlay continuation runs on a real fresh discard, then the + // original ordinary-card continuation resumes with the same income untouched. + const paused = openBankerOverlayHajr(paid, fixture); + const beforeOverlay = bankerOverlayLedger(paused, fixture.owner); + const claimed = claimBankerOverlaySemuta(paused, fixture); + assert.deepEqual(bankerOverlayLedger(claimed, fixture.owner), beforeOverlay, + 'Claiming through the original Semuta overlay must not create, move or duplicate Banker income'); + assert.deepEqual(claimed.spiceBankerIncome, paused.spiceBankerIncome); + + const collection = advanceBankerOverlayToPhase(claimed, 7, fixture.plan); + assert.deepEqual(bankerOverlayLedger(collection, fixture.owner).deferred, [{ owner: fixture.owner, amount: 1 }], + 'The printed spice must still be deferred through the ordinary Collection phase'); + const balance = bankerOverlayPlayer(collection, fixture.owner).spice; + const mentat = advanceBankerOverlayToMentat(collection, fixture.plan); + const mentatLedger = bankerOverlayLedger(mentat, fixture.owner); + assert.deepEqual(mentatLedger.deferred, [], 'The actual Mentat Pause collects the deferred spice'); + assert.deepEqual(mentatLedger.usedThisPhase, []); + assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance + 1, + 'The Mentat Pause credits exactly the one printed spice'); + assert.equal(mentat.spiceBankerIncome!.collections.length, 1); + assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, [{ owner: fixture.owner, amount: 1 }]); + assert.deepEqual(mentat.spiceBankerIncome!.collections[0].grants, [receipt.cursor]); + assert.equal(mentat.spiceBankerIncome!.sources.length, 1); + assert.equal(mentat.pendingTreacheryDiscard, null); + assert.equal(mentat.response, null); + assert.equal(mentat.decision, null); + assert.equal(mentat.status, 'playing'); + // Original overlay and card custody complete: the claimed card stays claimed. + assert.equal(bankerOverlayPlayer(mentat, fixture.owner).hand.filter(card => card.id === fixture.hajr).length, 1); + assert.equal(mentat.discard.filter(card => card.id === fixture.semuta).length, 1); + assert.deepEqual(bankerOverlayInventory(mentat), bankerOverlayInventory(fixture.game)); + }); +} + +void test('Basic Semuta + Skills: the same real Guild self-shipment below the printed band records no grant', () => { + const fixture = createBankerOverlayFixture({ kind: 'below', semuta: 'pass' }); + assert.equal(fixture.amount, 3); + assert.equal(fixture.cost, 3); + assert.equal(fixture.payee, 'bank'); + assert.equal(fixture.grant, false); + const before = bankerOverlayWallets(fixture.game); + const payer = before.find(row => row.id === fixture.payer)!; + const owner = before.find(row => row.id === fixture.owner)!; + const paid = shipBankerOverlayIncome(fixture); + assert.equal(paid.spiceBankerIncome!.sources.length, 1, + 'The real Bank payment is still recorded, without the printed grant'); + assert.equal(paid.spiceBankerIncome!.sources[0].grant, null); + assert.deepEqual(paid.spiceBankerIncome!.sources[0].source.bankLegs, [{ payer: fixture.payer, amount: 3 }]); + assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).deferred, []); + assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).usedThisPhase, []); + assert.equal(bankerOverlayPlayer(paid, fixture.payer).spice, payer.spice - 3); + + const paused = openBankerOverlayHajr(paid, fixture); + const passed = passBankerOverlaySemuta(paused, fixture); + assert.deepEqual(passed.spiceBankerIncome, paid.spiceBankerIncome, + 'Declining the printed overlay must not create Banker income'); + const collection = advanceBankerOverlayToPhase(passed, 7, fixture.plan); + const balance = bankerOverlayPlayer(collection, fixture.owner).spice; + assert.equal(balance, owner.spice, + 'This Richese holds no board forces, so ordinary Collection does not change its wallet'); + const mentat = advanceBankerOverlayToMentat(collection, fixture.plan); + assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance, + 'The Mentat Pause credits nothing without a printed grant'); + assert.equal(mentat.spiceBankerIncome!.collections.length, 1); + assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, []); + assert.deepEqual(mentat.spiceBankerIncome!.collections[0].grants, []); + assert.equal(mentat.discard.filter(card => card.id === fixture.hajr).length, 1); + assert.equal(bankerOverlayPlayer(mentat, fixture.owner).hand.filter(card => card.id === fixture.semuta).length, 1); + assert.deepEqual(bankerOverlayInventory(mentat), bankerOverlayInventory(fixture.game)); +}); + +void test('Basic Semuta + Skills: another payer’s real four-spice shipment to the seated Guild player is not a Bank payment', () => { + const fixture = createBankerOverlayFixture({ kind: 'player' }); + assert.equal(fixture.payer, 'a'); + assert.equal(fixture.payerFaction, 'atreides'); + assert.equal(fixture.amount, 2); + assert.equal(fixture.cost, 4, 'Two normal forces at the full desert price still pay four spice'); + assert.equal(fixture.payee, 'guild'); + assert.equal(fixture.grant, false); + const before = bankerOverlayWallets(fixture.game); + const atreides = before.find(row => row.id === 'a')!; + const guild = before.find(row => row.id === 'g')!; + const paid = shipBankerOverlayIncome(fixture); + assert.equal(paid.spiceBankerIncome!.sources.length, 0, + 'A price paid to a player is not a Bank payment, so no invoice is recorded'); + assert.equal(paid.spiceBankerIncome!.collections.length, 0); + assert.deepEqual(bankerOverlayLedger(paid, fixture.owner).deferred, []); + assert.equal(bankerOverlayPlayer(paid, 'a').spice, atreides.spice - 4); + assert.equal(bankerOverlayPlayer(paid, 'g').spice, guild.spice + 4, + 'The seated Guild still receives its ordinary shipment income'); + assert.equal(bankerOverlayPlayer(paid, 'a').shipped, true); + assert.equal(bankerOverlayPlayer(paid, 'a').forces[fixture.key], 2); + + const paused = openBankerOverlayHajr(paid, fixture); + const claimed = claimBankerOverlaySemuta(paused, fixture); + assert.equal(claimed.spiceBankerIncome!.sources.length, 0); + const collection = advanceBankerOverlayToPhase(claimed, 7, fixture.plan); + const balance = bankerOverlayPlayer(collection, fixture.owner).spice; + const mentat = advanceBankerOverlayToMentat(collection, fixture.plan); + assert.equal(bankerOverlayPlayer(mentat, fixture.owner).spice, balance); + assert.deepEqual(mentat.spiceBankerIncome!.collections[0].credits, []); + assert.equal(mentat.spiceBankerIncome!.collections.length, 1); +}); + +void test('Basic Semuta + Skills: supplied Banker authority on the ordinary native shipment is rejected without change', () => { + const fixture = createBankerOverlayFixture({ semuta: 'pass' }); + const before = structuredClone(fixture.game); + for (const key of ['actor', 'bankerIncome', 'paymentSource', 'spiceBankerIncome', 'bankLegs', 'trainer']) { + assert.throws(() => applyAction(fixture.game, fixture.payer, + { ...fixture.shipment, [key]: 'supplied' })); + assert.deepEqual(fixture.game, before); + } + const paid = shipBankerOverlayIncome(fixture); + assert.deepEqual(paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs, + [{ payer: fixture.payer, amount: fixture.cost }], + 'Only the original native transaction can supply the deferred Bank legs'); + assert.throws(() => applyAction(paid, fixture.payer, fixture.shipment)); + assert.equal(paid.spiceBankerIncome!.sources.length, 1); +}); diff --git a/tests/banker-world-default.test.ts b/tests/banker-world-default.test.ts new file mode 100644 index 00000000..031e07e7 --- /dev/null +++ b/tests/banker-world-default.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { quotePhaseResources } from '../game/phase-resource-quote'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { + advanceBankerWorldDefault, bankerWorldBeforeMentat, bankerWorldClean, bankerWorldPlayer, + bankerWorldWallets, createBankerWorldDefaultFixture, payBankerWorldDefault, +} from './fixture-banker-world-default'; + +void test('ordinary all14 native World: explicit Guild homeworld withdrawal pays Bank four and defers one without Heighliners income', () => { + const f = createBankerWorldDefaultFixture(), before = f.game; + assert.equal(before.status, 'playing'); + assert.equal(before.turn, 1); + assert.equal(before.phase, 5); + assert.equal(before.active, f.payer); + assert.ok(bankerWorldClean(before)); + assert.equal(bankerWorldPlayer(before, f.payer).shipped, false); + assert.ok(before.homeworlds?.custody && before.nexusCards?.cards && before.discoveries && before.strongholdCards); + const trained = before.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!; + assert.equal(trained.owner, f.owner); + assert.equal(trained.leader, f.trainer); + assert.equal(bankerWorldPlayer(before, f.owner).faction, 'richese'); + assert.equal(bankerWorldPlayer(before, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false); + assert.equal(f.shipment.actor, f.payer); + assert.deepEqual(f.shipment.action.homeworldSources, { 'homeworld:guild': { normal: 4, elite: 0 } }); + assert.deepEqual(f.shipment.action.homeworldSources, nativeShipmentSources(viewGame(before, f.payer), 4, 0)); + assert.equal(f.shipment.action.type, 'ship'); + assert.equal(f.shipment.action.amount, 4); + assert.equal(f.shipment.action.elite, 0); + const key = `${String(f.shipment.action.territory)}:${String(f.shipment.action.sector)}`; + assert.ok(before.players.every(p => !p.forces[key])); + assert.deepEqual(viewGame(before, f.owner).spiceBankerIncome!.deferred, []); + + const wallets = bankerWorldWallets(before), reserves = bankerWorldPlayer(before, f.payer).reserves; + const paid = payBankerWorldDefault(f); + assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 4 }); + assert.equal(bankerWorldPlayer(paid, f.payer).reserves, reserves - 4); + assert.equal(bankerWorldPlayer(paid, f.payer).forces[key], 4); + assert.equal(bankerWorldPlayer(paid, f.payer).shipped, true); + assert.equal(paid.phase, 5); + assert.equal(paid.active, f.payer); + assert.equal(paid.response, null); + // Guild's own off-planet shipment is explicitly excluded by Heighliners. + // Tech custody/accrual is not reassigned to make this experiment pay out. + assert.deepEqual(paid.techTokens, before.techTokens); + assert.equal(paid.techTokens!.heighliners.spice, 0); + const sources = paid.spiceBankerIncome!.sources.slice(before.spiceBankerIncome!.sources.length); + assert.equal(sources.length, 1); + assert.equal(sources[0].source.kind, 'shipment'); + assert.equal(sources[0].source.turn, 1); + assert.equal(sources[0].source.phase, 5); + assert.deepEqual(sources[0].source.bankLegs, [{ payer: f.payer, amount: 4 }]); + assert.equal(sources[0].source.trainer!.assignment.leader, f.trainer); + assert.equal(sources[0].source.trainer!.faceUp, true); + assert.equal(sources[0].source.trainer!.captured, false); + assert.equal(sources[0].grant!.owner, f.owner); + assert.equal(sources[0].grant!.amount, 1); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome, { + deferred: [{ owner: f.owner, amount: 1 }], usedThisPhase: [f.owner], + }); +}); + +void test('pending normal Banker spice cannot fund a real bribe; the final original Collection ready collects exactly once at Mentat 1', () => { + const f = createBankerWorldDefaultFixture(), paid = payBankerWorldDefault(f); + const owner = bankerWorldPlayer(paid, f.owner), controls = viewGame(paid, f.owner).bribeOptions; + assert.equal(controls.blocked, null); + assert.equal(controls.available, owner.spice); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + const frozen = structuredClone(paid); + assert.throws(() => applyAction(paid, f.owner, + { type: 'bribe', target: 'emperor', amount: owner.spice + 1 }), /Spice/); + assert.deepEqual(paid, frozen); + // Same legal native action/target succeeds at the actual wallet boundary. + const affordable = applyAction(paid, f.owner, { type: 'bribe', target: 'emperor', amount: owner.spice }); + assert.equal(bankerWorldPlayer(affordable, f.owner).spice, 0); + assert.equal(bankerWorldPlayer(affordable, 'emperor').bribes, owner.spice); + assert.deepEqual(viewGame(affordable, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + + // Continue the unmodified paid branch: no synthetic army, ownership, phase or + // wallet edits, and no bribe receipt enters this Mentat income baseline. + const boundary = bankerWorldBeforeMentat(paid); + assert.equal(boundary.beforeCollection.phase, 5); + assert.equal(boundary.collectionAction.action.type, 'endMovement'); + assert.equal(boundary.collectionAction.actor, boundary.beforeCollection.active); + assert.ok(bankerWorldClean(boundary.beforeCollection)); + // All native Tech/World shipment effects have already settled. Collection's + // independently quoted stronghold/desert money is distinct from the front. + assert.deepEqual(quotePhaseResources(boundary.beforeCollection).credits, []); + const nativeCollection = quoteSpiceCollection(boundary.beforeCollection); + assert.deepEqual(bankerWorldWallets(boundary.collection), + Object.fromEntries(nativeCollection.receipts.map(r => [r.player, r.balance]))); + assert.deepEqual(viewGame(boundary.collection, f.owner).spiceBankerIncome!.deferred, [{ owner: f.owner, amount: 1 }]); + assert.equal(boundary.game.turn, 1); + assert.equal(boundary.game.phase, 7); + assert.ok(bankerWorldClean(boundary.game)); + assert.equal(boundary.game.ready.length, boundary.game.players.length - 1); + assert.deepEqual(boundary.action.action, { type: 'ready' }); + assert.ok(!boundary.game.ready.includes(boundary.action.actor)); + assert.deepEqual(bankerWorldWallets(boundary.game), bankerWorldWallets(boundary.collection)); + assert.equal(boundary.game.spiceBankerIncome!.collections.length, 0); + const wallets = bankerWorldWallets(boundary.game); + const mentat = applyAction(boundary.game, boundary.action.actor, boundary.action.action); + assert.equal(mentat.turn, 1); + assert.equal(mentat.phase, 8); + assert.deepEqual(bankerWorldWallets(mentat), { ...wallets, [f.owner]: wallets[f.owner] + 1 }); + assert.deepEqual(viewGame(mentat, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(mentat.spiceBankerIncome!.collections.length, 1); + const collection = mentat.spiceBankerIncome!.collections[0]; + assert.equal(collection.turn, 1); + assert.equal(collection.phase, 8); + assert.deepEqual(collection.credits, [{ owner: f.owner, amount: 1 }]); + assert.deepEqual(collection.grants, mentat.spiceBankerIncome!.sources.filter(r => r.grant).map(r => r.cursor)); + const nextTurn = advanceBankerWorldDefault(mentat, g => g.turn === 2 && g.phase === 0 && bankerWorldClean(g)); + assert.deepEqual(nextTurn.spiceBankerIncome!.collections, mentat.spiceBankerIncome!.collections); + assert.deepEqual(bankerWorldWallets(nextTurn), bankerWorldWallets(mentat)); + assert.deepEqual(viewGame(nextTurn, f.owner).spiceBankerIncome!.deferred, []); +}); + +void test('ordinary World Guild self-shipment below four still debits Bank and withdraws physical homeworld forces, but grants nothing', () => { + const f = createBankerWorldDefaultFixture(), wallets = bankerWorldWallets(f.game); + const sources = nativeShipmentSources(viewGame(f.game, f.payer), 3, 0); + assert.ok(sources); + const paid = payBankerWorldDefault(f, { ...f.shipment.action, amount: 3, homeworldSources: sources }); + assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 3 }); + assert.equal(bankerWorldPlayer(paid, f.payer).reserves, bankerWorldPlayer(f.game, f.payer).reserves - 3); + assert.equal(bankerWorldPlayer(paid, f.payer).forces[`${String(f.shipment.action.territory)}:${String(f.shipment.action.sector)}`], 3); + const receipt = paid.spiceBankerIncome!.sources.at(-1)!; + assert.equal(receipt.source.kind, 'shipment'); + assert.deepEqual(receipt.source.bankLegs, [{ payer: f.payer, amount: 3 }]); + assert.equal(receipt.grant, null); + assert.deepEqual(viewGame(paid, f.owner).spiceBankerIncome!.deferred, []); + assert.deepEqual(paid.techTokens, f.game.techTokens); +}); + +void test('a played ordinary Skills save without income metadata keeps its legacy payment and Mentat behavior', () => { + const f = createBankerWorldDefaultFixture(); + // Recreate the pre-cutover serialized shape, not a fresh setup option. + const legacy = structuredClone(f.game); + delete legacy.spiceBankerIncomePreview; + delete legacy.spiceBankerIncome; + const wallets = bankerWorldWallets(legacy); + const paid = payBankerWorldDefault({ ...f, game: legacy }); + assert.deepEqual(bankerWorldWallets(paid), { ...wallets, [f.payer]: wallets[f.payer] - 4 }); + assert.equal(paid.spiceBankerIncomePreview, undefined); + assert.equal(paid.spiceBankerIncome, undefined); + assert.equal(viewGame(paid, f.owner).spiceBankerIncome, null); + const boundary = bankerWorldBeforeMentat(paid); + const mentat = applyAction(boundary.game, boundary.action.actor, boundary.action.action); + assert.equal(mentat.phase, 8); + assert.deepEqual(bankerWorldWallets(mentat), bankerWorldWallets(boundary.game)); + assert.equal(mentat.spiceBankerIncome, undefined); +}); diff --git a/tests/classic-nexus-banker-income.test.ts b/tests/classic-nexus-banker-income.test.ts index 0d535641..40027423 100644 --- a/tests/classic-nexus-banker-income.test.ts +++ b/tests/classic-nexus-banker-income.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyAction, initializeSpiceBankerIncomeGameForAudit, viewGame } from '../game/engine'; +import { applyAction, initializeLeaderSkillsGameForAudit, viewGame } from '../game/engine'; import { botActions } from '../game/bots'; import { validateLeaderSkills } from '../game/leader-skills'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; @@ -84,7 +84,7 @@ void test('Nexus Banker keeps available wallet budgets and existing legal policy assert.equal(nexusBankerPlayer(next, f.owner).spice, nexusBankerPlayer(f.paid, f.owner).spice); assert.deepEqual(viewGame(next, f.owner).spiceBankerIncome!.deferred, [{owner:'a',amount:1}]); const snapshot = structuredClone(f.paid); - assert.throws(()=>initializeSpiceBankerIncomeGameForAudit(f.paid)); + assert.throws(()=>initializeLeaderSkillsGameForAudit(f.paid)); assert.deepEqual(f.paid,snapshot); assert.throws(()=>applyAction(f.paid,f.owner,{type:'collectSpiceBankerIncome'})); assert.deepEqual(f.paid,snapshot); diff --git a/tests/classic-skills-stronghold-runtime.test.ts b/tests/classic-skills-stronghold-runtime.test.ts index 49954c8d..461598d8 100644 --- a/tests/classic-skills-stronghold-runtime.test.ts +++ b/tests/classic-skills-stronghold-runtime.test.ts @@ -80,7 +80,7 @@ void test('a fresh authenticated original lobby retains human IDs and input stat lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true }); lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true }); const original = structuredClone(lobby); - const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, bankerIncome: true, ownerDial: 6, support: 6 }); + const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, ownerDial: 6, support: 6 }); assert.deepEqual(lobby, original); assert.deepEqual(fixture.initial.players.map(seat => seat.id), original.players.map(seat => seat.id)); assert.equal(fixture.owner, 'human-guild'); @@ -99,7 +99,7 @@ for (const scenario of [ { support: 6, dial: 6, own: 4, casualties: 6 }, ]) { void test(`Arrakeen original support ${scenario.support}: actual own ${scenario.own}, not bank-funded support, controls Banker while trained Suk rescues physical losses`, () => { - const fixture = createClassicSkillsStrongholdFixture({ bankerIncome: true, skill: 'suk-graduate', band: 'skilled', + const fixture = createClassicSkillsStrongholdFixture({ skill: 'suk-graduate', band: 'skilled', ownerDial: scenario.dial, support: scenario.support, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' }); const revealed = revealClassicSkillsStrongholdBattle(fixture); const quote = quoteClassicSkillsStrongholdBattle(revealed); @@ -138,7 +138,7 @@ for (const scenario of [ } void test('four real policies physically choose original Suk rescue before winner-card cleanup and the actual losing-owner Tech reward', () => { - const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, bankerIncome: true, skill: 'suk-graduate', band: 'skilled', + const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, skill: 'suk-graduate', band: 'skilled', ownerDial: 6, support: 6, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' }); const revealed = revealClassicSkillsStrongholdBattle(fixture); const originalTokens = ownedTech(revealed.techTokens, fixture.opponent); diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index 104e9f1a..e041fe8e 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -54,10 +54,9 @@ void test('Discovery cannot redeal started components or silently remove another void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => { const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']); assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError); - const overlays = Array.from({ length: 2 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); - overlays[0].advanced = false; overlays[0].strongholdCards = createStrongholdCards(); - overlays[1].spiceBankerIncomePreview = true; - for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError); + const basicStrongholds = lobby(['guild', 'emperor', 'harkonnen'], []); + basicStrongholds.advanced = false; basicStrongholds.strongholdCards = createStrongholdCards(); + assert.throws(() => initializeLeaderSkillsGameForAudit(basicStrongholds), RuleError); const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens(); assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError); }); diff --git a/tests/discovery-native-e2-skills-runtime.test.ts b/tests/discovery-native-e2-skills-runtime.test.ts index b3971a20..4912510b 100644 --- a/tests/discovery-native-e2-skills-runtime.test.ts +++ b/tests/discovery-native-e2-skills-runtime.test.ts @@ -68,7 +68,6 @@ for (const advanced of [false, true]) for (const tech of [false, true]) { assert.ok(player(f.game, f.richese).hand.some(c => c.id === f.green)); assert.equal(f.game.richeseCache!.some(c => c.id === f.green), false); assert.ok(f.game.richeseCache!.length > 0, 'Two-turn bounded recipes do not bypass the exhausted-cache ruling.'); - assert.equal(f.game.spiceBankerIncomePreview, undefined); if (tech) { assert.deepEqual(f.setup.techTokens, createTechTokens()); assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.techTokens![token.id].owner !== null)); diff --git a/tests/ecaz-skills-modules-runtime.test.ts b/tests/ecaz-skills-modules-runtime.test.ts index 056ea99c..5e193509 100644 --- a/tests/ecaz-skills-modules-runtime.test.ts +++ b/tests/ecaz-skills-modules-runtime.test.ts @@ -102,8 +102,8 @@ void test('all four real legal policies rescue trained native Ecaz counters and } }); -void test('real Arrakeen subsidy and separately opted-in normal Banker count only the opposing paid Bank leg', () => { - const f = fixture({ kind: 'income', band: 'normal', bankerIncome: true, tech: true, stronghold: true, opponentDial: 4, defense: true }); +void test('real Arrakeen subsidy and ordinary normal Banker count only the opposing paid Bank leg', () => { + const f = fixture({ kind: 'income', band: 'normal', tech: true, stronghold: true, opponentDial: 4, defense: true }); const before = reveal(f), sources = before.spiceBankerIncome!.sources.length; const game = settle(before, 'cards'); assert.equal(game.lastBattleContext!.winner, f.owner); diff --git a/tests/fixture-advanced-native-skills.ts b/tests/fixture-advanced-native-skills.ts index db5ec73b..d3db1ba7 100644 --- a/tests/fixture-advanced-native-skills.ts +++ b/tests/fixture-advanced-native-skills.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import type { FactionId } from '../game/catalog'; @@ -14,7 +14,6 @@ export type AdvancedNativeSkillsOptions = { requestedSkill?: LeaderSkillId; skillOwner?: FactionId; opponents?: readonly FactionId[]; - bankerIncome?: boolean; /** Richese and Ecaz also exercise their Basic native rule bands. */ rules?: 'basic' | 'advanced'; /** Continue this actual authenticated lobby/setup without replacing its seats. */ @@ -76,7 +75,7 @@ export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkill assert.equal(game.status, 'lobby'); for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); return withSkillShuffle(options.requestedSkill ?? 'suk-graduate', game.players.findIndex(p => p.id === owner.id), - () => options.bankerIncome ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game)); + () => initializeLeaderSkillsGameForAudit(game)); } /** Finish original setup decisions from the actual own-seat projections. */ diff --git a/tests/fixture-banker-overlay-default.ts b/tests/fixture-banker-overlay-default.ts new file mode 100644 index 00000000..4cb064ad --- /dev/null +++ b/tests/fixture-banker-overlay-default.ts @@ -0,0 +1,439 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { botEntryAllowed } from '../game/bot-mobility'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import type { Card } from '../game/cards'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech'; + +export type BankerOverlayKind = 'bank' | 'below' | 'player'; +export type BankerOverlaySemuta = 'claim' | 'pass'; +export type BankerOverlayOptions = { + /** Basic by default; Advanced adds the Guild's own native shipment stop. */ + advanced?: boolean; + /** Optional original Tech Tokens in the same ordinary Skills setup. */ + tech?: boolean; + /** Optional original Advanced Stronghold Cards. */ + strongholds?: boolean; + /** Which real native shipment actually funds the Bank. */ + kind?: BankerOverlayKind; + /** Conserved normal reserve forces; the printed desert price follows the payer's rate. */ + amount?: number; + /** Conserved printed Semuta reaction at the real Hajr discard. */ + semuta?: BankerOverlaySemuta; +}; +export type BankerOverlayPlan = { + /** Play Atreides' conserved printed Hajr when its own movement begins. */ + hajr?: boolean; + /** Seat committing its own printed Semuta Drug at that fresh discard; null passes. */ + claim?: string | null; +}; +export type BankerOverlayFixture = { + /** Real phase 5 position: the payer is active and has not shipped yet. */ + game: Game; + /** Fresh ready lobby before the ordinary Semuta Skills initializer. */ + initial: Game; + /** Immediately after that initializer: ledger stamped, overlays undealt. */ + setup: Game; + /** Same position as `game`, before any conserved printed-card staging. */ + beforePayment: Game; + actions: RicheseSkillsTechStep[]; + staging: string[]; + owner: string; + trainer: string; + payer: string; + payerFaction: Player['faction']; + holder: string; + semuta: string; + hajr: string; + territory: string; + sector: number; + key: string; + amount: number; + cost: number; + /** Actual printed recipient of the real shipment price. */ + payee: 'bank' | 'guild'; + /** Printed entitlement of this exact payment. */ + grant: boolean; + kind: BankerOverlayKind; + advanced: boolean; + tech: boolean; + strongholds: boolean; + shipment: Action; + /** Original Advanced Guild stop, acknowledged before any payment. */ + allow: RicheseSkillsTechStep | null; + plan: BankerOverlayPlan; +}; + +export function bankerOverlayPlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original ${id} seat`); + return player; +} +/** No pending opening, response, decision or committed fresh discard. */ +export const bankerOverlayClean = (game: Game): boolean => + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; +/** One physical slot per original Treachery identity, including the Richese cache. */ +export function bankerOverlayInventory(game: Game): string[] { + const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'One physical slot per original Treachery identity'); + return ids; +} +/** Wallets, counters, clocks, flags and skill custody; original hands excluded. */ +export function bankerOverlayPosition(game: Game) { + return structuredClone({ + status: game.status, turn: game.turn, phase: game.phase, active: game.active, + order: game.order, ready: game.ready, movementRemaining: game.movementRemaining, + storm: game.storm, hajr: game.hajr, pendingShipment: game.pendingShipment, + karamaShipping: game.karamaShipping, guildRateBlocked: game.guildRateBlocked, + response: game.response, decision: game.decision, leaderSkills: game.leaderSkills, + players: game.players.map(player => ({ + id: player.id, faction: player.faction, ally: player.ally, spice: player.spice, + bribes: player.bribes, forces: player.forces, reserves: player.reserves, tanks: player.tanks, + elites: player.elites, leaders: player.leaders, shipped: player.shipped, moved: player.moved, + revived: player.revived, traitors: player.traitors, + })), + }); +} +export function bankerOverlayWallets(game: Game) { + return game.players.map(player => ({ + id: player.id, faction: player.faction, spice: player.spice, bribes: player.bribes, + reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved, + hand: player.hand.map(card => card.id), + })); +} +/** Own-view ledger projection plus the committed history, for exact comparisons. */ +export function bankerOverlayLedger(game: Game, owner: string) { + const projection = viewGame(game, owner).spiceBankerIncome; + assert.ok(projection, `The ordinary Skills overlay must keep ${owner}'s Spice Banker income projection.`); + return { + preview: game.spiceBankerIncomePreview === true, + state: structuredClone(game.spiceBankerIncome ?? null), + deferred: projection.deferred, + usedThisPhase: projection.usedThisPhase, + }; +} +export function bankerOverlayDeferred(game: Game, owner: string): number { + return bankerOverlayLedger(game, owner).deferred.find(row => row.owner === owner)?.amount ?? 0; +} + +/** Existing ordinary native policy plus the two genuine overlay continuations: + * the original Advanced Guild timing choice and the printed Semuta reaction. */ +export function nextBankerOverlayNativeStep( + game: Game, plan: BankerOverlayPlan = {}, +): RicheseSkillsTechStep { + const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction; + if (pending && reaction?.stage === 'offer') { + const event = pending.batch.event; + const claim = plan.claim ?? null; + if (claim && !reaction.passed.includes(claim)) { + const own = viewGame(game, claim).semutaReaction; + if (own?.stage === 'offer' && own.canCommit) return { actor: claim, action: { type: 'semutaCommit', event } }; + } + const actor = game.players.find(player => !reaction.passed.includes(player.id)); + assert.ok(actor, 'The neutral Semuta offer must keep one original waiting seat.'); + return { actor: actor.id, action: { type: 'semutaPass', event } }; + } + if (pending && reaction?.stage === 'select') { + const card = reaction.candidates[0]; + assert.ok(card, 'A committed Semuta claim must keep one private fresh candidate.'); + return { actor: reaction.player, action: { type: 'semutaSelect', event: pending.batch.event, card } }; + } + // Conserved own movement: the Guild may take the current combined turn; the + // ordinary order then resumes with the same physical queue. + if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: true } }; + if (plan.hajr && game.status === 'playing' && game.phase === 5 && bankerOverlayClean(game)) { + const holder = game.players.find(player => player.id === 'a' && player.id === game.active && + !game.hajr.includes(player.id) && player.hand.some(card => card.effect === 'hajr')); + if (holder) { + const card = holder.hand.find(candidate => candidate.effect === 'hajr')!; + return { actor: holder.id, action: { type: 'card', card: card.id } }; + } + } + return nextRicheseSkillsTechNativeStep(game); +} +function step(game: Game, plan: BankerOverlayPlan, actions?: RicheseSkillsTechStep[]): Game { + const next = nextBankerOverlayNativeStep(game, plan); + actions?.push(next); + return applyAction(game, next.actor, next.action); +} +/** Apply only original native actions until the requested boundary holds. */ +export function advanceBankerOverlay( + state: Game, until: (game: Game) => boolean, plan: BankerOverlayPlan = {}, + actions?: RicheseSkillsTechStep[], +): Game { + let game = state; + for (let n = 0; !until(game) && n < 1500; n++) { + assert.notEqual(game.status, 'finished', 'The genuine native path must reach its requested boundary'); + game = step(game, plan, actions); + } + assert.ok(until(game), 'The genuine native path must reach its requested boundary'); + return game; +} +export function advanceBankerOverlayToPhase( + state: Game, phase: number, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[], +): Game { + let game = state; + const turn = state.turn; + for (let n = 0; (game.phase !== phase || !bankerOverlayClean(game)) && + game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions); + assert.equal(game.turn, turn); + assert.equal(game.phase, phase); + assert.ok(bankerOverlayClean(game)); + return game; +} +/** Stop at the real native Mentat Pause entry; its own opening has already collected. */ +export function advanceBankerOverlayToMentat( + state: Game, plan: BankerOverlayPlan = {}, actions?: RicheseSkillsTechStep[], +): Game { + let game = state; + const turn = state.turn; + for (let n = 0; (game.phase !== 8 || game.phaseOpening) && + game.turn === turn && game.status === 'playing' && n < 1500; n++) game = step(game, plan, actions); + assert.equal(game.turn, turn); + assert.equal(game.phase, 8); + return game; +} + +/** Fix the initial all14 offer and native setup scalar entropy; leave UUIDs native. + * The printed Spice Banker lands in the first seat's own two-card offer. */ +export function withBankerOverlaySkillOffer(initialize: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + const target = LEADER_SKILL_CARDS.findIndex(card => card.id === 'spice-banker'); + assert.ok(target >= 0, 'The original all14 deck must keep the Spice Banker card.'); + let index = LEADER_SKILL_CARDS.length - 1; + crypto.getRandomValues = (array: V): V => { + if (!(array instanceof Uint32Array)) return Reflect.apply(original, crypto, [array]) as V; + array.fill(0xffffffff); + if (index > 0) { + assert.equal(array.length, 1, 'The initial all14 shuffle uses single scalar draws.'); + array[0] = index === target ? 0 : 0xffffffff; + index--; + } + return array; + }; + try { return initialize(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +/** Conserve one canonical printed card into its owner's hand from the first + * original slot already holding it; never mint a second identity. */ +function takePrintedCard( + game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], +): string { + const holder = bankerOverlayPlayer(game, actor); + const held = holder.hand.find(matches); + if (held) return held.id; + assert.ok(holder.hand.length < handLimit(holder), + 'The conserved hand must keep room for its own printed overlay card.'); + const zones: [Card[], string][] = [ + [game.deck, 'undealt Treachery deck'], + [game.discard, 'discard pile'], + [game.richeseCache ?? [], 'Richese cache'], + ]; + for (const [zone, name] of zones) { + const index = zone.findIndex(matches); + if (index < 0) continue; + const card = zone.splice(index, 1)[0]; + holder.hand.push(card); + staging.push(`Conserved canonical ${card.id} from the ${name} into ${actor}'s hand.`); + return card.id; + } + const donor = game.players.find(player => player.id !== actor && player.hand.some(matches)); + assert.ok(donor, 'The requested printed overlay card must keep one original custody slot.'); + const index = donor.hand.findIndex(matches); + const card = donor.hand.splice(index, 1)[0]; + holder.hand.push(card); + staging.push(`Conserved canonical ${card.id} from ${donor.id}'s original dealt hand into ${actor}'s hand.`); + return card.id; +} + +/** Genuine Basic/Advanced Semuta + Skills setup, first turn and the payer's own + * Movement turn. Initial setup scalar entropy, the first two unplayed Spice Cards + * and two conserved printed overlay cards are controlled and explicitly disclosed. + * Wallets, counters, clocks, turns, phases, skills, receipts and assignments stay native. */ +export function createBankerOverlayFixture(options: BankerOverlayOptions = {}): BankerOverlayFixture { + const kind = options.kind ?? 'bank'; + const advanced = options.advanced ?? false; + const tech = options.tech ?? false; + const strongholds = options.strongholds ?? false; + const semuta = options.semuta ?? 'claim'; + const payee = kind === 'player' ? 'guild' as const : 'bank' as const; + assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules.'); + let game = createGame('BANKEROVERLAY', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + const initial = structuredClone(game); + // The ordinary Semuta Skills delegate stamps the mandatory income ledger first, + // then sets its own original overlay flag. + game = withBankerOverlaySkillOffer(() => initializeSemutaGameForAudit(game, true)); + assert.equal(game.semutaPreview, true, 'The original Semuta overlay flag must remain set.'); + assert.equal(game.spiceBankerIncomePreview, true, + 'Ordinary Leader Skills setup must stamp the income ledger before the overlay flag.'); + assert.ok(game.spiceBankerIncome, 'The fresh ordinary setup must commit one income ledger.'); + assert.ok(viewGame(game, 'r').spiceBankerIncome, 'Each own seat must project that ledger.'); + assert.ok(game.leaderSkills?.offers['r']?.cards.includes('spice-banker'), + 'The controlled original all14 shuffle must offer the Spice Banker to the first seat.'); + const setup = structuredClone(game); + const actions: RicheseSkillsTechStep[] = [], staging: string[] = []; + staging.push('Initial all14 offers and native setup scalar entropy are controlled; UUIDs remain native. Components are not replaced.'); + const plan: BankerOverlayPlan = { hajr: false, claim: null }; + for (let n = 0; game.status === 'setup' && n < 250; n++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor]; + const own = viewGame(game, actor).leaderSkills!; + const skill = offer.cards.includes('spice-banker') ? 'spice-banker' + : offer.cards.find(card => !own.unavailableSkills?.[card]); + assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill], + 'The actual original offer must supply one legal skill.'); + const eligible = new Set(own.eligibleLeaders.map(leader => leader.id)); + const leader = bankerOverlayPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id)) + .sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader, 'The actual original living eligible disc must supply training.'); + const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id }; + actions.push({ actor, action }); + game = applyAction(game, actor, action); + } else game = step(game, plan, actions); + } + assert.equal(game.status, 'playing'); + const assignment = game.leaderSkills!.assignments.find(entry => entry.skill === 'spice-banker'); + assert.ok(assignment, 'The ordinary native setup must assign the real Spice Banker skill.'); + const owner = assignment.owner; + assert.equal(owner, 'r', 'The real Spice Banker must be trained by the first offered Richese seat.'); + assert.equal(bankerOverlayPlayer(game, owner).leaders.find(leader => leader.id === assignment.leader)!.dead, false, + 'The actual Banker trainer must still be alive.'); + // Prevent an incidental first-turn worm by conserving the first two unplayed blows. + for (const position of [0, 1]) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card); + assert.ok(index >= position, 'One original unplayed Spice Card must remain'); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + staging.push('Conserved only the first two original unplayed Spice Cards as ordinary land blows.'); + const payer = kind === 'player' ? 'a' : 'g'; + const amount = options.amount ?? (kind === 'below' ? 3 : kind === 'player' ? 2 : 4); + game = advanceBankerOverlay(game, candidate => candidate.phase === 5 && candidate.turn === 1 && + bankerOverlayClean(candidate) && candidate.active === payer && + !bankerOverlayPlayer(candidate, payer).shipped, plan, actions); + const seat = bankerOverlayPlayer(game, payer); + const halfRate = seat.faction === 'guild'; + const own = viewGame(game, payer); + // Empty ordinary sand isolates this paid shipment from battle and arrival parents; + // the real consumer entry helpers still choose the exact legal action. + const destination = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm) && + !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${t.id}:`))) && + botEntryAllowed(own, own.players.find(player => player.id === payer)!, t.id, t.sectors[0], 'ship')); + assert.ok(destination, 'One original legal empty sand destination must remain'); + const sector = destination.sectors[0]; + const cost = reserveShipmentCost({ faction: seat.faction, halfRate }, destination.type, amount); + assert.equal(cost, halfRate ? amount : amount * 2, + 'The printed shipment price must follow the payer’s own ordinary rate.'); + assert.ok(seat.reserves >= amount, 'The payer must hold that many ordinary reserves.'); + assert.ok((seat.spice ?? 0) >= cost, 'The payer must hold the printed shipment price.'); + const shipment: Action = { type: 'ship', territory: destination.id, sector, amount, elite: 0, allyPayment: 0 }; + const beforePayment = structuredClone(game); + // The two conserved printed overlay cards: Richese's own Semuta and the printed Hajr. + const semutaId = takePrintedCard(game, owner, card => card.id === SEMUTA_DRUG_ID, staging); + const hajrId = takePrintedCard(game, 'a', card => card.effect === 'hajr', staging); + assert.deepEqual(bankerOverlayPosition(game), bankerOverlayPosition(beforePayment), + 'Conserved printed-card staging must not move wallets, counters, clocks or skills.'); + assert.deepEqual(bankerOverlayInventory(game), bankerOverlayInventory(beforePayment), + 'Conserved printed-card staging must not duplicate or replace a physical identity.'); + return { + game, initial, setup, beforePayment, actions, staging, + owner, trainer: assignment.leader, payer, payerFaction: seat.faction, holder: 'a', + semuta: semutaId, hajr: hajrId, territory: destination.id, sector, + key: `${destination.id}:${sector}`, amount, cost, payee, grant: payee === 'bank' && cost >= 4, + kind, advanced, tech, strongholds, shipment, + allow: advanced ? { actor: 'g', action: { type: 'decision', allow: true } } : null, + plan: { hajr: true, claim: semuta === 'claim' ? owner : null }, + }; +} + +/** Apply the payer's own real declaration, its original Advanced Guild stop and + * any ordinary suffix. No receipt, leg or authority is ever supplied here. */ +export function shipBankerOverlayIncome(fixture: BankerOverlayFixture): Game { + let game = applyAction(fixture.game, fixture.payer, fixture.shipment); + if (fixture.allow) { + assert.equal(game.decision?.kind, 'guildShipment', + 'Advanced Shipment and Movement must open the original Guild stop first.'); + assert.equal(game.decision?.player, fixture.allow.actor); + assert.equal(game.pendingShipment?.player, fixture.payer); + game = applyAction(game, fixture.allow.actor, fixture.allow.action); + } + return advanceBankerOverlay(game, bankerOverlayClean, { hajr: false, claim: null }); +} + +/** Reach the holder's own movement turn, play the conserved printed Hajr, and + * assert the registered Semuta overlay offers its reaction on that fresh discard. */ +export function openBankerOverlayHajr(state: Game, fixture: BankerOverlayFixture): Game { + const game = advanceBankerOverlay(state, candidate => !!candidate.pendingTreacheryDiscard, + { hajr: true, claim: null }); + const pending = game.pendingTreacheryDiscard; + assert.ok(pending, 'The conserved printed Hajr must produce one real fresh discard.'); + assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); + assert.equal(pending.batch.cause, 'ordinary:hajr'); + assert.equal(pending.batch.turn, game.turn); + assert.equal(pending.batch.phase, 5); + assert.deepEqual(pending.batch.entries.map(entry => + ({ id: entry.card.id, actor: entry.discardedBy, publicFace: entry.publicFace })), + [{ id: fixture.hajr, actor: fixture.holder, publicFace: true }]); + assert.equal(pending.reaction?.stage, 'offer', + 'The original Semuta overlay must offer its neutral reaction before the printed continuation resumes.'); + assert.ok(!bankerOverlayPlayer(game, fixture.holder).hand.some(card => card.id === fixture.hajr)); + assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1); + assert.deepEqual(game.hajr, [fixture.holder]); + return game; +} + +/** Commit the owner's own printed Semuta at the open offer. The single freshly + * discarded card resolves the private selection atomically and resumes the + * original ordinary-card continuation without touching Banker income. */ +export function claimBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game { + const pending = state.pendingTreacheryDiscard; + assert.equal(pending?.reaction?.stage, 'offer', 'The printed Semuta offer must still be open.'); + const own = viewGame(state, fixture.owner).semutaReaction; + assert.ok(own?.stage === 'offer' && own.canCommit, own?.blocked ?? 'The printed Semuta claim must be legally available.'); + const inventory = bankerOverlayInventory(state), position = bankerOverlayPosition(state); + const ledger = bankerOverlayLedger(state, fixture.owner); + const game = applyAction(state, fixture.owner, { type: 'semutaCommit', event: pending!.batch.event }); + assert.equal(game.pendingTreacheryDiscard, null, + 'One fresh card resolves the private Semuta claim atomically.'); + assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 1, + 'The spent Semuta Drug must be discarded exactly once.'); + assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.hajr).length, 1, + 'The claimed fresh card must stay privately with its claimant.'); + assert.deepEqual(bankerOverlayInventory(game), inventory); + assert.deepEqual(bankerOverlayPosition(game), position, + 'The overlay continuation must not move wallets, counters, clocks or skills.'); + assert.deepEqual(bankerOverlayLedger(game, fixture.owner), ledger, + 'The overlay continuation must not add, move or duplicate Banker income.'); + return game; +} +/** Decline the printed offer at every remaining seat and resume the same + * original continuation; no claim, income or refund is invented. */ +export function passBankerOverlaySemuta(state: Game, fixture: BankerOverlayFixture): Game { + const inventory = bankerOverlayInventory(state); + const game = advanceBankerOverlay(state, candidate => !candidate.pendingTreacheryDiscard, fixture.plan); + assert.equal(game.discard.filter(card => card.id === fixture.semuta).length, 0); + assert.equal(bankerOverlayPlayer(game, fixture.owner).hand.filter(card => card.id === fixture.semuta).length, 1); + assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1); + assert.deepEqual(bankerOverlayInventory(game), inventory); + assert.deepEqual(game.spiceBankerIncome, state.spiceBankerIncome, + 'Declining the neutral overlay must not create Banker income.'); + return game; +} diff --git a/tests/fixture-banker-world-default.ts b/tests/fixture-banker-world-default.ts new file mode 100644 index 00000000..960e2252 --- /dev/null +++ b/tests/fixture-banker-world-default.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, +} from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { nextSpiceBankerIncomeNativeStep, type SpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; + +export type BankerWorldStep = SpiceBankerIncomeNativeStep; +export type BankerWorldDefaultFixture = { + lobby: Game; + offered: Game; + setup: Game; + /** Control-ready original Guild turn; no invoice has been declared. */ + game: Game; + owner: string; + payer: string; + trainer: string; + shipment: BankerWorldStep; + actions: BankerWorldStep[]; + staging: string[]; +}; +export type BankerWorldMentatBoundary = { + beforeCollection: Game; + collectionAction: BankerWorldStep; + collection: Game; + /** Last real Collection acknowledgement, immediately before Mentat 1 opens. */ + game: Game; + action: BankerWorldStep; + actions: BankerWorldStep[]; +}; +export const bankerWorldPlayer = (game: Game, id: string) => { + const player = game.players.find(p => p.id === id); + assert.ok(player); + return player; +}; +export const bankerWorldClean = (game: Game) => !game.phaseOpening && !game.response && + !game.decision && !game.pendingTreacheryDiscard; +export const bankerWorldWallets = (game: Game) => + Object.fromEntries(game.players.map(p => [p.id, p.spice])); + +/** Native no-bid/no-move policy. All owner-labelled windows remain real actions; + * no armies enter another faction's territory, so sealed battle plans are never + * manufactured. The original shared Banker stepper owns ordinary continuation. */ +export function nextBankerWorldDefaultStep(game: Game): BankerWorldStep { + if (!game.phaseOpening && !game.response) { + const d = game.decision, event = game.richeseBidding?.event; + if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: true } }; + if (d?.kind === 'richeseBlackMarket') return { actor: d.player, action: { type: 'decision', event, decline: true } }; + if (d?.kind === 'richeseDeclaration') return { actor: d.player, action: { type: 'decision', event, position: 'first' } }; + if (d?.kind === 'richeseCache') { + assert.ok(game.richeseCache?.[0]); + return { actor: d.player, action: { type: 'decision', event, card: game.richeseCache[0].id, + method: 'onceAround', direction: 'counterclockwise' } }; + } + if (d?.kind === 'richeseUnbid') return { actor: d.player, action: { type: 'decision', event, keep: false } }; + if (game.nexusCards?.phase?.stage === 'drawing' && !d) { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + const lot = game.richeseAuction; + if (!d && lot && !lot.outcome) { + const actor = lot.method === 'silent' + ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active; + assert.ok(actor); + return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } }; + } + } + assert.ok(!game.battle, 'This no-invasion programme must not invent a human Battle Plan.'); + const next = nextSpiceBankerIncomeNativeStep(game); + assert.ok(next); + return next; +} +export function advanceBankerWorldDefault(state: Game, until: (game: Game) => boolean, + actions: BankerWorldStep[] = []): Game { + let game = state; + for (let n = 0; n < 1400; n++) { + if (until(game)) return game; + assert.equal(game.status, 'playing'); + const next = nextBankerWorldDefaultStep(game); + actions.push(structuredClone(next)); + game = applyAction(game, next.actor, next.action); + } + throw Error('Native World Banker continuation did not reach its requested control.'); +} + +/** Select only the original all14 shuffle before any offer is dealt. Subsequent + * native setup draws retain deterministic entropy without rewriting components. */ +function withWorldOffers(run: () => T): T { + const selected: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster']; + const cards = LEADER_SKILL_CARDS.map(c => c.id), rest = cards.filter(id => !selected.includes(id)); + const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest); + const current = [...cards], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); + assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000)); + [current[i], current[j]] = [current[j], current[i]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + let index = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = rolls[index++] ?? 0xffffffff; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Executable ordinary all14 World smoke source. Nexus/Discovery are original + * unused lobby selections; Tech and Advanced Strongholds use real host actions. + * Only closed original Spice Card order is staged, with its full multiset kept. + * No forces, balances, phase clocks, ownership or receipts are assigned. */ +export function createBankerWorldDefaultFixture(): BankerWorldDefaultFixture { + let game = createGame('BANKERWORLDDEFAULT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); + game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + game.discoveryEnabled = true; + game.nexusCards = { cards: null, phase: null }; + const lobby = structuredClone(game), actions: BankerWorldStep[] = []; + for (const player of game.players) { + const next: BankerWorldStep = { actor: player.id, action: { type: 'ready' } }; + actions.push(next); game = applyAction(game, next.actor, next.action); + } + game = withWorldOffers(() => initializeLeaderSkillsGameForAudit(game)); + const offered = structuredClone(game); + const skills: LeaderSkillId[] = ['spice-banker', 'swordmaster-of-ginaz', 'warmaster']; + for (let n = 0; game.status === 'setup' && n < 200; n++) { + let next: BankerWorldStep; + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor]; + const own = viewGame(game, actor).leaderSkills!; + const skill = skills[game.players.findIndex(p => p.id === actor)]; + assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && own.eligibleLeaders[0]); + next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } }; + } else next = nextBankerWorldDefaultStep(game); + actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); + } + assert.equal(game.status, 'playing'); + const setup = structuredClone(game); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + // Two genuine ordinary blows close the first Advanced Spice phase without a + // worm/Nexus alliance or a Discovery entry changing this payment experiment. + for (let position = 0; position < 2; position++) { + const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery); + assert.ok(at >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); + } + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + game = advanceBankerWorldDefault(game, + g => g.turn === 1 && g.phase === 5 && g.active === 'guild' && bankerWorldClean(g), actions); + const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) && + game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))) && + !Object.keys(game.spice).some(key => key.startsWith(`${t.id}:`) && game.spice[key] > 0)); + assert.ok(destination); + const homeworldSources = nativeShipmentSources(viewGame(game, 'guild'), 4, 0); + assert.ok(homeworldSources); + const shipment: BankerWorldStep = { actor: 'guild', action: { type: 'ship', territory: destination.id, + sector: destination.sectors.find(s => s !== game.storm)!, amount: 4, elite: 0, allyPayment: 0, homeworldSources } }; + const assignment = game.leaderSkills!.assignments.find(a => a.owner === 'richese' && a.skill === 'spice-banker'); + assert.ok(assignment); + return { lobby, offered, setup, game, owner: 'richese', payer: 'guild', trainer: assignment.leader, + shipment, actions, staging: ['Original undealt all14 lottery; original unused Nexus/Discovery lobby selection; conserved first two closed ordinary Spice Cards.'] }; +} + +export function payBankerWorldDefault(fixture: BankerWorldDefaultFixture, action: Action = fixture.shipment.action): Game { + const paid = applyAction(fixture.game, fixture.shipment.actor, action); + return advanceBankerWorldDefault(paid, g => bankerWorldClean(g) && !g.pendingShipment); +} + +/** Preserve the actual last Collection-ready action, not a synthetic phase + * write or an inferred wallet from a later turn containing unrelated income. */ +export function bankerWorldBeforeMentat(state: Game): BankerWorldMentatBoundary { + const actions: BankerWorldStep[] = []; + let game = state, beforeCollection: Game | null = null, collectionAction: BankerWorldStep | null = null; + for (let n = 0; (game.phase !== 7 || !bankerWorldClean(game)) && n < 1000; n++) { + const before = game, next = nextBankerWorldDefaultStep(game); + actions.push(structuredClone(next)); + game = applyAction(game, next.actor, next.action); + if (before.phase !== 7 && game.phase === 7) { + beforeCollection = before; + collectionAction = next; + } + } + assert.ok(beforeCollection && collectionAction); + assert.equal(game.phase, 7); + assert.equal(game.turn, 1); + const collection = structuredClone(game); + for (let n = 0; n < game.players.length; n++) { + const next = nextBankerWorldDefaultStep(game); + assert.equal(next.action.type, 'ready'); + if (game.ready.length === game.players.length - 1) + return { beforeCollection, collectionAction, collection, game, action: next, actions }; + actions.push(next); game = applyAction(game, next.actor, next.action); + } + throw Error('Original Collection phase did not expose its final Mentat acknowledgement.'); +} diff --git a/tests/fixture-classic-nexus-banker-income.ts b/tests/fixture-classic-nexus-banker-income.ts index 008cc754..d781890a 100644 --- a/tests/fixture-classic-nexus-banker-income.ts +++ b/tests/fixture-classic-nexus-banker-income.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Game, type Action, } from '../game/engine'; import { validateLeaderSkills } from '../game/leader-skills'; @@ -45,7 +45,7 @@ export function createClassicNexusBankerFixture(options: { if (strongholds) game.strongholdCards = createStrongholdCards(); for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); const lobby = structuredClone(game); - game = withSpiceBankerIncomeSetupRandomness(() => initializeSpiceBankerIncomeGameForAudit(game)); + game = withSpiceBankerIncomeSetupRandomness(() => initializeLeaderSkillsGameForAudit(game)); const started = structuredClone(game); while (game.status === 'setup') { if (game.setupStage === 'leaderSkills') { diff --git a/tests/fixture-classic-skills-stronghold.ts b/tests/fixture-classic-skills-stronghold.ts index 4e5e72fd..959a8fb5 100644 --- a/tests/fixture-classic-skills-stronghold.ts +++ b/tests/fixture-classic-skills-stronghold.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, - initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, viewGame, + joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { territory } from '../game/board'; @@ -20,7 +20,6 @@ export type ClassicSkillsStrongholdOptions = { initial?: Game; seatIds?: string[]; optionalTech?: boolean; - bankerIncome?: boolean; kind?: Exclude; skill?: LeaderSkillId; band?: 'normal' | 'skilled'; @@ -200,8 +199,7 @@ export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStron const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'guild'))?.id; const opponent = game.players.find(p => p.faction === (options.opponentFaction ?? 'emperor'))?.id; assert.ok(owner && opponent && owner !== opponent); - const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')?.id : undefined; - if (options.bankerIncome) assert.ok(banker && banker !== owner && banker !== opponent && game.players.length >= 3); + const banker = game.players.find(p => p.faction === 'atreides' && p.id !== owner && p.id !== opponent)?.id; if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); assert.deepEqual(game.strongholdCards, createStrongholdCards()); if (options.optionalTech) { @@ -213,8 +211,7 @@ export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStron const passive: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as const) .filter(candidate => candidate !== skill); const selected = game.players.map(player => player.id === owner ? skill : player.id === banker ? 'spice-banker' as const : passive.shift()!); - game = withOffers(selected, () => options.bankerIncome - ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game)); + game = withOffers(selected, () => initializeLeaderSkillsGameForAudit(game)); const initial = structuredClone(game); for (let count = 0; game.status === 'setup' && count < 200; count++) { if (game.setupStage === 'leaderSkills') { diff --git a/tests/fixture-discovery-classic-skills.ts b/tests/fixture-discovery-classic-skills.ts index e0040074..b14354d6 100644 --- a/tests/fixture-discovery-classic-skills.ts +++ b/tests/fixture-discovery-classic-skills.ts @@ -121,7 +121,7 @@ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySki assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0)); const controlledOffers = game.status === 'lobby'; assert.deepEqual(game.expansions, []); - assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview); + assert.ok(!game.homeworlds && !game.nexusCards); if (options.strongholdCards || game.strongholdCards) { assert.equal(game.advanced, true); if (!game.strongholdCards) { diff --git a/tests/fixture-discovery-native-e2-skills.ts b/tests/fixture-discovery-native-e2-skills.ts index 84523f50..c69465ad 100644 --- a/tests/fixture-discovery-native-e2-skills.ts +++ b/tests/fixture-discovery-native-e2-skills.ts @@ -217,7 +217,7 @@ export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2S joinGame(game, newPlayer(faction, faction, faction)); assert.deepEqual(game.expansions, ['choam']); if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); - assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview); + assert.ok(!game.homeworlds && !game.nexusCards); assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.leaderSkills?.assignments.length === 0 && game.players.every(p => p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0), diff --git a/tests/fixture-discovery-native-e3-skills.ts b/tests/fixture-discovery-native-e3-skills.ts index 9ce5d165..720734db 100644 --- a/tests/fixture-discovery-native-e3-skills.ts +++ b/tests/fixture-discovery-native-e3-skills.ts @@ -149,7 +149,7 @@ export function initializeDiscoveryNativeE3Skills(options: DiscoveryNativeE3Skil assert.ok(game.players.every(p => ['moritani', 'guild', 'atreides', 'emperor', 'fremen', 'beneGesserit', ...(advanced ? [] : ['harkonnen'])].includes(p.faction))); assert.ok(!strongholds || advanced); - assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview); + assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery); if (game.status === 'lobby') { if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); diff --git a/tests/fixture-discovery-standalone-nexus-e3-skills.ts b/tests/fixture-discovery-standalone-nexus-e3-skills.ts index 3184c4e2..698c753b 100644 --- a/tests/fixture-discovery-standalone-nexus-e3-skills.ts +++ b/tests/fixture-discovery-standalone-nexus-e3-skills.ts @@ -116,8 +116,7 @@ export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoverySta ...(advanced ? [] : ['harkonnen'])].includes(p.faction))); assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.'); assert.ok(!strongholds || advanced); - assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview && - !game.spiceBankerIncomePreview); + assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview); if (game.status === 'lobby') { if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); diff --git a/tests/fixture-ecaz-skills-modules.ts b/tests/fixture-ecaz-skills-modules.ts index d0135e72..f5a7263d 100644 --- a/tests/fixture-ecaz-skills-modules.ts +++ b/tests/fixture-ecaz-skills-modules.ts @@ -22,8 +22,6 @@ export type EcazSkillsModulesOptions = { skill?: LeaderSkillId; band?: 'normal' | 'skilled'; strongholdKind?: Exclude; - /** Separate existing native Banker income opt-in, not a skill-module default. */ - bankerIncome?: boolean; poison?: boolean; defense?: boolean; opponentDial?: number; @@ -147,7 +145,7 @@ export function createEcazSkillsModulesFixture(options: EcazSkillsModulesOptions if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold }); const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; - game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, bankerIncome: options.bankerIncome, initial: game }); + game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, initial: game }); const offered = structuredClone(game); for (let i = 0; game.status === 'setup' && i < 200; i++) { if (game.setupStage === 'leaderSkills') { diff --git a/tests/fixture-homeworld-banker-income.ts b/tests/fixture-homeworld-banker-income.ts index f48ba69d..bbd7c2f2 100644 --- a/tests/fixture-homeworld-banker-income.ts +++ b/tests/fixture-homeworld-banker-income.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, + applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { territory } from '../game/board'; @@ -102,9 +102,8 @@ export const homeworldBankerSector = (game: Game, target: string): number => { }; /** Genuine ordinary reserve shipment out of a faction's own Homeworld reserves - * into an empty printed board location. The Banker profile allows only the - * original native payment fields, so the withdrawal keeps its default own-home - * allocation and no force, wallet, phase or receipt was assigned. */ + * into an empty printed board location. The withdrawal keeps its default + * own-home allocation; no force, wallet, phase or receipt is assigned. */ export function homeworldBankerShipment(game: Game, actor: string, target: string, amount: number, allyPayment = 0): Action { assert.ok(game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${target}:`))), @@ -201,7 +200,7 @@ function admitHomeworldBankerGame(): Game { const canonical = LEADER_SKILL_CARDS.map(card => card.id); const rest = canonical.filter(id => !REQUESTED_SKILLS.includes(id)); const desired = REQUESTED_SKILLS.flatMap(id => [id, rest.shift()!]).concat(rest); - return originalLottery(permutationDraws(canonical, desired), () => initializeSpiceBankerIncomeGameForAudit(game)); + return originalLottery(permutationDraws(canonical, desired), () => initializeHomeworldOccupationGameForAudit(game, true)); } /** Genuine setup selections: each requested skill is chosen from its real diff --git a/tests/fixture-ix-choam-skills-tech.ts b/tests/fixture-ix-choam-skills-tech.ts index cb0aea80..b2788db4 100644 --- a/tests/fixture-ix-choam-skills-tech.ts +++ b/tests/fixture-ix-choam-skills-tech.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { TERRITORIES } from '../game/board'; @@ -19,8 +19,6 @@ export type IxChoamSkillsTechOptions = { amount?: number; elite?: number; smuggler?: boolean; - /** Separate normal-income audit opt-in; needs an actual Atreides seat. */ - bankerIncome?: boolean; }; export type IxChoamSkillsTechStep = { actor: string; action: Action }; export type IxChoamSkillsTechFixture = { @@ -108,7 +106,7 @@ export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptio if (!options.initial) { joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); joinGame(game, newPlayer('fremen', 'Fremen', 'fremen')); - if (options.bankerIncome) joinGame(game, newPlayer('banker', 'Banker', 'atreides')); + joinGame(game, newPlayer('banker', 'Banker', 'atreides')); } assert.equal(game.status, 'lobby', 'Only the original fresh undealt lobby is accepted.'); assert.ok(game.expansions.includes(expansion)); @@ -116,16 +114,14 @@ export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptio if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); assert.ok(game.players.some(p => p.faction === family)); assert.ok(game.players.some(p => p.faction === 'emperor')); - if (options.bankerIncome) assert.ok(game.players.some(p => p.faction === 'atreides')); if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); assert.deepEqual(game.techTokens, createTechTokens()); for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'planetologist']; const selected = game.players.map(p => p.faction === family ? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler' - : options.bankerIncome && p.faction === 'atreides' ? 'spice-banker' : passive.shift()!); - return withOffers(selected, () => options.bankerIncome - ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game)); + : p.faction === 'atreides' ? 'spice-banker' : passive.shift()!); + return withOffers(selected, () => initializeLeaderSkillsGameForAudit(game)); } export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkillsTechOptions = {}, actions?: IxChoamSkillsTechStep[]): Game { @@ -138,7 +134,7 @@ export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkil const faction = ixChoamSkillsTechPlayer(game, actor).faction; const skill = faction === (options.family ?? 'ixians') ? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler' - : options.bankerIncome && faction === 'atreides' ? 'spice-banker' : offer.cards[0]; + : faction === 'atreides' ? 'spice-banker' : offer.cards[0]; assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]); const eligible = new Set(view.eligibleLeaders.map(l => l.id)); const leader = ixChoamSkillsTechPlayer(game, actor).leaders.filter(l => eligible.has(l.id)) @@ -166,7 +162,7 @@ export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions let game = completeIxChoamSkillsTechSetup(setup, options, actions); const owner = game.players.find(p => p.faction === family)!.id; const opponent = game.players.find(p => p.faction === 'emperor')!.id; - const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')!.id : undefined; + const banker = game.players.find(p => p.faction === 'atreides')?.id; const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader; // Four conserved non-worm cards avoid optional riders during the two-turn // charity continuation. They are not asserted to be a natural shuffle history. diff --git a/tests/fixture-native-banker-income.ts b/tests/fixture-native-banker-income.ts index 7847c457..6e63fbe5 100644 --- a/tests/fixture-native-banker-income.ts +++ b/tests/fixture-native-banker-income.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Game, } from '../game/engine'; import type { FactionId } from '../game/catalog'; @@ -78,7 +78,7 @@ export function initializeNativeBankerIncomeSetup(options: NativeBankerIncomeOpt 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)); + return withNativeBankerSkillShuffle(() => initializeLeaderSkillsGameForAudit(game)); } function step(game: Game): Game { diff --git a/tests/fixture-richese-skills-tech.ts b/tests/fixture-richese-skills-tech.ts index bbfb87ce..cb601020 100644 --- a/tests/fixture-richese-skills-tech.ts +++ b/tests/fixture-richese-skills-tech.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { TERRITORIES } from '../game/board'; @@ -185,7 +185,7 @@ export function createRicheseSkillsTechFixture(options: RicheseSkillsTechOptions const safe: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'killer-medic']; const selected = game.players.map(p => p.id === owner ? skill : p.id === banker ? 'spice-banker' as const : safe.shift()!); const bySeat: Record = Object.fromEntries(game.players.map((p, i) => [p.id, selected[i]])); - game = withOffers(selected, () => initializeSpiceBankerIncomeGameForAudit(game)); + game = withOffers(selected, () => initializeLeaderSkillsGameForAudit(game)); const setup = structuredClone(game); for (let n = 0; game.status === 'setup' && n < 200; n++) { if (game.setupStage === 'leaderSkills') { diff --git a/tests/fixture-skills-tech-payments.ts b/tests/fixture-skills-tech-payments.ts index d8ad7c02..e2a81d29 100644 --- a/tests/fixture-skills-tech-payments.ts +++ b/tests/fixture-skills-tech-payments.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { - applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { TERRITORIES } from '../game/board'; @@ -107,7 +107,7 @@ export function initializeSkillsTechPaymentsSetup(options: SkillsTechPaymentsOpt // setup/first Storm alone assign ownership; never retrofit a played game. if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); - return withPaymentSkillOffers(game, () => initializeSpiceBankerIncomeGameForAudit(game)); + return withPaymentSkillOffers(game, () => initializeLeaderSkillsGameForAudit(game)); } export function completeSkillsTechPaymentsSetup(state: Game, actions?: SkillsTechPaymentsStep[]): Game { diff --git a/tests/fixture-spice-banker-income.ts b/tests/fixture-spice-banker-income.ts index 244940d8..61ce1914 100644 --- a/tests/fixture-spice-banker-income.ts +++ b/tests/fixture-spice-banker-income.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {applyAction,createGame,handLimit,initializeSpiceBankerIncomeGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine'; +import {applyAction,createGame,handLimit,initializeLeaderSkillsGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine'; import {botActions} from '../game/bots'; import {TERRITORIES} from '../game/board'; import {LEADER_SKILL_CARDS} from '../game/leader-skill-cards'; @@ -80,7 +80,7 @@ function setup(initial:Game):Game { let game=structuredClone(initial); if(game.status==='lobby') { for(const p of game.players) if(!p.ready) game=applyAction(game,p.id,{type:'ready'}); - game=withSpiceBankerIncomeSetupRandomness(()=>initializeSpiceBankerIncomeGameForAudit(game)); + game=withSpiceBankerIncomeSetupRandomness(()=>initializeLeaderSkillsGameForAudit(game)); } else assert.ok(game.status==='setup'&&game.spiceBankerIncomePreview&&game.turn===1&&game.phase===0,'Only the original fresh admitted setup can continue.'); for(let i=0;game.status==='setup'&&i<200;i++) { if(game.setupStage==='leaderSkills') { diff --git a/tests/ix-choam-skills-tech-runtime.test.ts b/tests/ix-choam-skills-tech-runtime.test.ts index a20bc384..dde77aab 100644 --- a/tests/ix-choam-skills-tech-runtime.test.ts +++ b/tests/ix-choam-skills-tech-runtime.test.ts @@ -33,7 +33,7 @@ for (const advanced of [false, true]) { const rules = advanced ? 'Advanced' : 'Basic'; for (const family of ['ixians', 'choam'] as const) { for (const smuggler of [true, false]) void test(`${rules} ${family}: real typed four-counter invoice ${smuggler ? 'includes the free companion' : 'declines it'} and pays only native Heighliners at phase end`, () => { - const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler, bankerIncome: true }); + const fixture = createIxChoamSkillsTechFixture({ advanced, family, amount: 4, smuggler }); const before = fixture.game; const payer = player(before, fixture.actor); const tokenOwner = before.techTokens!.heighliners.owner!; @@ -144,7 +144,7 @@ for (const advanced of [false, true]) { } void test(`${rules} CHOAM: real exhausted Emperor shipment makes next-turn charity eligible; only original Spice Production accrues, never Axlotl`, () => { - const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity', bankerIncome: true }); + const fixture = createIxChoamSkillsTechFixture({ advanced, family: 'choam', kind: 'charity' }); const before = fixture.game; assert.ok(fixture.actions.some(step => step.actor === fixture.actor && step.action.type === 'ship' && step.action.amount === 5), 'Eligibility comes from an actual original ten-spice desert invoice.'); diff --git a/tests/nexus-skills-modules-payments-runtime.test.ts b/tests/nexus-skills-modules-payments-runtime.test.ts index ffdbd6a0..c91097e7 100644 --- a/tests/nexus-skills-modules-payments-runtime.test.ts +++ b/tests/nexus-skills-modules-payments-runtime.test.ts @@ -148,9 +148,6 @@ for (const config of configurations) { assert.equal(receipt.beforeSpice, buyer.spice); assert.equal(receipt.afterSpice, buyer.spice); assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1); assert.deepEqual(paid.techTokens, f.game.techTokens); - assert.equal(viewGame(paid, f.actor).spiceBankerIncome, null); - assert.equal(paid.spiceBankerIncome, undefined); - assert.equal(paid.currentAuctionSale?.spiceBankerIncomePayment, undefined); custody(f.game, paid); rejected(paid, f.actor, f.paymentAction); const continued = advanceNexusSkillsModulesPaymentsToPhase(paid, 4); assert.equal(nexusSkillsModulesPaymentsPlayer(continued, f.actor).hand.filter(card => card.id === lot.id).length, 1); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 0d9d9d85..a6927afc 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -810,71 +810,13 @@ void test('Harkonnen Betrayal CLI backs up every room and seat privately before assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats); }); -for (const advanced of [false, true]) { - for (const seats of [2, 3, 4, 5, 6]) { - void test(`Banker income ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry conserves the original base and skill inventories without changing credentials`, t => { - const { db } = fixture(t); - const lobby = harkonnenLobby(advanced, seats); - db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code); - const credentials = db.prepare('SELECT * FROM seats').all(); - const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'); - const before = db.prepare('SELECT * FROM rooms ORDER BY code').all(); - assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'banker-income')); - assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before); - startPrototypeRoom(db, lobby.code, 7, 'banker-income'); - const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game; - assert.equal(saved.version, 8); - assert.equal(saved.status, 'setup'); - assert.equal(saved.spiceBankerIncomePreview, true); - assert.equal(saved.advanced, advanced); - assert.equal(saved.host, lobby.host); - assert.deepEqual(saved.playerPositions, lobby.playerPositions); - assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction])); - const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)]; - assert.equal(stock.length, 33); - assert.equal(new Set(stock.map(card => card.id)).size, 33); - assert.ok(saved.leaderSkills); - const skills = [...saved.leaderSkills.deck, ...Object.values(saved.leaderSkills.offers).flatMap(offer => offer.cards), - ...saved.leaderSkills.assignments.map(assignment => assignment.skill)]; - assert.deepEqual(skills.sort(), createLeaderSkills(() => 0.5).deck.sort()); - for (const player of saved.players) { - const view = viewGame(saved, player.id); - assert.deepEqual(view.spiceBankerIncome, { deferred: [], usedThisPhase: [] }); - assert.equal(view.spiceBankerIncomePreview, true); - } - const started = db.prepare('SELECT * FROM rooms ORDER BY code').all(); - for (const profile of ['banker-income', 'leader-skills', 'nexus'] as const) - assert.throws(() => startPrototypeRoom(db, lobby.code, 8, profile)); - assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started); - assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials); - assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); - }); - } -} - -void test('Banker income rejects incompatible profiles and public activation without modifying any saved room or credential', t => { +void test('Leader Skills rejects invalid or already-dealt lobbies and forged public activation without changing saved rooms or credentials', t => { const { db } = fixture(t); const native = harkonnenLobby(); const changes: ((game: Game) => void)[] = [ game => { game.players[0].ready = false; }, game => { game.code = 'NOTMATCH'; }, game => { game.players[1].faction = game.players[0].faction; }, - ...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction => - (game: Game) => { game.players[1].faction = faction; }), - ...(['ix', 'choam', 'ecaz'] as const).map(expansion => - (game: Game) => { game.expansions = [expansion]; }), - game => { game.discoveryEnabled = true; }, - game => { game.ecazTreachery = true; }, - game => { game.semutaPreview = true; }, - game => { game.nexusIxianReplacementPreview = true; }, - game => { game.nexusIxianBetrayalPreview = true; }, - game => { game.nexusHarkonnenBetrayalPreview = true; }, - game => { game.kullPreview = true; }, - game => { game.nexusKullPreview = true; }, - game => { game.guildBetrayalPreview = true; }, - game => { game.richeseBetrayalPreview = true; }, - game => { game.moritaniAssassinatePreview = true; }, - game => { game.spiceBankerIncomePreview = true; }, game => { game.leaderSkills = createLeaderSkills(() => 0.5); }, game => { game.players.splice(1); }, game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); }, @@ -887,7 +829,7 @@ void test('Banker income rejects incompatible profiles and public activation wit db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code); const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all(); const credentials = db.prepare('SELECT * FROM seats').all(); - assert.throws(() => startPrototypeRoom(db, native.code, 7, 'banker-income')); + assert.throws(() => startPrototypeRoom(db, native.code, 7, 'leader-skills')); assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms); assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials); } @@ -898,8 +840,8 @@ void test('Banker income rejects incompatible profiles and public activation wit assert.deepEqual(native, original); }); -void test('Banker income CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => { - const directory = mkdtempSync(join(tmpdir(), 'dune-banker-income-')); +void test('Leader Skills CLI requires a fresh exact-version private backup and preserves native setup, old rooms and credentials', t => { + const directory = mkdtempSync(join(tmpdir(), 'dune-leader-skills-')); t.after(() => rmSync(directory, { recursive: true, force: true })); const database = join(directory, 'rooms.sqlite'); const db = new DatabaseSync(database); @@ -918,7 +860,7 @@ void test('Banker income CLI requires a fresh exact-version private backup and p const output = join(directory, 'private-proof'); const invoke = (version: number, out: string) => spawnSync(process.execPath, [ '--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)), - '--profile', 'banker-income', '--db', database, '--room', lobby.code, + '--profile', 'leader-skills', '--db', database, '--room', lobby.code, '--version', String(version), '--out', out, ], { encoding: 'utf8', timeout: 120000 }); const result = invoke(7, output); diff --git a/tests/richese-contribution-payments.test.ts b/tests/richese-contribution-payments.test.ts index bd0b7f5c..1ad6400f 100644 --- a/tests/richese-contribution-payments.test.ts +++ b/tests/richese-contribution-payments.test.ts @@ -37,7 +37,7 @@ function prepare(options: Options = {}): NativeRichesePaymentFixture { let game = initializeAdvancedNativeSkillsSetup({ family: 'richese', rules: options.rules ?? 'advanced', opponents, requestedSkill: options.banker ? 'spice-banker' : 'suk-graduate', - skillOwner: options.banker ? 'beneGesserit' : 'richese', bankerIncome: options.banker, + skillOwner: options.banker ? 'beneGesserit' : 'richese', }); const skillOwner = advancedNativePlayer(game, options.banker ? 'beneGesserit' : 'richese').id; const requested = options.banker ? 'spice-banker' : 'suk-graduate'; @@ -72,7 +72,9 @@ function prepare(options: Options = {}): NativeRichesePaymentFixture { else { const holder = game.players.find(p => p.id !== observer && p.hand.some(c => c.effect === 'karama'))!; assert.ok(holder, 'Transfer an existing original physical Karama, never manufacture one.'); - card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0]; + const returned = owner.hand.pop(); + assert.ok(returned, 'Exchange an original held card so donor auction eligibility is preserved.'); + card = holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1, returned)[0]; } owner.hand.push(card); } @@ -187,7 +189,7 @@ for (const rules of ['basic', 'advanced'] as const) { } for (const source of ['cache', 'blackMarket'] as const) { - void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies separate Banker, deferred until native Mentat`, () => { + void test(`Advanced native ${source}: only Richese bank-routed contribution qualifies Banker, deferred until native Mentat`, () => { const f = prepare({ source, emperor: false, banker: true }); const ownerBalance = balance(f.game, f.observer); let game = finishResponses(purchase(f)); diff --git a/tests/spice-banker-income-recovery.test.ts b/tests/spice-banker-income-recovery.test.ts index 7facd16a..50b309d1 100644 --- a/tests/spice-banker-income-recovery.test.ts +++ b/tests/spice-banker-income-recovery.test.ts @@ -90,7 +90,7 @@ async function fixture(t: test.TestContext, options: Options = {}): Promise startPrototypeRoom(store.sqlite, code, lobby.version, 'banker-income')); + withSpiceBankerIncomeSetupRandomness(() => startPrototypeRoom(store.sqlite, code, lobby.version, 'leader-skills')); const setup = await store.rooms.readRoom(code); const native = createSpiceBankerIncomeFixture({ ...options, initial: setup }); assert.equal(native.beforePayment.code, code); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 59659a75..c1e509dc 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -5,7 +5,6 @@ import { initializeDiscoveryGameForAudit, initializeHomeworldOccupationGameForAudit, initializeLeaderSkillsGameForAudit, - initializeSpiceBankerIncomeGameForAudit, initializeFactionExpansionsGameForAudit, initializeStrongholdFactionsGameForAudit, initializeNexusGameForAudit, @@ -31,7 +30,6 @@ export const PROTOTYPE_PROFILES = [ 'discovery', 'homeworld-occupation', 'leader-skills', - 'banker-income', 'factions', // Advanced selected native families or standalone E3; optional Tech requires 3+ seats. 'stronghold-factions', @@ -58,7 +56,7 @@ export type PrototypeOptions = { discoveries?: boolean; /** Explicit original skills with Semuta, classic occupation or Guild Betrayal. */ leaderSkills?: boolean; - /** Original Semuta/Banker composition or classic independent-card Homeworlds. */ + /** Original Skills/Semuta composition or classic independent-card Homeworlds. */ homeworlds?: boolean; }; export function isPrototypeProfile(value: string): value is PrototypeProfile { @@ -101,14 +99,14 @@ export function startPrototypeRoom( throw new Error('Unknown prototype profile.'); if (options.ecazTreachery && profile !== 'ecaz-occupy') throw new Error('--ecaz-treachery requires the ecaz-occupy profile.'); - if (options.nexusCards && profile !== 'leader-skills' && profile !== 'semuta' && profile !== 'banker-income') - throw new Error('--nexus-cards requires the leader-skills, banker-income or semuta profile.'); + if (options.nexusCards && profile !== 'leader-skills' && profile !== 'semuta') + throw new Error('--nexus-cards requires the leader-skills or semuta profile.'); if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus' && profile !== 'semuta') throw new Error('--discoveries requires the leader-skills, nexus or semuta profile.'); if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation' && profile !== 'guild-betrayal') throw new Error('--leader-skills requires the semuta, homeworld-occupation or guild-betrayal profile.'); - if (options.homeworlds && profile !== 'semuta' && profile !== 'banker-income' && profile !== 'ecaz-treachery') - throw new Error('--homeworlds requires the semuta, banker-income or ecaz-treachery profile.'); + if (options.homeworlds && profile !== 'semuta' && profile !== 'leader-skills' && profile !== 'ecaz-treachery') + throw new Error('--homeworlds requires the semuta, leader-skills or ecaz-treachery profile.'); if (options.ecazTreachery) initial.ecazTreachery = true; if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards) initial.nexusCards = { cards: null, phase: null }; @@ -172,8 +170,6 @@ export function startPrototypeRoom( : initializeNexusGameForAudit(initial) : profile === 'leader-skills' ? initializeLeaderSkillsGameForAudit(initial) - : profile === 'banker-income' - ? initializeSpiceBankerIncomeGameForAudit(initial) : initializeDiscoveryGameForAudit({ ...initial, discoveryEnabled: true, diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 13efc848..b7bac448 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -25,11 +25,11 @@ async function main() { }); if (values.help) { console.log( - "Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|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, optionally preserving unused Tech Tokens at 3–6 seats. 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, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, 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 fresh classic/base33, native Tleilaxu plus classics/Ix47 without optional Sandtrout, or native Richese plus classics/CHOAM35 with the separate original ten-card cache, in Basic/Advanced with Nexus alone. Normal and genuine Richese cache purchases are connected in Basic/Advanced; Black Market remains Advanced-only. Cache uses the visible provisional independent-Nexus grant interpretation; the native Ixian ally cache exclusion remains unchanged. Native Ixians remain excluded. Advanced Richese special-Karama cache purchases during Bidding use the same independent replacement after their original cost and once-use commit; other phases keep native acquisition without a Nexus offer. Original income and suspended active/control/funding obligations resume without a fabricated auction. Harkonnen uses the visible provisional replacement-before-income/bonus order. 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 except for each profile’s documented composition. 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|homeworld-occupation|leader-skills|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, optionally preserving unused Tech Tokens at 3–6 seats. 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. Fresh all14 Leader Skills setups include mandatory normal Banker income: another payer's actual Bank payment of at least four grants one deferred spice once per phase, collected at original Mentat and not spendable beforehand. 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 fresh classic/base33, native Tleilaxu plus classics/Ix47 without optional Sandtrout, or native Richese plus classics/CHOAM35 with the separate original ten-card cache, in Basic/Advanced with Nexus alone. Normal and genuine Richese cache purchases are connected in Basic/Advanced; Black Market remains Advanced-only. Cache uses the visible provisional independent-Nexus grant interpretation; the native Ixian ally cache exclusion remains unchanged. Native Ixians remain excluded. Advanced Richese special-Karama cache purchases during Bidding use the same independent replacement after their original cost and once-use commit; other phases keep native acquisition without a Nexus offer. Original income and suspended active/control/funding obligations resume without a fabricated auction. Harkonnen uses the visible provisional replacement-before-income/bonus order. 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 except for each profile’s documented composition. 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.", '\nSemuta additionally admits a fresh Basic roster with native Richese and Moritani, classic opponents, exactly CHOAM and Ecaz decks, and no optional modules. Native setup remains genuine. Private Sabotage and Robbery overflow discards may offer Semuta before a clean ordinary shipment/movement arrival resumes. The victim/draw and original payment/forces are already committed; candidate faces stay hidden until commitment. Advanced Moritani, native Ecaz, Ix/E3 mixtures, worm/Ambassador/overlap arrivals and public starts remain outside this entry.', '\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.', '\n--profile semuta --discoveries uses original fresh Richese/CHOAM and optional Ix Discovery7/8 setup, Basic/Advanced2–6, with original unused Tech3+/Advanced Strongholds. Shrine physical roles stay distinct: Truthtrance may be played as Karama while its printed identity remains; Semuta recovers the used physical cost without replaying its effect. No Skills, Homeworlds, Nexus, E3, played-save conversion or public-mode gate is opened.', - '\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.', + '\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery overlays, public starts and played-game conversion are not opened.', '\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.', '\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Printed CHOAM opening-income/Inflation cancellation and normal-lot Karama purchase/free-payment costs also pause before their original denial, spent attempt or zero-spice acquisition. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, retrofit and public starts remain guarded.', '\nThe same Homeworld Semuta entry also offers printed Karama canceling native Advanced BG extra charity and genuine allied Emperor gift/extra revival. The consumed charity claim, original ally, paid deployment/casualties, wallets and unawarded revival remain committed. Wealthy Basic BG keeps its ordinary-charity guard; no new benefit or profile is granted.', @@ -41,12 +41,12 @@ async function main() { '\nWithout Homeworlds, ecaz-treachery supports clean classic or selected Ix and/or CHOAM native/base Basic/Advanced rosters with original unused Tech3+ and/or Advanced Strongholds2+; both requires Advanced3+. Original selected33/35/47 decks gain the three independent cards; Richese cache stays separate. First-Storm Tech owners/income and actual end-Mentat Stronghold ownership remain original. Recruits keeps original Axlotl triggers; Reinforcements/Harass keep physical costs/returns and earned held-card effects. Original Stone/Mirror allocation/profile guards stay. Nexus, Skills, Discovery, Sandtrout, mixed E3 and unrelated overlays remain outside this entry.', '\nSelected Ix/CHOAM original Stone and physical Mirror now also retain original Tech/Advanced Strongholds, in ordinary47 or independently selected50-card setups with a separate cache. Earned Habbanya decides physical ties; original Stone-before-Tooth remains provisional, printed copy-first and user-selected physical Mirror retention stay. Public winner-changing allocations, mixed E3 phase modules and other overlay/public-start guards remain.', '\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' + - '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', + '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Mandatory normal Banker income never treats ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' + - '\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income remains a separate opt-in. Mixed E3 families, other modules and played-game conversion remain excluded.', - '\nBanker-income --nexus-cards composes the existing deferred normal income with fresh classic Basic/Advanced Nexus and all14 Leader Skills, optionally preserving original Tech3+ and Advanced Strongholds2+. Original bank payer legs, once-per-phase use and native Mentat collection remain unchanged. No Homeworlds, Discovery, expansion-family Nexus or Betrayal overlay is admitted by this entry; ordinary skill starts still require the separate income opt-in.', - '\nBanker-income --homeworlds composes fresh classic Basic/Advanced original Homeworld occupation and all14 Skills without Nexus, Discovery, Tech, Strongholds or expansion/Betrayal overlays. Only actual final Bank portions qualify, including the same payer’s Bureaucrat diversion. Allied income rounds once for the whole eligible invoice; private rounding/diversion attribution preserves full payer debits and occupied player shares. Original Mentat collects automatically; no existing game is converted.', - '\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.', + '\nLeader-skills preserves canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Mixed E3 families outside the documented Nexus envelopes, other modules and played-game conversion remain excluded.', + '\nAll fresh supported all14 Skills compositions retain mandatory normal Banker income, including original Nexus, Discovery, World and Semuta envelopes. Actual bank payer legs, once-per-phase use and original Mentat collection remain unchanged. No existing game is converted or retrofitted.', + '\n--profile homeworld-occupation --leader-skills preserves fresh classic Basic/Advanced original Homeworld occupation and all14 Skills without Nexus, Discovery, Tech, Strongholds or expansion/Betrayal overlays. Only actual final Bank portions qualify for normal Banker income, including the same payer’s Bureaucrat diversion. Allied income rounds once for the whole eligible invoice; private rounding/diversion attribution preserves full payer debits and occupied player shares. Ordinary --profile leader-skills --homeworlds retains the original supported World selection without adding occupation preview flags.', + '\nLeader-skills also preserves canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families outside documented Nexus envelopes, other overlays, public activation and save conversion stay excluded.', '\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.', '\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.', '\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Existing Homeworlds may be retained. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, expansion families outside their documented entries, unrelated previews, unresolved effects and public starts remain separate.', @@ -55,7 +55,7 @@ async function main() { '\nNexus additionally admits exactly one native Ixians OR Tleilaxu/Ix47, or CHOAM OR Richese/CHOAM35 with its original cache, plus classic opponents, Basic/Advanced2–6. Original Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds2+ may remain; leader-skills --nexus-cards retains all14 original offers. Ix offer/HMS, Tleilaxu Face Dancers, CHOAM Auditor and signed Richese markers remain physical. Original paired entries require both natives; additional exact mixed/paired E3 compositions are below. Public starts, played-save conversion and pending effect rulings remain separate.', '\nOriginal Nexus and Skills/Nexus additionally admit natives from BOTH selected Ix+CHOAM families with classics/union47, or BOTH Ecaz+Moritani with classics/ecaz33, Basic/Advanced2–6. Optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+ retain original consumers. No-Skills paired E3 now composes Advanced Harkonnen and captured-Duke/Assassination paths; all14 Skills keeps its Harkonnen/shared-training exclusions. Richese cache stays separate. Existing mixed-marker, unusual-Ghola, supply/relocation and other effect guards remain; no public start or played-save conversion.', '\nThe same original Nexus/Skills-Nexus entries compose selected Ecaz plus Ix and/or CHOAM, with an actual native from EVERY selected family and optional classics: two-family mixed E3 or all-three-family, Basic/Advanced2–6, all12 Nexus/all14 only with Skills and original optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+. Original selected decks/cache, offers and setup remain. No-Skills Moritani/Harkonnen now uses original capture, shared-Duke and printed-faction assassination; Skills/Harkonnen and exceptional target guards remain. Shared Duke never trains; foreign-Duke revival remains forbidden. No public start or played-save conversion.', - '\nLeader-skills --nexus-cards adds all12 Nexus to original classic/base33, single or paired one-family E1/E2, both selected Ix+CHOAM/union47, standalone or paired E3/ecaz33, or mixed E3/two-or-three selected families with an actual native from each and classics. All14 original offers and Basic/Advanced2–6 remain; Homeworlds may be retained, Tech3+/StrongholdsAdvanced. Actual Ix pre-training hand, ordinary discs and native components remain. Each capture/exceptional shared-Duke, Banker preview, borrowed Smuggler/companion/native-return and pending-effect boundary remains; no public start or played conversion.', + '\nLeader-skills --nexus-cards adds all12 Nexus to original classic/base33, single or paired one-family E1/E2, both selected Ix+CHOAM/union47, standalone or paired E3/ecaz33, or mixed E3/two-or-three selected families with an actual native from each and classics. All14 original offers and Basic/Advanced2–6 remain; Homeworlds may be retained, Tech3+/StrongholdsAdvanced. Actual Ix pre-training hand, ordinary discs and native components remain. Each capture/exceptional shared-Duke, borrowed Smuggler/companion/native-return and pending-effect boundary remains; no public start or played conversion.', '\nThese classic, single-native or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.', '\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.', '\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.', @@ -71,14 +71,14 @@ async function main() { } if (values['ecaz-treachery'] && values.profile !== 'ecaz-occupy') throw new Error('--ecaz-treachery requires --profile ecaz-occupy.'); - if (values['nexus-cards'] && values.profile !== 'leader-skills' && values.profile !== 'semuta' && values.profile !== 'banker-income') - throw new Error('--nexus-cards requires --profile leader-skills, banker-income or semuta.'); + if (values['nexus-cards'] && values.profile !== 'leader-skills' && values.profile !== 'semuta') + throw new Error('--nexus-cards requires --profile leader-skills or semuta.'); if (values.discoveries && values.profile !== 'leader-skills' && values.profile !== 'nexus' && values.profile !== 'semuta') throw new Error('--discoveries requires --profile leader-skills, nexus or semuta.'); if (values['leader-skills'] && values.profile !== 'semuta' && values.profile !== 'homeworld-occupation' && values.profile !== 'guild-betrayal') throw new Error('--leader-skills requires --profile semuta, homeworld-occupation or guild-betrayal.'); - if (values.homeworlds && values.profile !== 'semuta' && values.profile !== 'banker-income' && values.profile !== 'ecaz-treachery') - throw new Error('--homeworlds requires --profile semuta, banker-income or ecaz-treachery.'); + if (values.homeworlds && values.profile !== 'semuta' && values.profile !== 'leader-skills' && values.profile !== 'ecaz-treachery') + throw new Error('--homeworlds requires --profile semuta, leader-skills or ecaz-treachery.'); if ( !isPrototypeProfile(values.profile) || !values.db ||