Make normal Banker income mandatory in fresh Skills setups

This commit is contained in:
Quintine committed 2026-10-17 12:58:29 +11:00
1 parent 628f59b73a
commit 3ed9856578
43 files changed
+1244 -263

No files matched your search

+8 -8
View File
@@ -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`
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
</p>
<a href="/rules?topic=spice-banker-income#spice-banker-income">Income timing and preview limits</a>
<a href="/rules?topic=spice-banker-income#spice-banker-income">Income timing and skill limits</a>
</section>
);
}
+19
View File
@@ -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
+35
View File
@@ -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
+9 -2
View File
@@ -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).
+2 -2
View File
@@ -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) |
+74 -19
View File
@@ -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.
+1 -2
View File
@@ -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;
+11 -56
View File
@@ -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<string, readonly string[]> = {
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.');
}
+6 -13
View File
@@ -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;
}
+8 -8
View File
@@ -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.',
@@ -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;
+190
View File
@@ -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);
});
+154
View File
@@ -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);
});
+2 -2
View File
@@ -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);
@@ -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);
+3 -4
View File
@@ -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);
});
@@ -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));
+2 -2
View File
@@ -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);
+2 -3
View File
@@ -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. */
+439
View File
@@ -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<T>(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 = <V extends ArrayBufferView | null>(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;
}
+210
View File
@@ -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<T>(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 = <V extends ArrayBufferView | null>(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.');
}
+2 -2
View File
@@ -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') {
+3 -6
View File
@@ -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<StrongholdId, 'hidden_mobile_stronghold'>;
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') {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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 });
@@ -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 });
+1 -3
View File
@@ -22,8 +22,6 @@ export type EcazSkillsModulesOptions = {
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
strongholdKind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
/** 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') {
+4 -5
View File
@@ -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
+6 -10
View File
@@ -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.
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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<string, LeaderSkillId> = 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') {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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') {
+2 -2
View File
@@ -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.');
@@ -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);
+5 -63
View File
@@ -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);
+5 -3
View File
@@ -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));
+1 -1
View File
@@ -90,7 +90,7 @@ async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixt
}
// BG keeps the entry's actual native setup undealt. The fixture consumes that
// original setup; it cannot reset already dealt cards or replace an offer.
withSpiceBankerIncomeSetupRandomness(() => 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);
+5 -9
View File
@@ -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,
+12 -12
View File
@@ -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 ||