From 00e01a7cd2d70fc463de3cc04c4fdebd60a858f5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:26:46 +1100 Subject: [PATCH] Support selected Nexus decks with leader skills --- docs/CURRENT_STATUS.md | 14 +- docs/LEADER_SKILLS_RUNTIME.md | 12 ++ docs/NEXUS_CARD_RULES.md | 4 +- docs/RULE_DECISIONS.md | 6 + game/bureaucrat-payment.ts | 4 +- game/faction-module-profile.ts | 6 +- game/leader-skill-profile.ts | 22 ++- game/reference.ts | 2 +- ...ssic-nexus-skills-payments-runtime.test.ts | 21 ++- .../fixture-classic-nexus-skills-payments.ts | 4 + tests/selected-nexus-no-cards-profile.test.ts | 5 +- .../selected-nexus-skills-boundaries.test.ts | 92 +++++++++++ ...lected-nexus-skills-unused-runtime.test.ts | 145 ++++++++++++++++++ 13 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 tests/selected-nexus-skills-boundaries.test.ts create mode 100644 tests/selected-nexus-skills-unused-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d6c91367..7aaee0c3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,13 +17,23 @@ open mode gates. ## Current checkpoint and work +**Selected Nexus with Leader Skills and unused families — 29 October. +Prototyped / Partial.** Basic and Advanced previews support selected decks, +Nexus and Leader Skills even when some families are unseated, without requiring +independent Ecaz cards. Discovery may also be selected. Twelve related files +pass 121 cases; public-start fixtures train leaders and exercise a real worm, +Nexus draw and shipment, and Discovery blow with controlled unopened cards. +Advanced Harkonnen with Ecaz or Moritani, wider interactions, complete games +and deployed verification remain open. +[Scope](LEADER_SKILLS_RUNTIME.md#selected-nexus-with-unused-families--29-october-2026). + **Selected decks with Nexus or Discovery without extra cards — 29 October. Prototyped / Partial.** Basic and Advanced Nexus previews admit selected but unseated families without independent Ecaz cards, optionally with Discovery; Discovery also admits a lone E3 deck with classic seats. Public-start fixtures exercise an actual worm, Nexus draw, card-powered shipment and Discovery blow. -Fifteen related files pass 118 cases. Leader Skills, Advanced Harkonnen with -Ecaz or Moritani in the new Nexus profile, deeper interactions, complete games +Fifteen related files pass 118 cases. The newer Skills entry above extends this +profile; Advanced Harkonnen with Ecaz or Moritani, deeper interactions, complete games and deployed verification remain open. [Nexus scope](NEXUS_CARD_RULES.md#selected-decks-without-independent-cards--29-october-2026); [Discovery scope](DISCOVERY_PROTOTYPE.md#selected-but-unused-e3-deck--29-october-2026). diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 00b12378..dffdffb1 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -1,5 +1,17 @@ # Leader Skills first functional prototype +## Selected Nexus with unused families — 29 October 2026 + +**Prototyped / Partial.** A fresh Basic or Advanced Nexus table can train +leaders while using selected expansion decks without independent Ecaz cards, +even when a selected family has no seated native faction. Existing Skills and +Nexus controls and physical components remain in use. Ecaz Occupy and Advanced +Moritani assassination retain their native setup; Advanced Harkonnen with +Ecaz or Moritani remains guarded. Twelve related files pass 121 cases. Public +Basic/Advanced starts train leaders, trigger a real Nexus draw and shipment, +and resolve Discovery with controlled unopened card order. Optional-module +interactions, complete games and deployed verification remain open. + ## Independent band inventory — 26 October 2026 A bounded delivery inventory finds connected first versions of all28 independent diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index b9e06528..6c7f00ae 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -7,8 +7,8 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups **Prototyped / Partial.** Fresh Basic and Advanced Nexus previews can select original expansion decks without the independent Ecaz cards when at least one selected family has no seated native faction. Discovery may also be selected. -Existing seat and module prerequisites remain; Leader Skills and Advanced -Harkonnen with Ecaz or Moritani retain their separate boundaries. Ecaz Occupy, +Existing seat and module prerequisites remain; Leader Skills can now be selected. +Advanced Harkonnen with Ecaz or Moritani retains its separate boundary. Ecaz Occupy, Moritani assassination and their shared arrival use their existing handlers. Public-start fixtures cover classic and native seats, original selected card diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 66f5aee1..6c7587e5 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Selected Nexus with Leader Skills — 29 October 2026 + +Selecting Nexus and Leader Skills with expansion decks does not require every +selected family to be seated or independent Ecaz cards. Existing capture and +optional-module boundaries remain. [Scope](LEADER_SKILLS_RUNTIME.md#selected-nexus-with-unused-families--29-october-2026). + ## Selected decks with Nexus or Discovery — 29 October 2026 Selecting an expansion deck does not require seating its faction or enabling diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 471e6bbc..606745ed 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, selectedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -46,7 +46,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea return independentTreacheryLeaderSkillsProfile(game) || (!game.homeworlds && game.expansions.length === 1 && (game.expansions[0] === 'choam' || game.expansions[0] === 'ix') && pairedNexusLeaderSkillsProfile(game)) || - mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + selectedNexusLeaderSkillsProfile(game) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || guildBetrayalNexusLeaderSkillsProfile(game) || (noOtherLeaderSkillModules(game) && (!game.expansions.length || diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts index 9bc66d8a..29a45ddb 100644 --- a/game/faction-module-profile.ts +++ b/game/faction-module-profile.ts @@ -60,11 +60,11 @@ export function independentDiscoveryNexusProfile(game: NexusCoreConfiguration): } /** Original selected decks do not require their native factions or the - * independent three-card variant. Keep Skills and hidden E3/Harkonnen - * compositions on their existing separately scoped paths. */ + * independent three-card variant. Hidden E3/Harkonnen compositions retain + * their existing separately scoped paths. */ export function selectedNexusWithoutCardsProfile(game: NexusCoreConfiguration): boolean { const players = game.players; - if (game.ecazTreachery || game.leaderSkills || !game.nexusCards || !players || + if (game.ecazTreachery || !game.nexusCards || !players || !game.expansions.length || !nexusCoreRosterProfile(game, true, game.discoveryEnabled === true)) return false; if (game.advanced && players.some(player => player.faction === 'harkonnen') && players.some(player => player.faction === 'ecaz' || player.faction === 'moritani')) return false; diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 75250345..0f2fa7a8 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -1,5 +1,5 @@ import { FACTIONS } from './catalog'; -import { independentDiscoveryProfile, independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile } from './faction-module-profile'; +import { independentDiscoveryProfile, independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile, selectedNexusWithoutCardsProfile } from './faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; /** Public configuration only; never gate a skill on a hidden card or random deal. */ @@ -204,6 +204,13 @@ export function mixedE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boole mixedE3NexusRosterProfile(game, true); } +/** Original selected decks with Nexus and Skills do not require a seat from + * every selected family. Keep independent cards and other previews on their + * own paths, including the Advanced hidden E3/Harkonnen capture boundary. */ +export function selectedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + return selectedNexusWithoutCardsProfile(game); +} + /** Discovery keeps original native roster predicates; only this module envelope * differs. Live token/entry/Great Maker frames are original consumers. */ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { @@ -298,7 +305,7 @@ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolea export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (!game.strongholdCards || game.advanced !== true) return false; if (independentTreacheryLeaderSkillsProfile(game)) return true; - if (game.expansions.length) return selectedDeckLeaderSkillsProfile(game) || + if (game.expansions.length) return selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) || advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); const players = game.players; if (!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 || @@ -321,7 +328,7 @@ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean /** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.advanced === false && game.players?.some(player => player.faction === 'moritani') && - selectedDeckLeaderSkillsProfile(game)) return true; + (selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true; return game.advanced === false && game.expansions.length === 1 && game.expansions[0] === 'ecaz' && e3LeaderSkillModulesSupported(game) && !!game.players?.some(player => player.faction === 'moritani') && @@ -332,7 +339,7 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool /** Existing bounded Advanced assassination roster, composed with native skills. */ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.advanced === true && game.players?.some(player => player.faction === 'moritani') && - selectedDeckLeaderSkillsProfile(game)) return true; + (selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true; const players = game.players; if (game.advanced !== true || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -358,7 +365,8 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b /** Ecaz's five native discs; the separate temporarily controlled Duke is not an assignment disc. */ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.players?.some(player => player.faction === 'ecaz') && selectedDeckLeaderSkillsProfile(game)) return true; + if (game.players?.some(player => player.faction === 'ecaz') && + (selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true; const players = game.players; if (typeof game.advanced !== 'boolean' || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || @@ -434,7 +442,7 @@ export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boole export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return (game.expansions.length > 0 && independentTreacheryLeaderSkillsProfile(game)) || - mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + selectedNexusLeaderSkillsProfile(game) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || basicMoritaniLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) || advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); } @@ -529,7 +537,7 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool (!game.expansions.length || nativeExpansionLeaderSkillsProfile(game) || (game.expansions.length === 1 && game.expansions[0] === 'choam'))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || - strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || + strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || guildBetrayalNexusLeaderSkillsProfile(game) || singleE1E2NexusLeaderSkillsProfile(game) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || diff --git a/game/reference.ts b/game/reference.ts index add89cdf..b6d76b58 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6294,7 +6294,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.', 'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes include classics/base33, selected E1/E2 natives/union47 and standalone, paired or mixed E3 native families. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Original no-Skills E3 Nexus now admits Advanced Harkonnen; Skills retains its Harkonnen exclusion and shared Duke never trains. Public starts, played-save conversion and pending effects remain separate.', - 'Fresh selected-deck Nexus previews also admit an unseated selected family without independent Ecaz cards, with optional Discovery. Leader Skills and Advanced Harkonnen with Ecaz or Moritani retain their separate restrictions; wider interactions and complete-mode acceptance remain open.', + 'Fresh selected-deck Nexus previews also admit an unseated selected family without independent Ecaz cards, with optional Leader Skills and Discovery. Advanced Harkonnen with Ecaz or Moritani retains its separate restriction; wider interactions and complete-mode acceptance remain open.', 'Fresh nexus --discoveries uses the original no-Skills envelopes above with optional Homeworlds, Tech3+ and Advanced Strongholds. Seven Discovery Spice Cards/eight tokens, protected nested sites, next-turn physical entry and actual Maker losses/votes/reserve ride remain. The card draw follows both Advanced piles and actual settled alliance change; earlier Shai-Hulud losses never repeat. Source-clear quotas, borrowed Guild tariffs and signed native one-invoice pairs at revealed sites use original controls, not new transport adapters.', 'Without Skills, the existing generic Nexus initializer handles single and mixed selected E1/E2 natives, while paired one-family and paired E3 entries use their original paired initializer and require BOTH original natives. Native HMS/Face Dancers/Auditor/Richese cache, Ecaz ordinary discs/Loyalty/shared Duke and Advanced Moritani assassination remain physical. New overlays do not create a first Nexus hand, free native return, arbitrary marker companion or played-game conversion.', 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.', diff --git a/tests/classic-nexus-skills-payments-runtime.test.ts b/tests/classic-nexus-skills-payments-runtime.test.ts index f9d35367..94f196c3 100644 --- a/tests/classic-nexus-skills-payments-runtime.test.ts +++ b/tests/classic-nexus-skills-payments-runtime.test.ts @@ -113,9 +113,24 @@ for (const advanced of [false, true]) { sameSkillCustody(f.game, returned); for (const kind of ['emperor-revival', 'fremen-revival'] as const) { const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 }); - rejected(short.game, short.actor, short.paymentAction); - assert.equal(classicNexusSkillsPaymentsPlayer(short.game, short.actor).tanks, 2); - classicNexusSkillsPaymentsInventory(short.game); + const before = classicNexusSkillsPaymentsPlayer(short.game, short.actor); + if (kind === 'fremen-revival') { + // Atreides already revives both remaining counters for free, so the + // Fremen quota cannot improve this particular two-counter position. + rejected(short.game, short.actor, short.paymentAction); + assert.equal(before.tanks, 2); + classicNexusSkillsPaymentsInventory(short.game); + continue; + } + let granted = applyAction(short.game, short.actor, short.paymentAction); + granted = finishClassicNexusSkillsPaymentsFixture(short, granted); + const after = classicNexusSkillsPaymentsPlayer(granted, short.actor); + assert.equal(after.tanks, 0); + assert.equal(after.reserves, before.reserves + 2); + assert.equal(after.spice, before.spice); + assert.equal(after.revived, before.revived); + assert.equal(after.freeForcesRevived ?? 0, before.freeForcesRevived ?? 0); + classicNexusSkillsPaymentsInventory(granted); } const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' }); let ordinary = finishClassicNexusSkillsPaymentsFixture(extra, diff --git a/tests/fixture-classic-nexus-skills-payments.ts b/tests/fixture-classic-nexus-skills-payments.ts index 55586529..1496121e 100644 --- a/tests/fixture-classic-nexus-skills-payments.ts +++ b/tests/fixture-classic-nexus-skills-payments.ts @@ -316,6 +316,10 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki paymentAction = { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode }; } else { game = advanceClassicNexusSkillsPaymentsToPhase(game, 4, actions); + // Advanced Revival now follows the actual player order. Complete earlier + // seats' opportunities before the actor uses its ordinary revival quota. + game = advanceClassicNexusSkillsPayments(game, state => state.phase === 4 && clean(state) && + (!state.revivalOrder || state.revivalOrder.remaining[0] === actor), actions); const player = classicNexusSkillsPaymentsPlayer(game, actor); const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0; assert.ok(Number.isInteger(tanks) && tanks >= 0 && elite >= 0 && elite <= tanks); diff --git a/tests/selected-nexus-no-cards-profile.test.ts b/tests/selected-nexus-no-cards-profile.test.ts index 225d768e..878e156d 100644 --- a/tests/selected-nexus-no-cards-profile.test.ts +++ b/tests/selected-nexus-no-cards-profile.test.ts @@ -38,7 +38,6 @@ void test('selected Nexus admits independently selected decks with some or all n void test('selected Nexus rejects unrelated modules and illegal deck or seat configurations', () => { const rejected = [ { ...base, nexusCards: undefined }, - { ...base, leaderSkills: {} }, { ...base, ecazTreachery: true }, { ...base, expansions: [] }, { ...base, expansions: ['ix', 'ix'] }, @@ -72,7 +71,7 @@ void test('Discovery requires its explicit enabled selection, even with selected assert.equal(discoveryModeSupported(enabled), true); assert.equal(discoveryModeSupported({ ...enabled, discoveries: {}, discoveryStash: {}, greatMaker: {} }), true, 'Live Discovery objects do not erase an explicitly selected module'); - assert.equal(discoveryModeSupported({ ...enabled, leaderSkills: {} }), false); + assert.equal(discoveryModeSupported({ ...enabled, leaderSkills: {} }), true); }); void test('Advanced Harkonnen with either E3 native stays on its existing excluded boundary', () => { @@ -84,7 +83,7 @@ void test('Advanced Harkonnen with either E3 native stays on its existing exclud assert.equal(selectedNexusWithoutCardsProfile(advanced), false, faction); assert.equal(selectedNexusWithoutCardsProfile({ ...advanced, discoveryEnabled: true }), false, faction); assert.equal(discoveryModeSupported({ ...advanced, discoveryEnabled: true }), false, faction); - assert.equal(selectedNexusWithoutCardsProfile({ ...basic, leaderSkills: {} }), false, faction); + assert.equal(selectedNexusWithoutCardsProfile({ ...basic, leaderSkills: {} }), true, faction); } assert.equal(selectedNexusWithoutCardsProfile({ ...base, advanced: true, players: [{ faction: 'harkonnen' }, { faction: 'ixians' }] }), true, diff --git a/tests/selected-nexus-skills-boundaries.test.ts b/tests/selected-nexus-skills-boundaries.test.ts new file mode 100644 index 00000000..69d2e444 --- /dev/null +++ b/tests/selected-nexus-skills-boundaries.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment'; +import { discoveryModeSupported } from '../game/discovery-module-profile'; +import { nexusCoreProfile, selectedNexusWithoutCardsProfile, type FactionModuleProfile } from '../game/faction-module-profile'; +import { nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, + ordinaryLeaderSkillModeSupported, selectedNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; + +const base: FactionModuleProfile = { + advanced: false, + expansions: ['ix', 'choam', 'ecaz'], + players: [{ faction: 'atreides' }, { faction: 'guild' }, { faction: 'emperor' }], + nexusCards: {}, + leaderSkills: {}, +}; + +void test('selected Nexus Skills reuse ordinary Skills, Bureaucrat and Nexus rules with unused families', () => { + const rows: FactionModuleProfile[] = [ + base, + { ...base, advanced: true }, + { ...base, players: [{ faction: 'ixians' }, { faction: 'ecaz' }, { faction: 'guild' }], + nexusIxianBetrayalPreview: true, ecazOccupyPreview: true }, + { ...base, advanced: true, players: [{ faction: 'moritani' }, { faction: 'atreides' }, { faction: 'guild' }], + moritaniAssassinatePreview: true }, + { ...base, advanced: true, homeworlds: {}, techTokens: {}, strongholdCards: {} }, + { ...base, advanced: true, players: [{ faction: 'harkonnen' }, { faction: 'ixians' }], + nexusIxianBetrayalPreview: true }, + ]; + for (const row of rows) { + const label = JSON.stringify(row); + assert.equal(selectedNexusWithoutCardsProfile(row), true, label); + assert.equal(nexusCoreProfile(row), true, label); + assert.equal(selectedNexusLeaderSkillsProfile(row), true, label); + assert.equal(nativeExpansionLeaderSkillsProfile(row), true, label); + assert.equal(ordinaryLeaderSkillModeSupported(row), true, label); + assert.equal(bureaucratPaymentModeSupported(row), true, label); + } +}); + +void test('selected Nexus Skills Discovery requires selection and survives live original token frames', () => { + assert.equal(discoveryModeSupported(base), false); + assert.equal(nativeDiscoveryLeaderSkillsProfile(base), false); + const enabled: FactionModuleProfile = { ...base, discoveryEnabled: true }; + for (const row of [enabled, { ...enabled, discoveries: {}, discoveryStash: {}, greatMaker: {} }]) { + assert.equal(selectedNexusLeaderSkillsProfile(row), true); + assert.equal(discoveryModeSupported(row), true); + assert.equal(nativeDiscoveryLeaderSkillsProfile(row), true); + assert.equal(ordinaryLeaderSkillModeSupported(row), true); + assert.equal(bureaucratPaymentModeSupported(row), true); + } + assert.equal(selectedNexusWithoutCardsProfile({ ...base, discoveries: {} }), false); + assert.equal(discoveryModeSupported({ ...base, discoveries: {} }), false); +}); + +void test('selected Nexus Skills respect physical seat, family, optional-module and stamp boundaries', () => { + const rejected: FactionModuleProfile[] = [ + { ...base, nexusCards: undefined }, + { ...base, expansions: [] }, + { ...base, expansions: ['ix', 'ix'] }, + { ...base, expansions: ['ix', 'unknown'] }, + { ...base, players: [{ faction: 'atreides' }] }, + { ...base, players: [{ faction: 'atreides' }, { faction: 'atreides' }] }, + { ...base, players: [{ faction: 'atreides' }, { faction: 'unknown' }] }, + { ...base, expansions: ['ix'], players: [{ faction: 'choam' }, { faction: 'atreides' }] }, + { ...base, players: [{ faction: 'atreides' }, { faction: 'guild' }], techTokens: {} }, + { ...base, strongholdCards: {} }, + { ...base, ecazTreachery: true }, + { ...base, semutaPreview: true }, + { ...base, kullPreview: true }, + { ...base, nexusKullPreview: true }, + { ...base, nexusIxianBetrayalPreview: true }, + { ...base, moritaniAssassinatePreview: true }, + ]; + for (const row of rejected) { + const label = JSON.stringify(row); + assert.equal(selectedNexusWithoutCardsProfile(row), false, label); + assert.equal(selectedNexusLeaderSkillsProfile(row), false, label); + } +}); + +void test('Advanced Harkonnen with an actual E3 native remains excluded, but unused E3 is permitted', () => { + for (const faction of ['ecaz', 'moritani']) { + const players = [{ faction: 'harkonnen' }, { faction }]; + const basic = { ...base, players }, advanced = { ...basic, advanced: true }; + assert.equal(selectedNexusLeaderSkillsProfile(basic), true, faction); + assert.equal(selectedNexusLeaderSkillsProfile(advanced), false, faction); + assert.equal(discoveryModeSupported({ ...advanced, discoveryEnabled: true }), false, faction); + } + const noE3Seat = { ...base, advanced: true, players: [{ faction: 'harkonnen' }, { faction: 'ixians' }] }; + assert.equal(selectedNexusLeaderSkillsProfile(noE3Seat), true); + assert.equal(bureaucratPaymentModeSupported(noE3Seat), true); +}); diff --git a/tests/selected-nexus-skills-unused-runtime.test.ts b/tests/selected-nexus-skills-unused-runtime.test.ts new file mode 100644 index 00000000..19196fb4 --- /dev/null +++ b/tests/selected-nexus-skills-unused-runtime.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { TERRITORIES } from '../game/board'; +import type { FactionId } from '../game/catalog'; +import { treacheryDeck } from '../game/cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { validateNexusCards } from '../game/nexus-cards'; +import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills'; +import { actSelectedIndependentNexus as act, advanceSelectedIndependentNexus as advance, + selectedIndependentNexusClean as clean, type SelectedIndependentNexusStep } from './fixture-selected-independent-nexus-cards'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +type Options = { factions: readonly FactionId[]; advanced: boolean; discovery?: boolean }; + +/** A real public lobby with original selected decks and real private training. + * Only the original random lottery is fixed; no card or skill is granted. */ +function publicNexusSkills(options: Options) { + const [host, ...others] = options.factions; + let game = withClassicDiscoveryNexusLottery(() => createGame('NEXUSSKILLSUNUSED', + newPlayer(host, host, host), options.advanced, ['ix', 'choam', 'ecaz'])); + for (const faction of others) joinGame(game, newPlayer(faction, faction, faction)); + const actions: SelectedIndependentNexusStep[] = []; + const step = (actor: string, action: Action) => { game = act(game, actor, action, actions); }; + step(host, {type:'moduleOption', module:'leaderSkills', enabled:true}); + step(host, {type:'moduleOption', module:'nexusCards', enabled:true}); + if (options.discovery) step(host, {type:'moduleOption', module:'discoveries', enabled:true}); + for (const seat of game.players) step(seat.id, {type:'ready'}); + const lobby = structuredClone(game); + step(host, options.advanced ? {type:'start', advancedPreview:true} : {type:'start'}); + const setup = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 300; count++) { + const next = nextPairedChoamSkillsStep(game); + assert.ok(next, `No original setup control at ${game.setupStage ?? game.decision?.kind}`); + step(next.actor, next.action); + } + assert.equal(game.status, 'playing'); + return { lobby, setup, game, actions }; +} + +for (const advanced of [false, true]) for (const factions of [ + ['atreides', 'guild', 'harkonnen', 'emperor'], + ['ixians', 'ecaz', 'guild'], +] as const) void test(`public ${advanced ? 'Advanced' : 'Basic'} Nexus and Skills with unused selected family: ${factions.join('/')}`, () => { + const f = publicNexusSkills({advanced, factions}); + const game = f.game; + assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']); + assert.ok(f.actions.some(row => row.action.type === 'moduleOption' && row.action.module === 'nexusCards')); + assert.ok(f.actions.some(row => row.action.type === 'moduleOption' && row.action.module === 'leaderSkills')); + assert.equal(game.ecazTreachery, undefined); + assert.equal(game.leaderSkills!.assignments.length, factions.length); + assert.equal(f.actions.filter(row => row.action.type === 'leaderSkill').length, factions.length); + validateLeaderSkills(game.leaderSkills!, game.players); + validateNexusCards(game.nexusCards!.cards!, game.players); + assert.equal(game.nexusCards!.cards!.deck.length, 12); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)] + .map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort()); + if (game.players.some(player => player.faction === 'ecaz')) assert.equal(game.ecazOccupyPreview, true); + if (game.players.some(player => player.faction === 'ixians')) assert.equal(game.nexusIxianBetrayalPreview, true); + if (game.players.some(player => player.faction === 'harkonnen')) assert.equal(game.nexusHarkonnenBetrayalPreview, true); +}); + +void test('public Advanced Nexus/Skills initializes native Moritani assassination with unused selected families', () => { + const f = publicNexusSkills({advanced:true, factions:['moritani', 'atreides', 'guild']}); + assert.ok(f.game.moritaniAssassinate); + assert.equal(f.game.leaderSkills!.assignments.length, 3); + assert.ok(f.game.nexusCards?.cards); + validateLeaderSkills(f.game.leaderSkills!, f.game.players); + validateNexusCards(f.game.nexusCards!.cards!, f.game.players); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} public Nexus/Skills/Discovery with unused selected decks resolves a real Discovery Spice Blow`, () => { + const f = publicNexusSkills({advanced, discovery:true, factions:['atreides', 'guild', 'emperor']}); + let game = structuredClone(f.game); + assert.ok(game.discoveries); + assert.ok(game.nexusCards?.cards); + assert.equal(game.leaderSkills!.assignments.length, 3); + // Reorder original unopened Spice Cards; the real turn supplies placement. + for (const position of advanced ? [0, 1] : [0]) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && + (position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery)); + assert.ok(index >= position); + [game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]]; + } + game = advance(game, state => state.turn === 1 && state.phase === 5 && clean(state), f.actions); + assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === 'discovery-hagga-basin')); + const placed = game.discoveries!.tokens.filter(token => token.status === 'placed'); + assert.equal(placed.length, 1); + const source = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + assert.deepEqual([placed[0].type, placed[0].territory, placed[0].sector], + [source.type, source.territory, source.sector]); + validateDiscoveryState(game.discoveries!); + validateNexusCards(game.nexusCards!.cards!, game.players); + validateLeaderSkills(game.leaderSkills!, game.players); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Nexus/Skills selected-deck table draws and uses a real Nexus card`, () => { + const f = publicNexusSkills({advanced, factions:['atreides', 'guild', 'harkonnen', 'emperor']}); + let game: Game = structuredClone(f.game); + // Reorder only unopened original cards to reach an actual second-turn worm. + const take = (worm: boolean) => { + const index = game.spiceDeck.findIndex(card => worm + ? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed + : 'territory' in card && !card.discovery); + assert.ok(index >= 0); + return game.spiceDeck.splice(index, 1)[0]; + }; + const first = [take(false), ...(advanced ? [take(false)] : [])]; + const second = [take(true), take(false), ...(advanced ? [take(false)] : [])]; + game.spiceDeck.unshift(...first, ...second); + game = advance(game, state => state.turn === 2 && !!state.nexus && !state.spiceWindow && + !state.spiceResolution && clean(state), f.actions); + game = act(game, 'guild', {type:'alliance', target:'harkonnen'}, f.actions); + game = act(game, 'harkonnen', {type:'alliance', target:'guild'}, f.actions); + game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), f.actions); + const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('richese'); + assert.ok(index >= 0); deck.unshift(deck.splice(index, 1)[0]); + const before = structuredClone(game); + const view = viewGame(before, 'atreides'); + view.players.find(player => player.id === 'atreides')!.bot = 'Easy'; + const bot = botActions(view).find(action => action.type === 'nexusCardChoice'); + assert.ok(bot); + assert.doesNotThrow(() => act(before, 'atreides', bot)); + game = act(game, 'atreides', {type:'nexusCardChoice', turn:game.turn, + card:game.nexusCards!.cards!.hands.atreides, choice:'draw', ownRedraws:0}, f.actions); + assert.equal(game.nexusCards!.cards!.hands.atreides, 'richese'); + assert.equal(game.leaderSkills!.assignments.length, 4); + game = advance(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), f.actions); + const offer = viewGame(game, 'atreides').nexusRichese; + assert.ok(offer && !offer.blocked); + const place = TERRITORIES.find(territory => territory.type === 'stronghold' && + !territory.sectors.includes(game.storm) && game.players.every(player => + Object.keys(player.forces).every(key => !key.startsWith(`${territory.id}:`)))); + assert.ok(place); + game = act(game, 'atreides', {type:'ship', territory:place.id, sector:place.sectors[0], + amount:1, elite:0, allyPayment:0, nexus:offer.event}, f.actions); + game = advance(game, state => state.nexusRicheseLast?.stage === 'shipped', f.actions); + assert.equal(game.nexusCards!.cards!.hands.atreides, null); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.equal(game.players.find(player => player.id === 'atreides')!.forces[`${place.id}:${place.sectors[0]}`], 1); + validateNexusCards(game.nexusCards!.cards!, game.players); + validateLeaderSkills(game.leaderSkills!, game.players); +});