diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 056a5dd7..ebe22dc8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Classic Nexus normal Banker income — 13 October.** +The existing explicit `banker-income --nexus-cards` entry now composes native +classic Basic/Advanced all14/all12 setup, optionally Tech3+ and Advanced +Strongholds2+. Printed final-bank-leg, phase-use, deferred budget and original +Mentat settlement are unchanged. Native Guild pays4/wallet5→1/front1; paid3 +and Emperor's player-recipient4 earn none. Actual390px Collection Ready credits +Atreides10→11 at real Mentat, removes front1 and adds no collect action. +CLI `BANKERQA v8` retains real offers/all14, Nexus deck12 and income-v1. +Existing earned-custody policy and ordinary opt-in remain; Homeworlds, +Discovery, native-family Nexus, Betrayal and full-mode gates stay separate. +[Canonical source and local proof](SPICE_BANKER_RUNTIME.md#classic-nexus-income-composition--13-october-2026). +Final seven-file union159/159 and types/lint pass, including actual separate +Heighliners settlement. No broad-suite, full-combination or deployed claim. + **Checkpoint test repairs — 13 October.** The observed occupied-Guild failure came from always shipping to Arsunt11, even when the actual turn3 storm was there. The native fixture now chooses diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b46ed493..bf205704 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 13 October 2026 — classic Nexus normal Banker income + +The current fresh Banker entry rejected classic Nexus before native offers. +The classic Nexus skill predicate now admits its explicit income overlay +without enabling other native-family overlays. The original initializer +passes Nexus setup and original optional Tech3+/Advanced Strongholds2+ paths. +Both CLI and room-option validation admit `banker-income --nexus-cards`. +No printed payment, receipt, deferred-currency or collection rule changes. + +Native Guild self-shipment pays4/wallet5→1 and leaves Atreides front1 with no +available-wallet increase. Actual paid3 earns none; Emperor-to-Guild4 remains +player income. Native Advanced Guild timing and selected original modules +continue; actual Heighliners income settles at phase end, never another bank +invoice. The fixture's initial eight-force Polar shipment exceeded Guild's +original wallet; use the printed four-force/four-spice shipment instead, with +no wallet or force fabrication. An incidental empty-collection count pin is +removed: native Mentat records its epoch even when there is no grant. + +The seven-file related union passes158/158 and types/lint pass before adding +the explicit separate-Tech-credit regression. Actual isolated CLI +`BANKERQA v8` reaches native `leaderSkills`, all14 in original deck/offers, +Nexus deck12 and income-v1. Actual390px table shows deferred1 at wallet10; +the last ordinary Collection Ready enters real Mentat and credits10→11, +removing the notice with no extra action. Browser errors empty, no root +overflow. `/tmp/dune-classic-nexus-banker-ui-proof.json` retains evidence. +Owned UI tab/host/scaffold removed; no live room or saved database changed. +The existing separate opt-in and provisional earned-custody/per-payer policies +remain. Homeworlds, Discovery, native-family Nexus, Betrayal, complete-mode +and deployed acceptance remain outside this checkpoint. + +Final seven-file union159/159 and `npm run check:quick` pass. The additional +native Advanced Emperor-to-Guild receipt proves phase-end Heighliners1, +independent ordinary Collection, zero Banker sources and unchanged Banker +wallet at Mentat. No full-suite or deployed acceptance was run for this slice. + ## 13 October 2026 — storm-aware occupation fixture and obsolete guidance tests The observed Advanced occupied-Guild receipt failure was in the fixture: diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index a1eafdc6..68777ecf 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,17 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Classic Nexus with normal Banker income — 13 October 2026 + +Fresh `banker-income --nexus-cards` now composes the existing deferred normal +Skill band with classic Basic/Advanced all14/all12 setup, optionally original +Tech3+ and Advanced Strongholds2+. Actual bank payer legs still qualify once +per phase; player income and phase-end Tech payouts do not qualify, and the +original Mentat opening collects automatically. No Nexus hand is dealt at +setup, no new collect action is added, and no played game is converted. +Homeworlds, Discovery, native-family Nexus and Betrayal previews remain +outside this entry. [Source, original costs and runtime proof](SPICE_BANKER_RUNTIME.md#classic-nexus-income-composition--13-october-2026). + ## Semuta with original native Nexus — 11 October 2026 Fresh `semuta --nexus-cards` composes native Richese with the original diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md index 6bd3a582..95e2ff78 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -1,5 +1,43 @@ # Spice Banker battle commitment and normal income prototypes +## 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 previous entry rejected this fresh composition before offers. The shared +classic Nexus skill predicate now recognizes the explicit income overlay, and +the Banker initializer passes the original Nexus setup path. Existing payment, +recipient, front-shield, per-phase and Mentat calculations are unchanged. + +Native Guild self-shipment to Polar Sink pays4 (wallet5→1) and grants Atreides +one unavailable spice. Three paid earns none; Emperor's four paid to Guild earns +none. Advanced original Tech/Strongholds retain native Guild timing; actual +Heighliners income settles at phase end rather than becoming a Banker invoice. +The existing legal policy continues without spending deferred currency. +Actual390px table shows front1 while available wallet remains10; the last +ordinary Collection Ready enters real Mentat, credits10→11 and removes the +front-shield notice without an extra action. Browser errors empty; no root +overflow. `/tmp/dune-classic-nexus-banker-ui-proof.json` records local proof. + +Actual isolated CLI `BANKERQA v8` reaches `leaderSkills`, income-v1, all14 +original cards in deck/offers and all12 Nexus in deck. No existing room or +database is changed. Evidence: +`tests/classic-nexus-banker-income.test.ts` and +`tests/fixture-classic-nexus-banker-income.ts`. +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 \ + --db /private/local.sqlite --room ROOMCODE --version 7 \ + --out /private/new-classic-nexus-banker-entry +``` + **2 October normal-band checkpoint:** a separate fresh local `banker-income` classic/base33/all-fourteen-skills-only Basic/Advanced profile connects actual positive bank payments to automatic current Mentat collection. diff --git a/game/engine.ts b/game/engine.ts index f8d048fb..673eb628 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8317,12 +8317,14 @@ function bankerIncomeContext(g: Game): BankerIncomeContext { assignments: g.leaderSkills?.assignments ?? []}; } function bankerIncomeProfile(g: Game): boolean { + const classicNexus = classicNexusLeaderSkillsProfile(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 && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || strongholdLeaderSkillsProfile(g)) && - (!g.strongholdCards || strongholdLeaderSkillsProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && + (!g.nexusCards || classicNexus) && !g.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 && @@ -10051,7 +10053,7 @@ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game { g.leaderSkills = createLeaderSkills(random); g.spiceBankerIncomePreview = true; g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g))); - const initialized = initializeSetupGameForAudit(g, false, false, false, false, true, + 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; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index c76394c4..d4435ca5 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -34,7 +34,7 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false, betrayal = false): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false, betrayal = false, bankerIncome = false): boolean { return !!game.nexusCards && typeof game.advanced === 'boolean' && !!game.players && game.players.length >= 2 && game.players.length <= 6 && ((discovery && game.discoveryEnabled === true) || @@ -49,12 +49,14 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio (!game.guildBetrayalPreview || betrayal) && !game.richeseBetrayalPreview && !game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview && !game.nexusHarkonnenBetrayalPreview && - !game.spiceBankerIncomePreview; + (!game.spiceBankerIncomePreview || bankerIncome); } /** Fresh classic Nexus/Skills, optionally Homeworlds/Discovery/Tech/Advanced Strongholds. */ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true)) return false; + 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; 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; diff --git a/game/reference.ts b/game/reference.ts index 5f0bef06..e3729e75 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4747,6 +4747,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 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 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.', diff --git a/tests/classic-nexus-banker-income.test.ts b/tests/classic-nexus-banker-income.test.ts new file mode 100644 index 00000000..0d535641 --- /dev/null +++ b/tests/classic-nexus-banker-income.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, initializeSpiceBankerIncomeGameForAudit, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { + collectClassicNexusBanker, createClassicNexusBankerFixture, nexusBankerPlayer, +} from './fixture-classic-nexus-banker-income'; + +void test('classic Nexus Banker holds the actual four-spice bank grant until original Mentat', () => { + const f = createClassicNexusBankerFixture(); + assert.equal(nexusBankerPlayer(f.paid, f.payer).spice, nexusBankerPlayer(f.beforePayment, f.payer).spice - 4); + assert.equal(nexusBankerPlayer(f.paid, f.payer).forces['polar_sink:0'], f.amount); + assert.equal(nexusBankerPlayer(f.paid, f.owner).spice, nexusBankerPlayer(f.beforePayment, f.owner).spice); + assert.deepEqual(viewGame(f.paid, f.owner).spiceBankerIncome!.deferred, [{owner:'a',amount:1}]); + const receipt = f.paid.spiceBankerIncome!.sources.at(-1)!; + assert.deepEqual(receipt.source.bankLegs, [{payer:'g',amount:4}]); + assert.equal(receipt.grant!.owner, 'a'); + assert.deepEqual(f.paid.nexusCards, f.beforePayment.nexusCards); + const {beforeMentat,collected} = collectClassicNexusBanker(f.paid); + assert.equal(nexusBankerPlayer(collected, f.owner).spice, nexusBankerPlayer(beforeMentat, f.owner).spice + 1); + assert.deepEqual(viewGame(collected, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(collected.spiceBankerIncome!.collections.length, 1); + assert.deepEqual(collected.spiceBankerIncome!.collections[0].credits, [{owner:'a',amount:1}]); + validateLeaderSkills(collected.leaderSkills!, collected.players); +}); + +void test('a three-spice bank shipment never turns into four through Nexus or front-shield accounting', () => { + const f = createClassicNexusBankerFixture({amount:3}); + assert.equal(nexusBankerPlayer(f.paid, f.payer).spice, nexusBankerPlayer(f.beforePayment, f.payer).spice - 3); + assert.deepEqual(f.paid.spiceBankerIncome!.sources.at(-1)!.source.bankLegs, [{payer:'g',amount:3}]); + assert.equal(f.paid.spiceBankerIncome!.sources.at(-1)!.grant, null); + assert.deepEqual(viewGame(f.paid, f.owner).spiceBankerIncome!.deferred, []); + const {beforeMentat,collected} = collectClassicNexusBanker(f.paid); + assert.equal(nexusBankerPlayer(collected, f.owner).spice, nexusBankerPlayer(beforeMentat, f.owner).spice); +}); + +void test('original Guild player income remains a player payment rather than a qualifying bank leg', () => { + const f = createClassicNexusBankerFixture({payer:'e'}); + assert.equal(nexusBankerPlayer(f.paid, 'e').spice, nexusBankerPlayer(f.beforePayment, 'e').spice - 4); + assert.equal(nexusBankerPlayer(f.paid, 'g').spice, nexusBankerPlayer(f.beforePayment, 'g').spice + 4); + assert.deepEqual(viewGame(f.paid, f.owner).spiceBankerIncome!.deferred, []); + assert.equal(f.paid.spiceBankerIncome!.sources.length, 0); + const {beforeMentat,collected} = collectClassicNexusBanker(f.paid); + assert.equal(nexusBankerPlayer(collected, f.owner).spice, nexusBankerPlayer(beforeMentat, f.owner).spice); +}); + +void test('Advanced flexible Guild timing retains deferred Nexus income with selected Tech and Strongholds', () => { + const f = createClassicNexusBankerFixture({advanced:true,tech:true,strongholds:true}); + assert.equal(nexusBankerPlayer(f.paid, 'g').spice, nexusBankerPlayer(f.beforePayment, 'g').spice - 4); + assert.deepEqual(viewGame(f.paid, f.owner).spiceBankerIncome!.deferred, [{owner:'a',amount:1}]); + const {beforeMentat,collected} = collectClassicNexusBanker(f.paid); + assert.equal(nexusBankerPlayer(collected, f.owner).spice, nexusBankerPlayer(beforeMentat, f.owner).spice + 1); + assert.deepEqual(collected.spiceBankerIncome!.collections[0].credits, [{owner:'a',amount:1}]); + assert.equal(collected.spiceBankerIncome!.sources.filter(source=>source.grant).length, 1); + validateLeaderSkills(collected.leaderSkills!, collected.players); +}); + +void test('actual Heighliners income pays at phase end without manufacturing a Banker bank payment', () => { + const f = createClassicNexusBankerFixture({advanced:true,tech:true,strongholds:true,payer:'e'}); + const token = f.paid.techTokens!.heighliners; + assert.ok(token.owner); + assert.equal(token.spice, 1); + assert.equal(nexusBankerPlayer(f.paid, 'e').spice, nexusBankerPlayer(f.beforePayment, 'e').spice - 4); + assert.equal(nexusBankerPlayer(f.paid, 'g').spice, nexusBankerPlayer(f.beforePayment, 'g').spice + 4); + assert.deepEqual(viewGame(f.paid, f.owner).spiceBankerIncome!.deferred, []); + const ordinary = quoteSpiceCollection(f.paid).receipts.find(row => row.player === token.owner)!; + const {beforeMentat,collected} = collectClassicNexusBanker(f.paid); + assert.equal(nexusBankerPlayer(beforeMentat, token.owner).spice, + nexusBankerPlayer(f.paid, token.owner).spice + 1 + ordinary.strongholds + ordinary.collected); + assert.equal(collected.techTokens!.heighliners.spice, 0); + assert.equal(nexusBankerPlayer(collected, f.owner).spice, nexusBankerPlayer(beforeMentat, f.owner).spice); + assert.equal(collected.spiceBankerIncome!.sources.length, 0); +}); + +void test('Nexus Banker keeps available wallet budgets and existing legal policy actions; setup cannot be redealt', () => { + const f = createClassicNexusBankerFixture(); + const view = viewGame(f.paid, f.payer); + view.players.find(p=>p.id===f.payer)!.bot = 'Easy'; + const action = botActions(view)[0]; + assert.ok(action); + const next = applyAction(f.paid, f.payer, action); + 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.deepEqual(f.paid,snapshot); + assert.throws(()=>applyAction(f.paid,f.owner,{type:'collectSpiceBankerIncome'})); + assert.deepEqual(f.paid,snapshot); +}); diff --git a/tests/fixture-classic-nexus-banker-income.ts b/tests/fixture-classic-nexus-banker-income.ts new file mode 100644 index 00000000..008cc754 --- /dev/null +++ b/tests/fixture-classic-nexus-banker-income.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSpiceBankerIncomeGameForAudit, + joinGame, newPlayer, viewGame, type Game, type Action, +} from '../game/engine'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { createTechTokens } from '../game/tech-tokens'; +import { createStrongholdCards } from '../game/stronghold-cards'; +import { withSpiceBankerIncomeSetupRandomness } from './fixture-spice-banker-income'; +import { nextClassicNexusSkillsPaymentsNativeStep } from './fixture-classic-nexus-skills-payments'; + +export const nexusBankerPlayer = (game: Game, id: string) => { + const player = game.players.find(p => p.id === id); + assert.ok(player); + return player; +}; +export const nexusBankerClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +export function nexusBankerStep(game: Game): Game { + const step = nextClassicNexusSkillsPaymentsNativeStep(game); + assert.ok(step, 'The original Nexus/Skills continuation must retain a legal participant'); + return applyAction(game, step.actor, step.action); +} +export function advanceNexusBanker(state: Game, until: (game: Game) => boolean): Game { + let game = state; + for (let n = 0; n < 1200; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + game = nexusBankerStep(game); + } + throw Error('Native Nexus Banker continuation missed its requested boundary'); +} + +/** Original fresh lobby selection, genuine offers and native shipment timing. + * Only the original Skill shuffle is controlled to give the first seat Banker. + * No wallet, force, phase, receipt, plan or Nexus hand is manufactured. */ +export function createClassicNexusBankerFixture(options: { + advanced?: boolean; amount?: number; payer?: 'g' | 'e'; tech?: boolean; strongholds?: boolean; +} = {}) { + const { advanced = false, payer = 'g', amount = payer === 'g' ? 4 : 2, tech = false, strongholds = false } = options; + let game = createGame('CLASSICNEXUSBANKER', newPlayer('a', 'Atreides', 'atreides'), advanced, []); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + game.nexusCards = { cards: null, phase: null }; + if (tech) game.techTokens = createTechTokens(); + 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)); + const started = structuredClone(game); + while (game.status === 'setup') { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const skills = viewGame(game, actor).leaderSkills!; + const skill = actor === 'a' ? 'spice-banker' : skills.offer!.cards.find(id => !skills.unavailableSkills?.[id]); + assert.ok(skill && skills.offer!.cards.includes(skill)); + game = applyAction(game, actor, { type: 'leaderSkill', event: skills.offer!.event, + skill, leader: skills.eligibleLeaders[0].id }); + } else game = nexusBankerStep(game); + } + validateLeaderSkills(game.leaderSkills!, game.players); + const setup = structuredClone(game); + game = advanceNexusBanker(game, g => g.turn === 1 && g.phase === 5 && g.active === payer && nexusBankerClean(g)); + const action: Action = { type: 'ship', territory: 'polar_sink', sector: 0, amount, elite: 0 }; + const beforePayment = structuredClone(game); + game = applyAction(game, payer, action); + game = advanceNexusBanker(game, nexusBankerClean); + return { lobby, started, setup, beforePayment, paid: game, payer, amount, action, owner: 'a' }; +} + +/** Stop around the actual phase transition so the separate Mentat grant is + * never mistaken for ordinary Arrakeen/Tech/Collection income. */ +export function collectClassicNexusBanker(state: Game) { + let game = state; + let beforeMentat: Game | undefined; + for (let n = 0; n < 1200; n++) { + if (game.phase === 8 && nexusBankerClean(game)) { + assert.ok(beforeMentat); + return { beforeMentat, collected: game }; + } + const next = nexusBankerStep(game); + if (game.phase !== 8 && next.phase === 8) beforeMentat = structuredClone(game); + game = next; + } + throw Error('Native Nexus Banker did not reach actual Mentat'); +} diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 9354f55c..de13ca75 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -866,7 +866,6 @@ void test('Banker income rejects incompatible profiles and public activation wit game => { game.discoveryEnabled = true; }, game => { game.ecazTreachery = true; }, game => { game.semutaPreview = true; }, - game => { game.nexusCards = { cards: null, phase: null }; }, game => { game.nexusIxianReplacementPreview = true; }, game => { game.nexusIxianBetrayalPreview = true; }, game => { game.nexusHarkonnenBetrayalPreview = true; }, diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index be881aef..20ebaf42 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -101,8 +101,8 @@ 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') - throw new Error('--nexus-cards requires the leader-skills or semuta 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.discoveries && profile !== 'leader-skills' && profile !== 'nexus') throw new Error('--discoveries requires the leader-skills or nexus profile.'); if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation' && profile !== 'guild-betrayal') diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 8eebd59c..b42b30b1 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -39,6 +39,7 @@ async function main() { '\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.', '\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.', '\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.', '\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.', @@ -64,8 +65,8 @@ 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') - throw new Error('--nexus-cards requires --profile leader-skills or semuta.'); + 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.discoveries && values.profile !== 'leader-skills' && values.profile !== 'nexus') throw new Error('--discoveries requires --profile leader-skills or nexus.'); if (values['leader-skills'] && values.profile !== 'semuta')