diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c7b8c057..eebad4ba 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,35 @@ open mode gates. ## Current checkpoint and work +**Classic Nexus / Skills / Tech / Strongholds — bounded Development Verified:** +original fresh skill/Nexus setup retains selected unused Tech3..6 Basic/Advanced +and/or Strongholds2..6 Advanced; both require3..6. Original base33/all14/all12, +saved offers/hands/IDs and owner/deal timing remain. Seventeen setup programs +and four retained prerequisite rejections pass;192 affected cases, types/lint/ +build, original physical rescue/payment programs and four legal policies pass. +Seventeen genuine2..6 games finish11,526 accepted actions/no rejection, +303 JSON continuations and12 actual closing choices on unchanged source tree +`480664928339c7e7026564687099bb5ddda9a7391fdfea9dc054bc1d088ebd88`. +The original stranded6-seat Collection state also finishes807 accepted actions/ +no rejection after the fix. Original Bureaucrat invoices are no longer guarded +in this composition; Ghola/ordinary leader guidance obeys existing captured +training prohibition without removing force Ghola or deciding replacement skills. +Controlled original backed-up CLI **P4D8MY3V v10 / UVHFYQB8 v8 setup** +preserves real offers/hands/IDs after a scoped original14 shuffle. +Human390px **P4D8MY3V v22/Collection** selects trained Fenring6/dial10/ +support6/Shield with bank2/own4, saves three actual ordinary casualties +(one kept/two returned):15 reserve/3 Tanks/2 board, all five actual Sardaukar +reserved. Cleanup transfers original Guild Heighliners before inspection; +actual wallet10→6 then city2→8. **UVHFYQB8 v13/movement** spends actual +Fremen Nexus on three ordinary free returns including one actual starred piece: +17 reserves/3 Tanks, starred4 reserve/1 Tank, ordinary allowance3/elite1. +Axlotl1 accrues without wallet income, then original Revival end pays12→13 once. +Original posture/Cunning/support/rescue/cleanup/free-return/phase-end controls, +force inspectors, refresh and source guide have no root390px overflow. +Only dedicated QA rooms changed. Native overlays, HW, Banker/Mentat previews, +borrowed Smuggler arithmetic, pending rulings, public/save conversion and full +module/deployed acceptance stay separate. [Canonical scope and evidence](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026). + **Paired E1/E2 Nexus / Leader Skills — bounded Development Verified:** both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one family deck/all14/all12, Basic/Advanced2..6 through original `leader-skills --nexus-cards`, no overlays. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index d5734314..344f42ab 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -29,6 +29,24 @@ Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Classic Nexus with original Tech/Strongholds — 4 October 2026 + +Fresh classic `leader-skills --nexus-cards` now preserves selected unused +Tech in Basic/Advanced3..6 and/or Strongholds in Advanced2..6; both require3+. +This extends the Skills-only checkpoint below. Original base33/all14/all12, +saved hands/offers, native setup order and real closing draws remain. +First-Storm token allocation and end-Mentat held custody are not granted +by a fixture or by setup. Free returns accrue Axlotl until Revival end; +ordinary off-planet native shipment accrues Heighliners until movement end. + +Held Arrakeen subsidizes the payer, not the paid-support commitment used by +casualty/Suk rules. Cunning temporary roles remain ordinary physical counters, +rescue finishes once before cleanup and original mandatory winner token choice. +Held physical Shield and original loser retention retain their native windows. +Paired native Skills/Nexus still excludes Tech/Strongholds. Other unresolved +overlays/rulings and public/deployed gates remain guarded. +See [canonical combined scope](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026). + ## Paired E1/E2 Nexus composition — 4 October 2026 Fresh original `leader-skills --nexus-cards` additionally composes both diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 04c2ed4e..9467bc64 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,76 @@ 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, Skills and original Tech/Strongholds — 4 October 2026 + +The original fresh `leader-skills --nexus-cards` entry now preserves selected +unused original Tech Tokens in Basic/Advanced3..6 and/or Stronghold Cards +in Advanced2..6; both require at least three seats. Classic base33/all14/all12, +prediction, starting hands/offers, skill-before-Traitor order, real closing +draws, first-Storm Tech and actual end-Mentat held custody remain unchanged. +This extends the earlier classic Skills-only checkpoint below, not paired +native compositions. No public start, save conversion, Homeworlds, native +overlays, Banker/Mentat preview, companion or new pending ruling is opened. + +Independent printed effects retain their original settlement: Emperor extra +and Fremen ordinary free returns produce phase-end Axlotl, not immediate +wallet income. Native off-planet paid shipments produce Heighliners; Fremen +on-planet southern reinforcement does not become off-planet by paying a +borrowed tariff. Held Arrakeen reduces the actual payer debit, not the original +support commitment or Suk eligibility. Cunning roles remain physical ordinary +counters; normal/trained Suk rescue settles before cleanup and mandatory +winner Tech choice. Original Shield poison protection and Nexus Moritani +loser retention keep their independent original windows. + +Genuine games exposed two integration defects: the existing Bureaucrat payment +guard rejected ordinary invoices in this composition, and Ghola guidance +offered leader revivals forbidden by captured training, stranding a legal +Collection pass. Original once-per-phase Bureaucrat settlement is admitted; +Ghola and ordinary revival projections reuse the exact existing captured +training restriction. Force Ghola and Kwisatz alternatives are not removed, +and no replacement-skill ruling is inferred. + +```sh +node --import tsx tools/start-prototype.ts --profile leader-skills \ + --nexus-cards --db /private/local.sqlite --room ROOMCODE --version 9 \ + --out /private/new-classic-nexus-skills-modules-entry +node --import tsx tools/faction-games.ts --profile nexus-skills-stronghold-tech \ + --players all --rules advanced --out /private/new-classic-combined-games +``` + +`nexus-skills-tech` also covers Basic/Advanced3..6; `nexus-skills-stronghold` +covers Advanced2..6. Module selection must precede original fresh setup. +Focused runtime evidence is in `tests/nexus-skills-modules-payments-runtime.test.ts` +and `tests/nexus-skills-modules-battles-runtime.test.ts`; their conserved +source-controlled fixtures label physical relocation/order rather than claim +natural shuffle history. Full-module/public/deployed gates remain open. + +### Bounded combined classic evidence + +New48/affected192, types/lint/build, seventeen original setup programs/four +retained prerequisite rejections, physical payment/rescue programs and four +legal policies pass. Seventeen genuine2..6 games complete11,526 accepted actions, +no rejection,303 JSON continuations and12 actual closing choices; natural +Cunning combinations are not claimed. Three final-profile reports under +`/tmp/dune-nexus-skills-{tech,stronghold,both}-final-games-20261004/report.json` +capture unchanged tree `480664928339c7e7026564687099bb5ddda9a7391fdfea9dc054bc1d088ebd88`. +The preserved stranded6-seat state completes807 further accepted actions/ +no rejection at `/tmp/dune-nexus-skills-both-resume-20261004/report.json`. + +Original backed-up CLI **P4D8MY3V v10 / UVHFYQB8 v8 setup** continues actual +offers/hands/actor IDs after only a scoped original14 pre-deal shuffle. +Controlled human390px **P4D8MY3V v22/Collection** uses trained Fenring6, +dial10/support6/Shield: bank2/own4, six physical ordinary casualties save3 +(one kept/two returned),15 reserves/3 Tanks/2 board, actual starred5 reserve/ +zero Tanks. Original winner cleanup transfers Guild's Heighliners before +inspection; wallet10→6 support precedes separate city2→8. +**UVHFYQB8 v13/movement** uses actual Fremen Nexus for three free returns +including one starred:17 reserves/3 Tanks, starred4 reserve/1 Tank, normal +allowance3/elite1. Axlotl1 accrues without wallet income, then original Revival +end pays12→13 exactly once. Actual controls/inspectors/refresh/updated source +guide have no root390px overflow. Only dedicated QA rooms changed; controlled +physical relocation/order is labeled, not claimed as natural history. + ## Paired E1/E2 Nexus and Leader Skills composition — 4 October 2026 The original fresh `leader-skills --nexus-cards` entry additionally admits diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1ddbfd4c..1463f47d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -207,6 +207,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | 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) | | Paired E1/E2 Nexus / Tech / Strongholds | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics with one original family deck/all12 Nexus preserves Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills or other overlays. Original Cunning, typed Suboid/Cyborg loss and equal substitution, Face Dancer reward-before-replacement and draw-before-retirement, two physical No-Fields/one tariff, native industry at phase end and actual held/copy effects reuse original handlers. No mixed families, E3, new interpretations, public starts or played-game conversion. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026), [Richese composition](NEXUS_RICHESE_RULES.md#paired-techstronghold-composition--4-october-2026) | +| Classic Nexus / Skills / Tech / Strongholds | Fresh classic original skill/Nexus entry retains selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced. Original setup/closing deals/owner timing, distinct free returns and phase-end industry remain. Held subsidy changes actual payer debit, not committed support/Suk entitlement; physical rescue precedes card cleanup and mandatory winner token choice. Original once-per-phase Bureaucrat diversion composes player payments. Captured-training leader revival remains blocked; public Ghola/ordinary options now omit those illegal choices rather than strand legal AI passes. Paired native combined components, HW, other previews, public/save conversion and new rulings stay guarded. | [Combined source contract](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026) | | Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) | | Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 6d858f18..e4f7f93c 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { event: string; @@ -38,7 +38,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea return (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || - nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game); + nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game); } export function bureaucratUsed( used: readonly BureaucratPaymentUse[], diff --git a/game/engine.ts b/game/engine.ts index 3b9a9621..65a5ee85 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8118,11 +8118,14 @@ function actBureaucratPayment(g: Game,action:Action) { else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected); else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected; } +function leaderSkillRevivalBlocked(g: Game, owner: string): boolean { + return !!g.leaderSkills?.assignments.some( + (a) => a.owner === owner && leaderSkillController(g, a) !== owner, + ); +} function requireLeaderSkillRevival(g: Game, owner: string) { requireRule( - !g.leaderSkills?.assignments.some( - (a) => a.owner === owner && leaderSkillController(g, a) !== owner, - ), + !leaderSkillRevivalBlocked(g, owner), 'Reviving another leader while your Leader Skill is captured awaits the replacement-skill ruling.', ); } @@ -9439,7 +9442,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); - const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g)); + const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || + (leaderSkills && classicNexusLeaderSkillsProfile(g))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); @@ -18965,6 +18969,7 @@ function normalizeBattle(g: Game) { /** Ghola follows current control for ordinary leaders, but Duke's printed * revival right belongs exclusively to Ecaz, regardless of temporary custody. */ function gholaLeaders(g: Game, p: Player) { + if (leaderSkillRevivalBlocked(g, p.id)) return []; const native = controlledLeaders(g, p).filter( (l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy, ); @@ -31385,7 +31390,7 @@ export function viewGame(state: Game, id: string) { g.decision?.kind === 'revivalStop' ? homeworldRevivalKaramaBlock(g, g.decision.recipient) : null, - leaders: leaderRevivals.leaders, + leaders: leaderSkillRevivalBlocked(g, id) ? [] : leaderRevivals.leaders, kwisatz: leaderRevivals.kwisatz, cycleBlock: leaderRevivals.cycleBlock, dukeBlocked: leaderRevivals.dukeBlocked, diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 2eb15c75..a7dfaf47 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -33,11 +33,13 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, tokenCards = false): boolean { return !!game.nexusCards && typeof game.advanced === 'boolean' && !!game.players && game.players.length >= 2 && game.players.length <= 6 && !game.homeworlds && !game.discoveries && !game.discoveryEnabled && - !game.discoveryStash && !game.greatMaker && !game.strongholdCards && !game.techTokens && + !game.discoveryStash && !game.greatMaker && + (!game.techTokens || (tokenCards && game.players.length >= 3)) && + (!game.strongholdCards || (tokenCards && game.advanced === true)) && !game.ecazTreachery && !game.semutaPreview && !game.moritaniAssassinatePreview && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && !game.guildBetrayalPreview && !game.richeseBetrayalPreview && @@ -46,9 +48,9 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { !game.spiceBankerIncomePreview; } -/** Fresh classic Nexus/Skills only; other module and native compositions stay separate. */ +/** Fresh classic Nexus/Skills, with original Tech and Advanced Strongholds; native overlays stay separate. */ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length || !nexusLeaderSkillModulesSupported(game)) return false; + if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, true)) 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/nexus-emperor-secret-ally.ts b/game/nexus-emperor-secret-ally.ts index bf6b4e6a..7c571dcf 100644 --- a/game/nexus-emperor-secret-ally.ts +++ b/game/nexus-emperor-secret-ally.ts @@ -71,7 +71,8 @@ export function emperorNexusModeSupported(g: Game): boolean { !g.discoveries && !g.discoveryStash && !g.greatMaker && - ((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g)) + ((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g) || + (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g))) ); } /** A fixed three-counter grant preserves the separate Advanced elite cap. */ diff --git a/game/reference.ts b/game/reference.ts index b1d3a158..7c10136e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4367,7 +4367,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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 local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.', 'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.', - 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, without other overlays or public starts. Original starting hands and skill offers are continued, not redealt. Actual closing Nexus draws remain required. Extra/ordinary free-three returns, bank purchase and committed Traitor Deck exchanges preserve skill custody. Native Emperor Cunning composes ordinary skill posture, bonuses and Suk rescue without changing physical starred inventory or repeating support.', + 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats. Selected original Tech Tokens require three or more seats; Stronghold Cards require Advanced. Original starting hands/offers, 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. Other overlays and public starts remain guarded.', 'The same fresh skill/Nexus entry also supports paired Ixians + Tleilaxu OR CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats, without other overlays. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Face Dance follows original skill/card/reward settlement, kills the matching trainer and returns its skill once without another bounty; partial Tleilaxu Cunning preserves ordinary Mentat rights. Original Richese pair and CHOAM printed fuel use their distinct physical operations. Mixed families, E3, companion/mixed-plan, native free-return accounting and other pending rulings stay separate.', 'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.', 'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.', @@ -4419,7 +4419,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts'] }, ], }, { @@ -4606,7 +4606,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.', - 'Fresh classic Leader Skills may explicitly add Nexus alone through the local leader-skills --nexus-cards entry, Basic/Advanced two through six players. Starting hands can already be dealt before skill choice; saved offers remain authoritative. Original closing Nexus draws, free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from physical training. Native expansions, Tech/Strongholds/Homeworlds, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and other unresolved interactions remain separate.', + 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. Saved hands/offers remain authoritative; actual closing draws remain required. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, native factions with these combined components, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', 'The original paired Ixian/Tleilaxu or CHOAM/Richese native skill roster may also add Nexus alone through that explicit fresh local entry, with classics and one original family deck, Basic/Advanced two through six players. Full unsupported Suboids retain Cyborg support and skill posture. Suk physical rescue finishes before equal substitution of only actual Cyborg Tank losses; Face Dance then returns a killed trainer skill once without another bounty. Richese Cunning preserves its signed two-marker/one-invoice operation; ordinary skill marker proofs and companion exclusions remain separate. CHOAM Special fuel is discarded before an ordinary Planetologist move, without a second card effect or created combat Special.', 'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.', 'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.', diff --git a/tests/fixture-classic-nexus-skills-payments.ts b/tests/fixture-classic-nexus-skills-payments.ts index 8381a6f6..25da79b0 100644 --- a/tests/fixture-classic-nexus-skills-payments.ts +++ b/tests/fixture-classic-nexus-skills-payments.ts @@ -153,7 +153,7 @@ export function initializeClassicNexusSkillsPaymentsSetup(options: ClassicNexusS assert.ok(game.players.some(p => p.faction === faction)); if (game.status === 'setup') { assert.ok(classicNexusLeaderSkillsProfile(game)); - assert.equal(game.setupStage, 'leaderSkills'); + assert.ok(game.setupStage === 'prediction' || game.setupStage === 'leaderSkills'); assert.equal(game.turn, 1); assert.equal(game.phase, 0); assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length)); assert.equal(game.leaderSkills!.assignments.length, 0); diff --git a/tests/fixture-nexus-skills-modules-battles.ts b/tests/fixture-nexus-skills-modules-battles.ts new file mode 100644 index 00000000..30e280e9 --- /dev/null +++ b/tests/fixture-nexus-skills-modules-battles.ts @@ -0,0 +1,515 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, + joinGame, newPlayer, viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { territory } from '../game/board'; +import type { Card } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { createTechTokens, ownedTech } from '../game/tech-tokens'; +import { createStrongholdCards, type StrongholdId } from '../game/stronghold-cards'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type NexusSkillsModulesBattleStep = { actor: string; action: Action }; +export type NexusSkillsModulesBattleOptions = { + /** Original undealt authenticated CLI setup, or fresh classic lobby. Never redealt. */ + initial?: Game; + advanced?: boolean; + seatIds?: string[]; + tech?: boolean; + strongholds?: boolean; + kind?: Exclude; + ownerElites?: number; + /** Earn a second token through a real first-turn battle, never assign ownership. */ + opponentTokens?: 'one' | 'several'; + opponentNexus?: 'moritani'; + skill?: LeaderSkillId; + band?: 'normal' | 'skilled'; + ownerDial?: number; + support?: number; + opponentDial?: number; + opponentSupport?: number; + ownerWeapon?: Card['kind']; + ownerDefense?: Card['kind']; + opponentWeapon?: Card['kind']; + opponentDefense?: Card['kind']; + traitors?: 'owner' | 'opponent' | 'both'; + counter?: boolean; + secondBattle?: boolean; +}; +export type NexusSkillsModulesBattleFixture = { + initial: Game; + afterSetup: Game; + beforeFirstStorm: Game; + firstStormStep: NexusSkillsModulesBattleStep; + afterFirstStorm: Game; + warmupBeforeBattle?: Game; + warmupPlanActions?: NexusSkillsModulesBattleStep[]; + warmupAfterBattle?: Game; + beforeFirstMentat: Game; + firstMentatStep: NexusSkillsModulesBattleStep; + afterFirstMentat: Game; + beforeAlliance: Game; + allianceActions: NexusSkillsModulesBattleStep[]; + beforeDraw: Game; + drawAction: NexusSkillsModulesBattleStep; + afterDraw: Game; + beforeBattle: Game; + battleAction: NexusSkillsModulesBattleStep; + game: Game; + owner: string; + opponent: string; + observer: string; + partner: string; + trainer: string; + skill: LeaderSkillId; + band: 'normal' | 'skilled'; + kind: Exclude; + key: string; + secondKey?: string; + counter?: string; + planActions: NexusSkillsModulesBattleStep[]; + actions: NexusSkillsModulesBattleStep[]; + staging: string[]; +}; +export function nexusSkillsModulesBattlePlayer(game: Game, actor: string): Player { + const player = game.players.find(seat => seat.id === actor); + assert.ok(player, `Missing original classic seat ${actor}`); + return player; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Scoped original shuffle/action entropy; generic non-word requests retain native forwarding. */ +function entropy(operation: () => T, rolls: number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) { + for (let index = 0; index < array.length; index++) array[index] = rolls[cursor++] ?? 0xffffffff; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function offerRolls(selected: LeaderSkillId[]): number[] { + const working = LEADER_SKILL_CARDS.map(card => card.id); + const remainder = working.filter(skill => !selected.includes(skill)); + const desired = selected.flatMap(skill => [skill, remainder.shift()!]); + desired.push(...remainder); + const rolls: number[] = []; + for (let index = working.length - 1; index > 0; index--) { + const from = working.indexOf(desired[index]); + assert.ok(from >= 0 && from <= index); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1))); + [working[index], working[from]] = [working[from], working[index]]; + } + return rolls; +} +export function nextNexusSkillsModulesBattleStep(game: Game): NexusSkillsModulesBattleStep | null { + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase; + const actor = phase.eligible.find(id => !phase.done.includes(id)); + if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + if (!game.phaseOpening && !game.response) { + const decision = game.decision; + if (decision?.kind === 'leaderSkillVisibility') + return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } }; + if (decision?.kind === 'mentatQuestion') + return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; + if (decision?.kind === 'wormRide') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide') + return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } }; + if (decision?.kind === 'nexusChoamInspection') + return { actor: decision.player, action: { type: 'decision', event: decision.event, inspect: false } }; + if (decision?.kind === 'moritaniRetention' && decision.source === 'nexus') + return { actor: decision.player, action: { type: 'decision', event: decision.event, keep: null } }; + if (decision?.kind === 'techToken') + return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } }; + if (decision?.kind === 'faceDance') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision?.kind === 'sukRescue') { + const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept })) + .sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index; + return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } }; + } + } + return nextStrongholdFactionsNativeStep(game); +} +export function stepNexusSkillsModulesBattle(game: Game, next = nextNexusSkillsModulesBattleStep(game)): Game { + assert.ok(next, 'Original private plan requires a human action'); + return entropy(() => applyAction(game, next.actor, next.action)); +} +export function advanceNexusSkillsModulesBattle(game: Game, until: (state: Game) => boolean, + actions: NexusSkillsModulesBattleStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next, 'Human plan boundary before requested state'); + actions.push(structuredClone(next)); + game = stepNexusSkillsModulesBattle(game, next); + } + throw Error('Original Skills/Nexus continuation did not reach its boundary'); +} +/** Reorder undealt singleton Spice Cards only. Discards keep their original history. */ +function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void { + const remainder = [...game.spiceDeck]; + const front = kinds.map(kind => { + const index = remainder.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card); + assert.ok(index >= 0, `Original undealt ${kind} must still exist`); + return remainder.splice(index, 1)[0]; + }); + game.spiceDeck = [...front, ...remainder]; + staging.push(`Controlled conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`); +} +function reserveBoard(game: Game): void { + for (const seat of game.players) { + seat.reserves += Object.values(seat.forces).reduce((sum, count) => sum + count, 0); + seat.forces = {}; + if (seat.elites) { + seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0); + seat.elites.forces = {}; + } + if (seat.advisors) seat.advisors = {}; + } +} +function place(game: Game, actor: string, key: string, count: number): void { + const seat = nexusSkillsModulesBattlePlayer(game, actor); + assert.ok(seat.reserves - (seat.elites?.reserves ?? 0) >= count); + seat.reserves -= count; + seat.forces[key] = (seat.forces[key] ?? 0) + count; +} +function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[]): string { + const seat = nexusSkillsModulesBattlePlayer(game, actor); + let card = seat.hand.find(card => matches(card) && !used.includes(card.id)); + if (!card) { + const from = game.deck.findIndex(card => matches(card) && !used.includes(card.id)); + const donor = from < 0 ? game.players.find(seat => seat.hand.some(card => matches(card) && !used.includes(card.id))) : undefined; + assert.ok(from >= 0 || donor, 'Required original card must remain in deck/hand custody'); + assert.ok(seat.hand.length < handLimit(seat)); + card = from >= 0 ? game.deck.splice(from, 1)[0] + : donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0]; + seat.hand.push(card); + staging.push(`Conserved physical ${card.id}: ${donor?.id ?? 'original deck'} → ${actor} hand`); + } + used.push(card.id); + return card.id; +} +function traitor(game: Game, actor: string, identity: string, staging: string[]): void { + const seat = nexusSkillsModulesBattlePlayer(game, actor); + if (seat.traitors.includes(identity)) return; + const returned = seat.traitors[0]; + const from = game.traitorReserve?.indexOf(identity) ?? -1; + const donor = game.players.find(seat => seat.traitors.includes(identity)); + assert.ok(returned && (from >= 0 || donor)); + if (from >= 0) game.traitorReserve!.splice(from, 1, returned); + else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; + seat.traitors[0] = identity; + staging.push(`Conserved original traitor ${identity}: ${donor?.id ?? 'reserve'} exchanged for ${actor}'s ${returned}`); +} + +/** Genuine original all14/base33/all12 setup → Storm → END Mentat → worm → + * reciprocal alliance → closing Emperor draw → movement → human battle plans. + * Only labeled conserved physical sources are relocated; no wallets, phases, + * turn, skill effects, module owners, earned receipts or outcomes are assigned. */ +export function createNexusSkillsModulesBattleFixture(options: NexusSkillsModulesBattleOptions = {}): NexusSkillsModulesBattleFixture { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const band = options.band ?? 'skilled'; + const tech = options.tech ?? !!options.initial?.techTokens; + const strongholds = options.strongholds ?? !!options.initial?.strongholdCards; + const kind = options.kind ?? 'arrakeen'; + assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules'); + const actions: NexusSkillsModulesBattleStep[] = [], staging: string[] = []; + let game: Game; + if (options.initial) game = structuredClone(options.initial); + else { + const ids = options.seatIds ?? ['emperor', 'guild', 'atreides', 'fremen']; + assert.ok(ids.length >= 3 && ids.length <= 6 && new Set(ids).size === ids.length); + const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit']; + game = createGame('NEXUSSKILLSMODULESBATTLE', newPlayer(ids[0], factions[0], factions[0]), advanced, []); + for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index])); + } + assert.equal(game.advanced, advanced); + assert.deepEqual(game.expansions, []); + assert.ok(!game.homeworlds); + if (game.status === 'lobby') { + if (tech && !game.techTokens) game = stepNexusSkillsModulesBattle(game, { actor: game.host, action: { type: 'techTokens', enabled: true } }); + if (strongholds && !game.strongholdCards) game = stepNexusSkillsModulesBattle(game, { actor: game.host, action: { type: 'strongholdCards', enabled: true } }); + } + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + if (tech) assert.deepEqual(game.techTokens, createTechTokens(game.status === 'setup' ? game.players : [])); + if (strongholds) assert.deepEqual(game.strongholdCards, createStrongholdCards()); + assert.ok(game.players.length >= 3 && game.players.length <= 6); + const owner = game.players.find(seat => seat.faction === 'emperor')?.id; + const opponent = game.players.find(seat => seat.faction === 'guild')?.id; + const observer = game.players.find(seat => seat.faction === 'atreides')?.id; + assert.ok(owner && opponent && observer); + const partner = game.players.find(seat => ![owner, opponent, observer].includes(seat.id))?.id ?? opponent; + const desiredSkill = options.skill ?? 'warmaster'; + const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] + .filter(skill => skill !== desiredSkill) as LeaderSkillId[]; + const selected = game.players.map(seat => seat.id === owner ? desiredSkill : passive.shift()!); + if (game.status === 'lobby') { + assert.ok(!game.leaderSkills, 'Never replace existing original skill custody'); + game.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + for (const seat of game.players) if (!seat.ready) game = stepNexusSkillsModulesBattle(game, { actor: seat.id, action: { type: 'ready' } }); + game = entropy(() => initializeLeaderSkillsGameForAudit(game), offerRolls(selected)); + } + assert.equal(game.status, 'setup', 'Only original fresh undealt setup/lobby is accepted'); + assert.equal(game.turn, 1); assert.equal(game.phase, 0); + assert.ok(game.leaderSkills && game.nexusCards?.cards); + assert.equal(game.leaderSkills.assignments.length, 0); + const initial = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 240; count++) { + if (game.setupStage === 'leaderSkills') { + const actor: string = Object.keys(game.leaderSkills!.offers)[0]; + const offer: NonNullable['offers'][string] = game.leaderSkills!.offers[actor]; + const view = viewGame(game, actor).leaderSkills!; + const requested: LeaderSkillId = selected[game.players.findIndex(seat => seat.id === actor)]; + const legal: LeaderSkillId[] = offer.cards.filter(skill => !view.unavailableSkills?.[skill]); + const skill: LeaderSkillId | undefined = legal.includes(requested) ? requested : legal[0]; + assert.ok(skill, 'Existing saved offer has no legal original skill; it cannot be redealt'); + if (actor === owner && options.skill) assert.equal(skill, options.skill, 'Requested skill must be in the original saved offer'); + const eligible = new Set(view.eligibleLeaders.map(leader => leader.id)); + const leader = nexusSkillsModulesBattlePlayer(game, actor).leaders.filter(leader => eligible.has(leader.id)) + .sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); + const next: NexusSkillsModulesBattleStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + actions.push(next); game = stepNexusSkillsModulesBattle(game, next); + } else { + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next); + actions.push(next); game = stepNexusSkillsModulesBattle(game, next); + } + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const assignment = game.leaderSkills!.assignments.find(skill => skill.owner === owner)!; + const skill = assignment.skill, trainer = assignment.leader; + let beforeFirstStorm!: Game, firstStormStep!: NexusSkillsModulesBattleStep, afterFirstStorm!: Game; + while (!(game.phase === 1 && clean(game))) { + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next); + const before = structuredClone(game); + actions.push(next); game = stepNexusSkillsModulesBattle(game, next); + if (tech ? JSON.stringify(before.techTokens) !== JSON.stringify(game.techTokens) : before.phase === 0 && game.phase === 1) { + beforeFirstStorm = before; firstStormStep = next; afterFirstStorm = structuredClone(game); + } + } + assert.ok(beforeFirstStorm && firstStormStep && afterFirstStorm); + orderSpice(game, ['land', 'land'], staging); + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 5 && clean(state), actions); + reserveBoard(game); + staging.push('After actual setup, conserve all board counters through their own reserves before first movement completion'); + let warmupBeforeBattle: Game | undefined, warmupPlanActions: NexusSkillsModulesBattleStep[] | undefined, + warmupAfterBattle: Game | undefined; + if (options.opponentTokens === 'several') { + assert.ok(tech, 'Multiple rewards require selected original Tech Tokens'); + assert.equal(ownedTech(game.techTokens, opponent).length, 1); + const donor = game.players.find(seat => seat.id !== opponent && ownedTech(game.techTokens, seat.id).length === 1); + assert.ok(donor); + for (const actor of [opponent, donor.id]) place(game, actor, 'arrakeen:10', 5); + staging.push(`Conserved five ordinary counters each at Arrakeen for ${opponent}'s real first-turn token-winning battle against ${donor.id}`); + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 6 && clean(state), actions); + const actor = game.active!; assert.ok([opponent, donor.id].includes(actor)); + warmupBeforeBattle = structuredClone(game); + const choose: NexusSkillsModulesBattleStep = { actor, action: { type: 'chooseBattle', territory: 'arrakeen', + target: actor === opponent ? donor.id : opponent } }; + actions.push(choose); game = stepNexusSkillsModulesBattle(game, choose); + game = advanceNexusSkillsModulesBattle(game, state => !nextNexusSkillsModulesBattleStep(state), actions); + const leader = (id: string, strongest: boolean) => { + const candidates = nexusSkillsModulesBattlePlayer(game, id).leaders.filter(disc => !disc.dead && !disc.usedAt && + !game.leaderSkills!.assignments.some(assignment => assignment.leader === disc.id)); + candidates.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength); + assert.ok(candidates[0]); return candidates[0].id; + }; + warmupPlanActions = [ + { actor: opponent, action: { type: 'battlePlan', leader: leader(opponent, true), dial: 4, + support: advanced ? 4 : 0, weapon: null, defense: null } }, + { actor: donor.id, action: { type: 'battlePlan', leader: leader(donor.id, false), dial: 0, + support: 0, weapon: null, defense: null } }, + ]; + for (const plan of warmupPlanActions) { + actions.push(plan); game = stepNexusSkillsModulesBattle(game, plan); + while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') { + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next); + actions.push(next); game = stepNexusSkillsModulesBattle(game, next); + } + } + game = finishNexusSkillsModulesBattle(game); + assert.equal(game.lastBattleContext!.winner, opponent); + assert.equal(ownedTech(game.techTokens, opponent).length, 2); + warmupAfterBattle = structuredClone(game); + } + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 8 && clean(state), actions); + if (strongholds) { + reserveBoard(game); + place(game, owner, `${kind}:${territory(kind).sectors[0]}`, 1); + staging.push(`Conserved one ordinary claimant at ${kind} before actual END Mentat; no Stronghold owner grant`); + } + let beforeFirstMentat!: Game, firstMentatStep!: NexusSkillsModulesBattleStep; + while (game.turn === 1) { + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next); + const before = structuredClone(game); + actions.push(next); game = stepNexusSkillsModulesBattle(game, next); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + const afterFirstMentat = structuredClone(game); + if (strongholds) assert.equal(game.strongholdCards!.owners[kind], owner); + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 1 && clean(state), actions); + orderSpice(game, ['worm', 'land', 'land'], staging); + game = advanceNexusSkillsModulesBattle(game, state => state.nexus === true && !state.spiceWindow && clean(state), actions); + const beforeAlliance = structuredClone(game); + const allianceActions: NexusSkillsModulesBattleStep[] = [ + { actor: observer, action: { type: 'alliance', target: partner } }, + { actor: partner, action: { type: 'alliance', target: observer } }, + ]; + for (const next of allianceActions) { actions.push(next); game = stepNexusSkillsModulesBattle(game, next); } + game = advanceNexusSkillsModulesBattle(game, state => state.nexusCards?.phase?.stage === 'drawing', actions); + const cards = game.nexusCards!.cards!; + const from = cards.deck.indexOf('emperor'); assert.ok(from >= 0); + cards.deck.unshift(cards.deck.splice(from, 1)[0]); + staging.push('Conserved undealt Emperor Nexus singleton ordered first after genuine alliance; only actual closing draw changes hand custody'); + const beforeDraw = structuredClone(game); + const drawAction: NexusSkillsModulesBattleStep = { actor: owner, action: { + type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0, + } }; + actions.push(drawAction); game = stepNexusSkillsModulesBattle(game, drawAction); + const afterDraw = structuredClone(game); + assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor'); + if (options.opponentNexus) { + const cards = game.nexusCards!.cards!; + assert.ok(game.nexusCards!.phase?.eligible.includes(opponent), 'Requested original opponent draw requires an unallied closing recipient'); + const from = cards.deck.indexOf(options.opponentNexus); assert.ok(from >= 0); + cards.deck.unshift(cards.deck.splice(from, 1)[0]); + const draw: NexusSkillsModulesBattleStep = { actor: opponent, action: { type: 'nexusCardChoice', turn: game.turn, + card: cards.hands[opponent], choice: 'draw', ownRedraws: 0 } }; + staging.push('Conserved undealt Moritani singleton ordered before Guild’s actual unallied closing draw'); + actions.push(draw); game = stepNexusSkillsModulesBattle(game, draw); + assert.equal(game.nexusCards!.cards!.hands[opponent], 'moritani'); + } + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 5 && clean(state), actions); + reserveBoard(game); + const key = `${kind}:${territory(kind).sectors[0]}`; + assert.ok(!territory(kind).sectors.includes(game.storm)); + for (const actor of [owner, opponent]) place(game, actor, key, 7); + const eliteCount = options.ownerElites ?? 0; + if (eliteCount) { + const seat = nexusSkillsModulesBattlePlayer(game, owner); + assert.ok(seat.elites && Number.isInteger(eliteCount) && eliteCount > 0 && seat.elites.reserves >= eliteCount); + seat.reserves -= eliteCount; seat.elites.reserves -= eliteCount; + seat.forces[key] += eliteCount; seat.elites.forces[key] = eliteCount; + staging.push(`Conserved ${eliteCount} actual starred Sardaukar from own reserves to ${key}; distinct from the five temporary ordinary roles`); + } + const secondKey = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined; + if (secondKey) { + assert.ok(!territory('carthag').sectors.includes(game.storm)); + for (const actor of [owner, opponent]) place(game, actor, secondKey, 1); + } + staging.push(`Before actual turn-two movement completion, conserved seven ordinary counters per combatant at ${kind}; any starred counters are separately labeled`); + if (secondKey) staging.push('Conserved one further ordinary counter per combatant at Carthag for genuine same-turn expiry battle'); + const used: string[] = []; + const card = (actor: string, kind: Card['kind'] | undefined) => kind ? hold(game, actor, card => card.kind === kind, staging, used) : null; + const ownerWeapon = card(owner, options.ownerWeapon ?? (skill === 'warmaster' ? 'worthless' : undefined)); + const ownerDefense = card(owner, options.ownerDefense); + const opponentWeapon = card(opponent, options.opponentWeapon); + const opponentDefense = card(opponent, options.opponentDefense); + const counter = options.counter ? hold(game, opponent, card => card.effect === 'karama', staging, used) : undefined; + const untrained = (actor: string) => { + const leaders = nexusSkillsModulesBattlePlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt && + !game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)); + const leader = leaders.find(leader => leader.strength === 3) ?? leaders[0]; assert.ok(leader); + return leader.id; + }; + const ownerLeader = band === 'skilled' ? trainer : untrained(owner); + const opponentLeader = untrained(opponent); + if (options.traitors === 'owner' || options.traitors === 'both') traitor(game, owner, opponentLeader, staging); + if (options.traitors === 'opponent' || options.traitors === 'both') traitor(game, opponent, ownerLeader, staging); + game = advanceNexusSkillsModulesBattle(game, state => state.phase === 6 && clean(state), actions); + const beforeBattle = structuredClone(game); + const actor = game.active!; assert.ok([owner, opponent].includes(actor)); + const battleAction: NexusSkillsModulesBattleStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner } }; + actions.push(battleAction); game = stepNexusSkillsModulesBattle(game, battleAction); + const planActions: NexusSkillsModulesBattleStep[] = [ + { actor: owner, action: { type: 'battlePlan', leader: ownerLeader, dial: options.ownerDial ?? 6, + support: advanced ? options.support ?? 3 : 0, weapon: ownerWeapon, defense: ownerDefense } }, + { actor: opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: options.opponentDial ?? 0, + support: advanced ? options.opponentSupport ?? 0 : 0, weapon: opponentWeapon, defense: opponentDefense } }, + ]; + return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeAlliance, allianceActions, + beforeDraw, drawAction, afterDraw, beforeBattle, battleAction, game, owner, opponent, observer, partner, + beforeFirstStorm, firstStormStep, afterFirstStorm, warmupBeforeBattle, warmupPlanActions, warmupAfterBattle, + trainer, skill, band, kind, key, secondKey, counter, planActions, actions, staging }; +} +export function prepareNexusSkillsModulesBattle(fixture: NexusSkillsModulesBattleFixture, state = fixture.game): Game { + let game = structuredClone(state); + for (let count = 0; count < 160; count++) { + const next = nextNexusSkillsModulesBattleStep(game); + if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; } + if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled'; + game = stepNexusSkillsModulesBattle(game, next); + } + throw Error('Original skill posture/private plan boundary did not open'); +} +export function beginNexusSkillsModulesCunning(fixture: NexusSkillsModulesBattleFixture, state = prepareNexusSkillsModulesBattle(fixture)): Game { + const offer = viewGame(state, fixture.owner).nexusSardaukar?.offer; + assert.ok(offer); assert.equal(offer.blocked, null); + return stepNexusSkillsModulesBattle(state, { actor: fixture.owner, action: { type: 'nexusSardaukar', event: offer.event } }); +} +export function revealNexusSkillsModulesBattle(fixture: NexusSkillsModulesBattleFixture, + options: { state?: Game; cunning?: boolean } = {}): Game { + let game = prepareNexusSkillsModulesBattle(fixture, options.state ?? fixture.game); + if (options.cunning) game = advanceNexusSkillsModulesBattle(beginNexusSkillsModulesCunning(fixture, game), clean); + for (const plan of fixture.planActions) { + game = stepNexusSkillsModulesBattle(game, plan); + while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepNexusSkillsModulesBattle(game); + } + assert.ok(game.battle?.revealed); + return game; +} +export function finishNexusSkillsModulesBattle(state: Game, + options: { stop?: 'losses' | 'rescue' | 'cleanup' | 'tech' | 'retention' | 'inspection'; callers?: string[] } = {}): Game { + let game = structuredClone(state); + for (let count = 0; count < 240; count++) { + if (options.stop === 'losses' && game.decision?.kind === 'battleLosses') return game; + if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game; + if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game; + if (options.stop === 'tech' && game.decision?.kind === 'techToken') return game; + if (options.stop === 'retention' && game.decision?.kind === 'moritaniRetention') return game; + if (options.stop === 'inspection' && game.decision?.kind === 'nexusChoamInspection') return game; + if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game; + const next = nextNexusSkillsModulesBattleStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false; + game = stepNexusSkillsModulesBattle(game, next); + } + throw Error('Original Skills/Nexus battle aftermath did not finish'); +} +/** Quote the actual native source, including physical temporary roles and skill posture. */ +export function quoteNexusSkillsModulesBattle(game: Game) { + const battle = game.battle; assert.ok(battle?.revealed); + const side = (actor: string): ResolutionCombatant => { + const seat = nexusSkillsModulesBattlePlayer(game, actor), view = viewGame(game, actor).battle!; + assert.ok(view.ownForces); + return { id: actor, faction: seat.faction, ally: seat.ally, spice: seat.spice, hand: seat.hand, + plan: battle.plans[actor], leader: seat.leaders.find(leader => leader.id === battle.plans[actor].leader), + forces: view.ownForces, stronghold: view.strongholdEffects[actor], + leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor) + .map(assignment => ({ skill: assignment.skill, leader: assignment.leader, + faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) }; + }; + return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory, + aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender), + voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, + beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : nexusSkillsModulesBattlePlayer(game, actor).ally!, + called: battle.traitorCalls[actor] ?? false, traitors: nexusSkillsModulesBattlePlayer(game, actor).traitors })), + participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true }); +} diff --git a/tests/fixture-nexus-skills-modules-payments.ts b/tests/fixture-nexus-skills-modules-payments.ts new file mode 100644 index 00000000..4fb85fdf --- /dev/null +++ b/tests/fixture-nexus-skills-modules-payments.ts @@ -0,0 +1,308 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, + type Action, type Game } from '../game/engine'; +import { distance, TERRITORIES, territory } from '../game/board'; +import { botActions } from '../game/bots'; +import type { FactionId } from '../game/catalog'; +import type { Card } from '../game/cards'; +import type { LeaderSkillId } from '../game/leader-skill-cards'; +import { STRONGHOLD_CARDS } from '../game/stronghold-cards'; +import { ownedTech, TECH_TOKENS, type TechId } from '../game/tech-tokens'; +import { classicNexusSkillsPaymentsInventory, classicNexusSkillsPaymentsPlayer, + initializeClassicNexusSkillsPaymentsSetup, nextClassicNexusSkillsPaymentsNativeStep, + type ClassicNexusSkillsPaymentStep } from './fixture-classic-nexus-skills-payments'; + +export type NexusSkillsModulesPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'native-shipment'; +export type NexusSkillsModulesPaymentStep = ClassicNexusSkillsPaymentStep; +export type NexusSkillsModulesPaymentsOptions = { + /** Original lobby or turn-one, phase-zero unassigned Skills setup; never redealt. */ + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + seatIds?: string[]; + ownerFaction?: FactionId; + kind?: NexusSkillsModulesPaymentKind; + skill?: LeaderSkillId; + nativeGuild?: boolean; + tanks?: number; + eliteTanks?: number; + amount?: number; +}; +export type NexusSkillsModulesPaymentsFixture = { + initial: Game; + afterSetup: Game; + beforeFirstMentat: Game; + firstMentatStep: NexusSkillsModulesPaymentStep; + afterFirstMentat: Game; + beforeClosingDraw: Game; + closingDraw: NexusSkillsModulesPaymentStep; + afterClosingDraw: Game; + beforePayment: Game; + game: Game; + actor: string; + opponent: string; + kind: NexusSkillsModulesPaymentKind; + paymentAction: Action; + amount: number; + cost: number; + token: TechId; + tokenOwner: string | null; + tokenCount: number; + destination?: string; + actions: NexusSkillsModulesPaymentStep[]; + staging: string[]; +}; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance; +export const nexusSkillsModulesPaymentsPlayer = classicNexusSkillsPaymentsPlayer; + +/** Scalar rolls stabilize physical deck/Storm order; UUID byte arrays stay native. */ +function originalRandom(run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = 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'); + } +} +export function nextNexusSkillsModulesPaymentsNativeStep(game: Game): NexusSkillsModulesPaymentStep | null { + if (clean(game) && game.status === 'finished') return null; + if (game.truthtrance && !game.phaseOpening && !game.response) { + for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(p => p.id === player.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) return { actor: player.id, action }; + } + throw Error('The actual Truthtrance must expose its native priority/question/answer action'); + } + if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance') + return { actor: game.decision.player, action: { type: 'decision', reveal: false } }; + return nextClassicNexusSkillsPaymentsNativeStep(game); +} +export function advanceNexusSkillsModulesPaymentsStep(state: Game, actions?: NexusSkillsModulesPaymentStep[]): Game { + const next = nextNexusSkillsModulesPaymentsNativeStep(state); + assert.ok(next, 'The native continuation stopped for a human battle plan'); + actions?.push(structuredClone(next)); + return applyAction(state, next.actor, next.action); +} +export function advanceNexusSkillsModulesPayments(state: Game, predicate: (game: Game) => boolean, + actions?: NexusSkillsModulesPaymentStep[]): Game { + let game = state; + for (let n = 0; n < 1800; n++) { + if (predicate(game)) return game; + assert.notEqual(game.status, 'finished'); + game = advanceNexusSkillsModulesPaymentsStep(game, actions); + } + throw Error('Original Nexus/Skills/module continuation did not reach its boundary'); +} +export function advanceNexusSkillsModulesPaymentsToPhase(state: Game, phase: number, + actions?: NexusSkillsModulesPaymentStep[]): Game { + const turn = state.turn; + return advanceNexusSkillsModulesPayments(state, game => { + assert.equal(game.turn, turn); + return game.phase >= phase && clean(game); + }, actions); +} + +export function initializeNexusSkillsModulesPaymentsSetup(options: NexusSkillsModulesPaymentsOptions = {}): Game { + const kind = options.kind ?? 'emperor-revival'; + const ownerFaction = options.ownerFaction ?? 'atreides'; + let game = options.initial ? structuredClone(options.initial) + : createGame('NEXUSSKILLSMODULEPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', ownerFaction, ownerFaction), + options.advanced ?? !!options.strongholds); + if (!options.initial) { + const forbidden = kind === 'fremen-revival' ? 'fremen' : 'emperor'; + const count = options.seatIds?.length ?? 3; + assert.ok(count >= 3 && count <= 6); + const factions: FactionId[] = options.nativeGuild + ? ['guild', 'harkonnen', 'atreides', 'beneGesserit', 'fremen', 'emperor'] + : ['harkonnen', 'atreides', 'beneGesserit', 'guild', 'fremen', 'emperor']; + for (const faction of factions) if (game.players.length < count && faction !== forbidden && faction !== ownerFaction) + joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction)); + assert.equal(game.players.length, count); + } + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + const tech = options.tech ?? (options.initial ? !!game.techTokens : true); + const strongholds = options.strongholds ?? !!game.strongholdCards; + assert.ok(tech || strongholds, 'Select original Tech and/or Advanced Strongholds'); + if (game.status === 'lobby') { + if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + } + assert.equal(!!game.techTokens, tech); + assert.equal(!!game.strongholdCards, strongholds); + if (options.nativeGuild) assert.ok(game.players.some(player => player.faction === 'guild')); + return initializeClassicNexusSkillsPaymentsSetup({ initial: game, ownerFaction, + kind: kind === 'native-shipment' ? 'emperor-revival' : kind, advanced: options.advanced, skill: options.skill }); +} +function reserveBoard(game: Game, staging: string[]): void { + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); + player.elites.forces = {}; + } + if (player.advisors) player.advisors = {}; + } + staging.push('Conserved every original board counter into its own reserves; no wallet, phase, module owner or earned receipt assigned.'); +} +function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void { + const front: Game['spiceDeck'] = []; + for (const kind of kinds) { + const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card); + assert.ok(index >= 0, 'Only actual still-undealt physical Spice Cards may be ordered'); + front.push(game.spiceDeck.splice(index, 1)[0]); + } + game.spiceDeck.unshift(...front); + staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; discard custody unchanged.`); +} +/** Relocates an actual original card, retaining its identity and the 33-card inventory. */ +export function holdNexusSkillsModulesPaymentsCard(game: Game, actor: string, kind: Card['kind'] | 'karama' | 'truthtrance', staging: string[]): Card { + const player = nexusSkillsModulesPaymentsPlayer(game, actor); + const matches = (card: Card) => card.kind === kind || card.effect === kind; + const held = player.hand.find(matches); + if (held) return held; + assert.ok(player.hand.length < handLimit(player)); + const index = game.deck.findIndex(matches); + const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined; + assert.ok(index >= 0 || donor, `An original ${kind} card must remain in real deck/hand custody`); + const card = index >= 0 ? game.deck.splice(index, 1)[0] + : donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0]; + player.hand.push(card); + staging.push(`Conserved original ${card.id}: ${donor?.id ?? 'undealt deck'} custody to ${actor}'s hand.`); + return card; +} + +/** Full original setup, first Storm assignment, first END Mentat claim and real + * unallied closing draw precede the payment. Only physical positioning/order is staged. */ +export function createNexusSkillsModulesPaymentsFixture(options: NexusSkillsModulesPaymentsOptions = {}): NexusSkillsModulesPaymentsFixture { + const initial = initializeNexusSkillsModulesPaymentsSetup(options); + return originalRandom(() => { + const kind = options.kind ?? 'emperor-revival'; + const actor = initial.players.find(p => p.faction === (options.ownerFaction ?? 'atreides'))!.id; + const opponent = initial.players.find(p => p.id !== actor)!.id; + const actions: NexusSkillsModulesPaymentStep[] = [], staging: string[] = []; + let game = initial; + while (game.status === 'setup') { + let next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next); + if (game.setupStage === 'leaderSkills' && next.actor === actor && options.skill) { + const view = viewGame(game, actor).leaderSkills!; + assert.ok(view.offer!.cards.includes(options.skill), 'Explicit skill must exist in the saved original offer'); + assert.ok(!view.unavailableSkills?.[options.skill]); + next = { actor, action: { ...next.action, skill: options.skill } }; + } + actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); + } + const afterSetup = structuredClone(game); + reserveBoard(game, staging); + if (game.strongholdCards) { + const player = nexusSkillsModulesPaymentsPlayer(game, actor); + assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 1); + player.reserves--; player.forces['arrakeen:10'] = 1; + staging.push(`Conserved one ordinary ${actor} reserve counter to Arrakeen before the real first END Mentat claim.`); + } + game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions); + orderUndealtSpice(game, ['land', 'land'], staging); + game = advanceNexusSkillsModulesPaymentsToPhase(game, 8, actions); + let beforeFirstMentat: Game | undefined, firstMentatStep: NexusSkillsModulesPaymentStep | undefined; + while (game.turn === 1) { + const before = structuredClone(game), next = nextNexusSkillsModulesPaymentsNativeStep(game); assert.ok(next); + game = advanceNexusSkillsModulesPaymentsStep(game, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = structuredClone(game); + if (game.strongholdCards) assert.equal(game.strongholdCards.owners.arrakeen, actor); + game = advanceNexusSkillsModulesPaymentsToPhase(game, 1, actions); + reserveBoard(game, staging); + orderUndealtSpice(game, ['worm', 'land', 'land'], staging); + let allied = false; + for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) { + if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) { + const others = game.players.filter(p => p.id !== actor); assert.ok(others.length >= 2); + for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) { + const action: Action = { type: 'alliance', target: to }; + actions.push({ actor: from, action }); game = applyAction(game, from, action); + } + allied = true; + } + game = advanceNexusSkillsModulesPaymentsStep(game, actions); + } + assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing'); + const card = kind === 'fremen-revival' ? 'fremen' : 'emperor'; + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + staging.push(`Ordered original undealt ${card} Nexus singleton before a genuine closing draw; no card grant.`); + const beforeClosingDraw = structuredClone(game); + const closingDraw: NexusSkillsModulesPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }; + actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action); + assert.equal(game.nexusCards!.cards!.hands[actor], card); + const afterClosingDraw = structuredClone(game); + game = advanceNexusSkillsModulesPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions); + game = advanceNexusSkillsModulesPaymentsToPhase(game, kind === 'native-shipment' ? 5 : kind === 'emperor-purchase' ? 3 : 4, actions); + let paymentAction: Action, destination: string | undefined, cost = 0; + const amount = kind === 'native-shipment' ? options.amount ?? 3 : kind === 'emperor-purchase' ? 1 : 3; + if (kind === 'emperor-purchase') { + for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) { + if (game.auction?.active === actor && !game.auction.bid) { + const action: Action = { type: 'bid', amount: 2 }; + actions.push({ actor, action }); game = applyAction(game, actor, action); + } else game = advanceNexusSkillsModulesPaymentsStep(game, actions); + } + assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor); + assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor); + cost = game.auction!.bid; + paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event }; + } else if (kind === 'native-shipment') { + game = advanceNexusSkillsModulesPayments(game, state => state.active === actor && clean(state), actions); + const player = nexusSkillsModulesPaymentsPlayer(game, actor); + const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) + && game.players.every(p => Object.keys(p.forces).every(key => !key.startsWith(`${t.id}:`))) + && (player.faction !== 'fremen' || territory('the_great_flat').sectors.some(from => + t.sectors.some(to => distance(`the_great_flat:${from}`, `${t.id}:${to}`) <= 2)))); + assert.ok(target, 'An empty native-legal stronghold destination must exist'); + destination = `${target.id}:${target.sectors[0]}`; + cost = player.faction === 'fremen' ? 0 : player.faction === 'guild' ? Math.ceil(amount / 2) : amount; + assert.ok(player.spice >= cost); + paymentAction = { type: 'ship', territory: target.id, sector: target.sectors[0], amount, smuggler: false }; + } else { + const player = nexusSkillsModulesPaymentsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0; + assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks); + assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0)); + player.reserves -= tanks; player.tanks += tanks; + if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; } + staging.push(`Conserved ${actor}: ${tanks} original reserve counters to Tanks, including ${elite} elites; no revival receipt staged.`); + const view = viewGame(game, actor); + paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive', + event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 }; + } + nexusSkillsModulesPaymentsInventory(game); + const token: TechId = kind === 'native-shipment' ? 'heighliners' : kind === 'emperor-purchase' ? 'production' : 'axlotl'; + const tokenOwner = game.techTokens?.[token].owner ?? null; + return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw, closingDraw, + afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind, paymentAction, amount, cost, + token, tokenOwner, tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, destination, actions, staging }; + }); +} +/** One original action only: exposes any actual interception/payment response. */ +export function payNexusSkillsModulesPaymentsFixture(fixture: NexusSkillsModulesPaymentsFixture, state = fixture.game): Game { + return applyAction(state, fixture.actor, fixture.paymentAction); +} +export function finishNexusSkillsModulesPaymentsFixture(_fixture: NexusSkillsModulesPaymentsFixture, state: Game, + actions?: NexusSkillsModulesPaymentStep[]): Game { + return advanceNexusSkillsModulesPayments(state, game => clean(game) && !game.pendingRevival && !game.pendingShipment, actions); +} +export function nexusSkillsModulesPaymentsInventory(game: Game): void { + classicNexusSkillsPaymentsInventory(game); + if (game.techTokens) assert.deepEqual(Object.keys(game.techTokens).sort(), TECH_TOKENS.map(t => t.id).sort()); + if (game.strongholdCards) assert.deepEqual(Object.keys(game.strongholdCards.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort()); +} diff --git a/tests/leader-skills-capture.test.ts b/tests/leader-skills-capture.test.ts index af5d1d4f..fd2ac037 100644 --- a/tests/leader-skills-capture.test.ts +++ b/tests/leader-skills-capture.test.ts @@ -13,6 +13,7 @@ import { } from '../game/engine'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { placeFixtureHand } from './fixture-hand'; +import { botActions } from '../game/bots'; const SEATS = [ ['e', 'Emperor seat', 'emperor'], @@ -481,3 +482,29 @@ void test('capturing an unskilled leader preserves the existing private identity assert.ok(g.log.every((entry) => !entry.text.includes('Captain Aramsham'))); assertSkillCustody(g); }); + +void test('captured training excludes blocked Ghola and ordinary leader revivals while preserving a legal Collection pass', () => { + const kept = applyAction(acceptCapture(captureOffer()), 'h', { type: 'decision', mode: 'keep' }); + kept.phase = 7; + kept.decision = null; + kept.response = null; + kept.phaseOpening = null; + kept.ready = kept.players.filter(player => player.id !== 'e').map(player => player.id); + const emperor = kept.players.find(player => player.id === 'e')!; + const ghola = baseDeck().find(card => card.effect === 'ghola')!; + placeFixtureHand(kept, kept.players.indexOf(emperor), [ghola]); + emperor.bot = 'Medium'; + const before = structuredClone(kept); + const view = viewGame(kept, 'e'); + assert.deepEqual(view.ghola.leaders, []); + assert.deepEqual(view.revival.leaders, []); + assert.equal(view.ghola.available, true); + assert.equal(view.ghola.maxForces, 5, 'Captured training does not prohibit physical force Ghola'); + const action = botActions(view).find(action => action.type === 'ready'); + assert.ok(action); + const done = applyAction(kept, 'e', action); + assert.equal(done.phase, 8); + assert.equal(done.players.find(player => player.id === 'e')!.hand.some(card => card.id === ghola.id), true); + assert.deepEqual(kept, before); + assertSkillCustody(done); +}); diff --git a/tests/nexus-skills-modules-battles-runtime.test.ts b/tests/nexus-skills-modules-battles-runtime.test.ts new file mode 100644 index 00000000..e8529393 --- /dev/null +++ b/tests/nexus-skills-modules-battles-runtime.test.ts @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { casualtyOptions, maxCombatDial } from '../game/combat'; +import { TECH_TOKENS, ownedTech } from '../game/tech-tokens'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { nexusInventory } from './fixture-nexus-cards'; +import { + advanceNexusSkillsModulesBattle, beginNexusSkillsModulesCunning, + createNexusSkillsModulesBattleFixture, finishNexusSkillsModulesBattle, + nexusSkillsModulesBattlePlayer as player, prepareNexusSkillsModulesBattle, + quoteNexusSkillsModulesBattle, revealNexusSkillsModulesBattle, stepNexusSkillsModulesBattle, + type NexusSkillsModulesBattleFixture, +} from './fixture-nexus-skills-modules-battles'; + +function custody(game: Game): void { + nexusInventory(game); + const skills = game.leaderSkills!; + assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill), + ...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort()); + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.equal(cards.length, 33); assert.equal(new Set(cards.map(card => card.id)).size, 33); + for (const seat of game.players) { + assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20); + if (seat.elites) { + assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0), + seat.faction === 'fremen' ? 3 : 5); + assert.ok(seat.elites.reserves <= seat.reserves && seat.elites.tanks <= seat.tanks); + for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0)); + } + } +} +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function moneyAtCleanup(fixture: NexusSkillsModulesBattleFixture, revealed: Game, cleanup: Game): void { + const quote = quoteNexusSkillsModulesBattle(revealed); + assert.equal(cleanup.decision?.kind, 'battleCards'); + assert.equal(cleanup.lastBattleContext!.winner, quote.winner); + for (const seat of revealed.players) { + const debit = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0); + const income = quote.strongholdIncome.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.amount, 0); + const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0; + assert.equal(player(cleanup, seat.id).spice, seat.spice - debit + income + bounty, + 'Actual payer debit and printed-disc bounty settle once; the bank subsidy is not personal income'); + } + assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Winner Tech reward waits for original card cleanup'); + assert.deepEqual(cleanup.strongholdCards, fixture.afterFirstMentat.strongholdCards, 'Battle does not acquire or steal a held Stronghold Card'); + custody(cleanup); +} + +for (const advanced of [false, true]) for (const count of [3, 4, 5, 6]) { + void test(`${advanced ? 'Advanced' : 'Basic'} Tech ${count}: original Storm assignment, saved hands/offers, alliance and closing Emperor draw are replayable`, () => { + const fixture = createNexusSkillsModulesBattleFixture({ advanced, tech: true, strongholds: advanced, + seatIds: Array.from({ length: count }, (_, index) => `human-${advanced}-${count}-${index}`) }); + for (const game of [fixture.initial, fixture.afterSetup, fixture.afterFirstStorm, fixture.afterFirstMentat, fixture.afterDraw, fixture.beforeBattle]) custody(game); + assert.ok(TECH_TOKENS.some(token => fixture.beforeFirstStorm.techTokens![token.id].owner === null)); + const storm = stepNexusSkillsModulesBattle(fixture.beforeFirstStorm, fixture.firstStormStep); + assert.deepEqual(storm, fixture.afterFirstStorm); + assert.ok(TECH_TOKENS.every(token => storm.techTokens![token.id].owner)); + assert.equal(new Set(TECH_TOKENS.map(token => storm.techTokens![token.id].owner)).size, 3); + const mentat = stepNexusSkillsModulesBattle(fixture.beforeFirstMentat, fixture.firstMentatStep); + assert.deepEqual(mentat, fixture.afterFirstMentat); + if (advanced) { + assert.deepEqual(mentat.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, false)); + assert.equal(mentat.strongholdCards!.owners.arrakeen, fixture.owner); + assert.equal(strongholdControllers(fixture.beforeBattle.players, false).arrakeen, null, + 'Real later contested positions differ from retained first-Mentat custody'); + } + let allied = fixture.beforeAlliance; + for (const action of fixture.allianceActions) allied = stepNexusSkillsModulesBattle(allied, action); + assert.equal(player(allied, fixture.observer).ally, fixture.partner); + assert.equal(player(allied, fixture.partner).ally, fixture.observer); + assert.equal(player(fixture.beforeDraw, fixture.owner).ally, null); + const drawn = stepNexusSkillsModulesBattle(fixture.beforeDraw, fixture.drawAction); + assert.deepEqual(drawn, fixture.afterDraw); + assert.equal(drawn.nexusCards!.cards!.hands[fixture.owner], 'emperor'); + let saved = structuredClone(fixture.initial); + while (saved.setupStage === 'prediction') saved = stepNexusSkillsModulesBattle(saved); + const resumed = createNexusSkillsModulesBattleFixture({ initial: saved, skill: 'warmaster' }); + assert.deepEqual(resumed.initial, saved); + assert.deepEqual(resumed.initial.players.map(seat => [seat.id, seat.hand]), saved.players.map(seat => [seat.id, seat.hand])); + assert.deepEqual(resumed.initial.leaderSkills!.offers, saved.leaderSkills!.offers); + const unavailable = LEADER_SKILL_CARDS.find(card => !saved.leaderSkills!.offers[fixture.owner]?.cards.includes(card.id))!; + reject(fixture.beforeBattle, fixture.observer, fixture.battleAction.action); + assert.throws(() => createNexusSkillsModulesBattleFixture({ initial: saved, skill: unavailable.id })); + assert.deepEqual(saved, resumed.initial); + }); +} + +for (const band of ['normal', 'skilled'] as const) { + void test(`held Arrakeen + ${band} Warmaster: bank two reduces debit, not three supported temporary-counter casualties or printed bounty`, () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, band, ownerWeapon: 'projectile', ownerDefense: 'worthless' }); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const forces = viewGame(revealed, fixture.owner).battle!.ownForces!; + const quote = quoteNexusSkillsModulesBattle(revealed); + assert.equal(forces.normal, 7); assert.equal(forces.elite, 0); assert.equal(forces.temporaryElite, 5); + assert.equal(maxCombatDial(forces), 12); + const payment = quote.payments.find(payment => payment.player === fixture.owner)!; + assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1); assert.equal(payment.allyPayment, 0); + assert.deepEqual(quote.casualties!.options.map(loss => [loss.normal, loss.elite, loss.paidNormal, loss.paidElite]), + [[3, 0, 3, 0], [4, 0, 3, 0], [5, 0, 3, 0]]); + const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender; + assert.equal(bonus.bonus, band === 'skilled' ? 3 : 1); + const opposingDisc = player(revealed, fixture.opponent).leaders.find(disc => disc.id === revealed.battle!.plans[fixture.opponent].leader)!; + assert.equal(quote.bounty!.amount, opposingDisc.strength); + let losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' }); + assert.ok(losses.decision?.kind === 'battleLosses'); + reject(losses, fixture.opponent, { type: 'decision', choice: 2 }); + losses = applyAction(losses, fixture.owner, { type: 'decision', choice: 2 }); + assert.equal(player(losses, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 5); + assert.deepEqual(player(losses, fixture.owner).elites, player(revealed, fixture.owner).elites); + assert.equal(losses.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete'); + moneyAtCleanup(fixture, revealed, losses); + const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1); + const done = finishNexusSkillsModulesBattle(losses); + assert.equal(done.techTokens![token[0]].owner, fixture.owner); + assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []); + custody(done); + }); +} + +for (const band of ['normal', 'skilled'] as const) for (const support of [3, 6]) { + void test(`${band} Suk with held Arrakeen support ${support}: original support eligibility survives bank subsidy and rescue settles before winner reward`, () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', band, + support, ownerDial: support === 6 ? 10 : 6, ownerDefense: 'shield' }); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const quote = quoteNexusSkillsModulesBattle(revealed); + const physicalLoss = support === 6 ? 6 : band === 'normal' ? 4 : 5; + const payment = quote.payments.find(payment => payment.player === fixture.owner)!; + assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, support - 2); + if (support === 6) { + assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]); + assert.notDeepEqual(quote.casualties!.options, casualtyOptions(quote.casualties!.forces, 10, payment.ownPayment)); + } + let game: Game; + if (support === 3) { + const losses = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' }); + assert.ok(losses.decision?.kind === 'battleLosses'); + game = applyAction(losses, fixture.owner, { type: 'decision', choice: band === 'normal' ? 1 : 2 }); + } else game = finishNexusSkillsModulesBattle(revealed, { stop: band === 'skilled' ? 'rescue' : 'cleanup' }); + const before = player(revealed, fixture.owner); + if (band === 'skilled') { + const decision = game.decision; assert.ok(decision?.kind === 'sukRescue'); + const pending = game.pendingSukRescue!; + assert.equal(pending.commitment.forces.temporaryElite, 5); + assert.ok(pending.losses); + assert.equal(pending.losses.normal, physicalLoss); assert.equal(pending.losses.paidNormal, support); + assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'pending'); + assert.equal(player(game, fixture.owner).tanks, before.tanks); + assert.deepEqual(game.techTokens, revealed.techTokens); + const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key && option.kept.kind === 'normal'); + assert.ok(choice >= 0); + const action: Action = { type: 'decision', event: decision.event, choice }; + reject(game, fixture.opponent, action); + reject(game, fixture.owner, { ...action, event: `${decision.event}-stale` }); + game = applyAction(game, fixture.owner, action); + reject(game, fixture.owner, action); + } + const saved = band === 'skilled' ? 3 : 1, kept = band === 'skilled' ? 1 : 0; + assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + kept); + assert.equal(player(game, fixture.owner).reserves, before.reserves + saved - kept); + assert.equal(player(game, fixture.owner).tanks, before.tanks + physicalLoss - saved); + assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + physicalLoss - saved); + assert.deepEqual(player(game, fixture.owner).elites, before.elites); + assert.equal(game.pendingSukRescue, null); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + assert.equal(game.nexusSardaukarHistory!.at(-1)!.casualties!.outcome, 'complete'); + moneyAtCleanup(fixture, revealed, game); + custody(finishNexusSkillsModulesBattle(game)); + }); +} + +void test('actual starred Sardaukar block ordinary-as-Sardaukar Cunning; native typed Suk rescue keeps the selected star rather than creating an elite Tank', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', ownerElites: 1, + ownerDial: 5, support: 3, ownerDefense: 'shield' }); + const prepared = prepareNexusSkillsModulesBattle(fixture); + const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!; + reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event }); + assert.equal(prepared.nexusCards!.cards!.hands[fixture.owner], 'emperor'); + const revealed = revealNexusSkillsModulesBattle(fixture, { state: prepared }); + const own = viewGame(revealed, fixture.owner).battle!.ownForces!; + assert.equal(own.normal, 7); assert.equal(own.elite, 1); assert.equal(own.temporaryElite ?? 0, 0); + let game = finishNexusSkillsModulesBattle(revealed, { stop: 'losses' }); + assert.ok(game.decision?.kind === 'battleLosses'); + const loss = game.decision.options.findIndex(option => option.normal === 4 && option.elite === 1); assert.ok(loss >= 0); + game = applyAction(game, fixture.owner, { type: 'decision', choice: loss }); + const rescue = game.decision; assert.ok(rescue?.kind === 'sukRescue'); + const choice = rescue.options.findIndex(option => option.normal === 2 && option.elite === 1 && option.kept?.kind === 'elite'); assert.ok(choice >= 0); + const original = player(revealed, fixture.owner); + const physicalRescue = structuredClone(game); + game = applyAction(game, fixture.owner, { type: 'decision', event: rescue.event, choice }); + assert.equal(player(game, fixture.owner).forces[fixture.key], 4); + assert.equal(player(game, fixture.owner).elites!.forces[fixture.key], 1); + assert.equal(player(game, fixture.owner).reserves, original.reserves + 2); + assert.equal(player(game, fixture.owner).elites!.reserves, original.elites!.reserves); + assert.equal(player(game, fixture.owner).tanks, original.tanks + 2); + assert.equal(player(game, fixture.owner).elites!.tanks, original.elites!.tanks); + moneyAtCleanup(fixture, revealed, game); + custody(finishNexusSkillsModulesBattle(game)); + const normalChoice = rescue.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.kind === 'normal'); + assert.ok(normalChoice >= 0); + const ordinary = applyAction(physicalRescue, fixture.owner, { type: 'decision', event: rescue.event, choice: normalChoice }); + assert.equal(player(ordinary, fixture.owner).forces[fixture.key], 4); + assert.equal(player(ordinary, fixture.owner).elites!.forces[fixture.key] ?? 0, 0); + assert.equal(player(ordinary, fixture.owner).reserves, original.reserves + 2); + assert.equal(player(ordinary, fixture.owner).elites!.reserves, original.elites!.reserves); + assert.equal(player(ordinary, fixture.owner).tanks, original.tanks + 2); + assert.equal(player(ordinary, fixture.owner).elites!.tanks, original.elites!.tanks + 1, + 'An unsaved actual starred casualty enters starred Tanks; temporary ordinary roles never do'); + moneyAtCleanup(fixture, revealed, ordinary); + custody(finishNexusSkillsModulesBattle(ordinary)); +}); + +void test('held Carthag physical Shield provides poison protection after first-Mentat positions change, preserving the actual skilled Suk entitlement', () => { + for (const strongholds of [false, true]) { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds, kind: 'carthag', skill: 'suk-graduate', + ownerDefense: 'shield', opponentWeapon: 'poison' }); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const quote = quoteNexusSkillsModulesBattle(revealed); + const death = revealed.battle!.attacker === fixture.owner ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender; + assert.equal(death, !strongholds); + assert.equal(!!quote.sukGraduate, strongholds); + if (strongholds) { + assert.equal(fixture.afterFirstMentat.strongholdCards!.owners.carthag, fixture.owner); + assert.equal(strongholdControllers(fixture.beforeBattle.players, false).carthag, null); + const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' }); + assert.equal(rescue.decision?.kind, 'sukRescue'); + const cleanup = finishNexusSkillsModulesBattle(rescue, { stop: 'cleanup' }); + assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, false); + moneyAtCleanup(fixture, revealed, cleanup); + } else { + const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }); + assert.equal(player(cleanup, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.dead, true); + assert.equal(cleanup.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.trainer), false); + assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1); + assert.equal(cleanup.pendingSukRescue ?? null, null); + const printed = player(revealed, fixture.owner).leaders.find(disc => disc.id === fixture.trainer)!.strength; + assert.equal(quote.bounty!.amount, printed); + moneyAtCleanup(fixture, revealed, cleanup); + } + } +}); + +void test('several losing-owner tokens are actually earned by first-turn combat; original rescue, winner disposal, loser retention, mandatory token selection and inspection remain separate', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, opponentTokens: 'several', opponentNexus: 'moritani', + skill: 'suk-graduate', ownerDefense: 'shield', opponentDefense: 'shield' }); + assert.equal(fixture.warmupAfterBattle!.lastBattleContext!.winner, fixture.opponent); + assert.equal(ownedTech(fixture.afterFirstStorm.techTokens, fixture.opponent).length, 1); + const original = ownedTech(fixture.game.techTokens, fixture.opponent); assert.equal(original.length, 2); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }); + moneyAtCleanup(fixture, revealed, cleanup); + const defense = fixture.planActions[0].action.defense!; + const disposal: Action = { type: 'decision', discard: [defense] }; + reject(cleanup, fixture.opponent, disposal); + const disposed = applyAction(cleanup, fixture.owner, disposal); + assert.equal(player(disposed, fixture.owner).hand.some(card => card.id === defense), false); + assert.equal(disposed.discard.filter(card => card.id === defense).length, 1); + const retention = finishNexusSkillsModulesBattle(disposed, { stop: 'retention' }); + assert.ok(retention.decision?.kind === 'moritaniRetention' && retention.decision.source === 'nexus'); + assert.deepEqual(retention.techTokens, revealed.techTokens); + const keep: Action = { type: 'decision', event: retention.decision.event, keep: fixture.planActions[1].action.defense! }; + reject(retention, fixture.owner, keep); + reject(retention, fixture.opponent, { ...keep, event: `${retention.decision.event}-old` }); + const retained = applyAction(retention, fixture.opponent, keep); + assert.equal(player(retained, fixture.opponent).hand.some(card => card.id === keep.keep), true); + assert.equal(retained.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1); + for (const token of original) { + const choice = finishNexusSkillsModulesBattle(retained, { stop: 'tech' }); + assert.ok(choice.decision?.kind === 'techToken'); + assert.deepEqual(choice.decision.choices, original); + reject(choice, fixture.opponent, { type: 'decision', token }); + reject(choice, fixture.owner, { type: 'decision', decline: true }); + const selected = applyAction(choice, fixture.owner, { type: 'decision', token }); + assert.equal(selected.techTokens![token].owner, fixture.owner); + assert.equal(selected.techTokens![original.find(id => id !== token)!].owner, fixture.opponent); + const inspection = finishNexusSkillsModulesBattle(selected, { stop: 'inspection' }); + assert.ok(inspection.decision?.kind === 'nexusChoamInspection'); + assert.equal(viewGame(inspection, fixture.owner).nexusChoamInspection!.canInspect, false, + 'Spent Emperor Cunning is not a held CHOAM card and cannot inspect a fresh card'); + const decline: Action = { type: 'decision', event: inspection.decision.event, inspect: false }; + reject(inspection, fixture.opponent, decline); + reject(inspection, fixture.owner, { ...decline, event: `${inspection.decision.event}-old` }); + const declined = applyAction(inspection, fixture.owner, decline); + reject(declined, fixture.owner, decline); + custody(finishNexusSkillsModulesBattle(declined)); + } +}); + +void test('physical Karama cancellation leaves held Arrakeen support legal and does not fabricate temporary roles or spend Tech', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, counter: true, ownerDial: 4, support: 4 }); + let game = beginNexusSkillsModulesCunning(fixture); + assert.equal(game.response?.kind, 'nexusSardaukar'); + reject(game, fixture.owner, { type: 'card', card: fixture.counter!, mode: 'cancel' }); + game = applyAction(game, fixture.opponent, { type: 'card', card: fixture.counter!, mode: 'cancel' }); + game = advanceNexusSkillsModulesBattle(game, state => !state.response && !state.phaseOpening && !state.decision); + assert.equal(game.nexusSardaukarLast!.stage, 'canceled'); + assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0); + assert.equal(game.discard.filter(card => card.id === fixture.counter).length, 1); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1); + const revealed = revealNexusSkillsModulesBattle(fixture, { state: game }); + const payment = quoteNexusSkillsModulesBattle(revealed).payments.find(payment => payment.player === fixture.owner)!; + assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 2); + moneyAtCleanup(fixture, revealed, finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' })); +}); + +void test('Basic Tech keeps the original blocked Cunning offer, no Advanced support fee, and mandatory physical winner reward', () => { + const fixture = createNexusSkillsModulesBattleFixture({ advanced: false, tech: true }); + const prepared = prepareNexusSkillsModulesBattle(fixture); + const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!; + reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event }); + assert.equal(maxCombatDial(viewGame(prepared, fixture.owner).battle!.ownForces!), 7); + const revealed = revealNexusSkillsModulesBattle(fixture); + const quote = quoteNexusSkillsModulesBattle(revealed); + assert.deepEqual(quote.payments, []); assert.equal(quote.casualties, null); assert.equal(quote.basicWinnerLosses, 6); + const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }); + assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 6); + assert.equal(cleanup.nexusCards!.cards!.hands[fixture.owner], 'emperor'); + moneyAtCleanup(fixture, revealed, cleanup); + const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1); + const done = finishNexusSkillsModulesBattle(cleanup); + assert.equal(done.techTokens![token[0]].owner, fixture.owner); + custody(done); +}); + +void test('a sealed plan and later battle reject stale Cunning; the physical spent group expires through actual END Mentat and next Storm', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, secondBattle: true, ownerDial: 6, support: 3 }); + let sealed = prepareNexusSkillsModulesBattle(fixture); + const event = viewGame(sealed, fixture.owner).nexusSardaukar!.offer!.event; + sealed = applyAction(sealed, fixture.owner, { ...fixture.planActions[0].action, dial: 4, support: 4 }); + reject(sealed, fixture.owner, { type: 'nexusSardaukar', event }); + assert.equal(sealed.nexusCards!.cards!.hands[fixture.owner], 'emperor'); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const oldEvent = revealed.nexusSardaukarLast!.event; + let game = finishNexusSkillsModulesBattle(revealed); + const actor = game.active!; + game = applyAction(game, actor, { type: 'chooseBattle', territory: 'carthag', target: actor === fixture.owner ? fixture.opponent : fixture.owner }); + game = prepareNexusSkillsModulesBattle(fixture, game); + assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, 1); + assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0); + reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent }); + const candidates = (id: string) => player(game, id).leaders.filter(disc => !disc.dead && !disc.usedAt && + !game.leaderSkills!.assignments.some(assignment => assignment.leader === disc.id)); + for (const id of [fixture.owner, fixture.opponent]) { + game = applyAction(game, id, { type: 'battlePlan', leader: candidates(id)[0].id, dial: 0, support: 0, weapon: null, defense: null }); + while (game.phaseOpening || game.response || game.decision?.kind === 'fullPlanOffer') game = stepNexusSkillsModulesBattle(game); + } + game = finishNexusSkillsModulesBattle(game); + game = advanceNexusSkillsModulesBattle(game, state => state.turn === 3 && state.phase === 1 && !state.response && !state.phaseOpening && !state.decision); + assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false); + assert.equal(game.nexusSardaukarHistory![0].receipt.turn, 2); + assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete'); + reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent }); + custody(game); +}); + +void test('four original legal policies rescue three physical ordinary counters and retain their native support budget before cleanup and Tech transfer', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: true, strongholds: true, skill: 'suk-graduate', support: 6, + ownerDial: 10, ownerDefense: 'shield' }); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const rescue = finishNexusSkillsModulesBattle(revealed, { stop: 'rescue' }); + assert.ok(rescue.decision?.kind === 'sukRescue'); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(rescue, fixture.owner); + view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'decision'); + assert.ok(action && typeof action.choice === 'number'); + const option: (typeof rescue.decision.options)[number] = rescue.decision.options[action.choice]; + assert.equal(option.normal, 3); assert.equal(option.elite, 0); assert.equal(option.kept?.key, fixture.key); + const cleanup = applyAction(rescue, fixture.owner, action); + assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 2); + assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3); + assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice - 4); + moneyAtCleanup(fixture, revealed, cleanup); + custody(finishNexusSkillsModulesBattle(cleanup)); + let prepared = prepareNexusSkillsModulesBattle(fixture); + const openingView = viewGame(prepared, fixture.owner); + openingView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty; + const cunning = botActions(openingView).find(action => action.type === 'nexusSardaukar'); assert.ok(cunning); + prepared = applyAction(prepared, fixture.owner, cunning); + prepared = advanceNexusSkillsModulesBattle(prepared, state => !state.response && !state.phaseOpening && !state.decision); + const planView = viewGame(prepared, fixture.owner); + planView.players.find(seat => seat.id === fixture.owner)!.bot = difficulty; + const plan = botActions(planView).find(action => action.type === 'battlePlan'); assert.ok(plan); + assert.equal(typeof plan.support, 'number'); + assert.ok(Number(plan.support) <= player(prepared, fixture.owner).spice + 2); + const committed = applyAction(prepared, fixture.owner, plan); + assert.ok(committed.battle!.plans[fixture.owner]); + assert.equal(player(committed, fixture.owner).spice, player(prepared, fixture.owner).spice, + 'The legal bot seals its budget; no debit is taken before actual battle resolution'); + } +}); + +void test('Advanced Strongholds without Tech retain the same original Skills/Nexus Arrakeen rescue and physical inventory', () => { + const fixture = createNexusSkillsModulesBattleFixture({ tech: false, strongholds: true, skill: 'suk-graduate', + support: 6, ownerDial: 10, ownerDefense: 'shield' }); + const revealed = revealNexusSkillsModulesBattle(fixture, { cunning: true }); + const quote = quoteNexusSkillsModulesBattle(revealed); + assert.equal(quote.payments.find(payment => payment.player === fixture.owner)!.ownPayment, 4); + const cleanup = finishNexusSkillsModulesBattle(revealed, { stop: 'cleanup' }); + assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3); + assert.equal(cleanup.pendingTech ?? null, null); + moneyAtCleanup(fixture, revealed, cleanup); + custody(finishNexusSkillsModulesBattle(cleanup)); +}); diff --git a/tests/nexus-skills-modules-payments-runtime.test.ts b/tests/nexus-skills-modules-payments-runtime.test.ts new file mode 100644 index 00000000..936e1f9c --- /dev/null +++ b/tests/nexus-skills-modules-payments-runtime.test.ts @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { TERRITORIES } from '../game/board'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival'; +import { + advanceNexusSkillsModulesPayments, advanceNexusSkillsModulesPaymentsStep, + advanceNexusSkillsModulesPaymentsToPhase, createNexusSkillsModulesPaymentsFixture, + finishNexusSkillsModulesPaymentsFixture, holdNexusSkillsModulesPaymentsCard, + initializeNexusSkillsModulesPaymentsSetup, nexusSkillsModulesPaymentsInventory, + nexusSkillsModulesPaymentsPlayer, payNexusSkillsModulesPaymentsFixture, + type NexusSkillsModulesPaymentsFixture, +} from './fixture-nexus-skills-modules-payments'; + +const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); +function rejected(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'A rejected private action cannot mutate cards, counters or original accounting'); +} +function custody(before: Game, after: Game): void { + assert.deepEqual(after.leaderSkills, before.leaderSkills); + assert.deepEqual(after.strongholdCards, before.strongholdCards, + 'A return, purchase or shipment grants no new Stronghold revival/payment advantage'); + for (const player of before.players) + assert.deepEqual(viewGame(after, player.id).leaderSkills!.assignments, + viewGame(before, player.id).leaderSkills!.assignments); + nexusSkillsModulesPaymentsInventory(after); +} +/** Quote city Collection independently when native empty Battle auto-skips. */ +function collectOnce(f: NexusSkillsModulesPaymentsFixture, paid: Game, amount: number): Game { + const expected = wallets(paid); + let ended = advanceNexusSkillsModulesPaymentsToPhase(paid, paid.phase + 1); + if (ended.phase === 7) + for (const receipt of quoteSpiceCollection(paid).receipts) expected[receipt.player] = receipt.balance; + if (f.tokenOwner) expected[f.tokenOwner] += amount; + assert.deepEqual(wallets(ended), expected, 'Only the original phase-end pile and separately quoted city collection become spendable'); + if (ended.techTokens) assert.equal(ended.techTokens[f.token].spice, 0); + const collection = advanceNexusSkillsModulesPaymentsToPhase(ended, 7); + const atCollection = wallets(collection); + ended = advanceNexusSkillsModulesPaymentsToPhase(collection, 8); + assert.deepEqual(wallets(ended), atCollection, 'Mentat cannot collect the same Tech pile again'); + if (ended.techTokens) assert.equal(ended.techTokens[f.token].spice, 0); + const continued = advanceNexusSkillsModulesPaymentsStep(ended); + assert.deepEqual(wallets(continued), atCollection); + custody(paid, continued); + return continued; +} +const configurations = [ + { name: 'Basic Tech', advanced: false, tech: true, strongholds: false }, + { name: 'Advanced Tech', advanced: true, tech: true, strongholds: false }, + { name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true }, + { name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true }, +] as const; + +// Original Nexus Emperor extra-three and the recorded original Fremen free-three +// interpretation remain distinct. E1 printed p5: free revivals trigger Axlotl, +// payable only at Revival end (http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf). +for (const config of configurations) { + for (const kind of ['emperor-revival', 'fremen-revival'] as const) { + void test(`${config.name} original all14/33/12 ${kind} conserves physical returns, quota and phase-end Axlotl once`, () => { + const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind }); + assert.equal(f.afterSetup.leaderSkills!.assignments.length, f.afterSetup.players.length); + assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2); + if (config.tech) { + assert.ok(Object.values(f.afterFirstMentat.techTokens!).every(token => token.owner)); + } + if (config.strongholds) { + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.actor); + } + assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing'); + assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.actor], null); + assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], kind === 'emperor-revival' ? 'emperor' : 'fremen'); + const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor); + rejected(f.game, f.opponent, f.paymentAction); + rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' }); + let paid = payNexusSkillsModulesPaymentsFixture(f); + let actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor); + assert.equal(actor.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3); + assert.deepEqual(wallets(paid), wallets(f.game), 'A free return does not collect the accrued token or mint personal income'); + assert.equal(actor.revived, kind === 'fremen-revival' ? 3 : before.revived); + assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0)); + assert.equal(paid.nexusCards!.cards!.hands[f.actor], null); + assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1); + const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0]; + assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4); + assert.equal(receipt.before.tanks - receipt.after.tanks, 3); + assert.equal(receipt.before.spice, receipt.after.spice); + if (config.tech) { + assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount); + assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn); + } + custody(f.game, paid); rejected(paid, f.actor, f.paymentAction); + if (kind === 'fremen-revival') { + assert.equal(forceRevivalRemaining(paid, actor), 0); + rejected(paid, f.actor, { type: 'revive', amount: 1 }); + } else { + assert.equal(forceRevivalRemaining(paid, actor), 3); + const quote = forceRevivalQuote(paid, actor, 3); + assert.equal(quote.cost, 2, 'A Stronghold cannot increase the original Atreides two-free allowance'); + paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 }); + paid = finishNexusSkillsModulesPaymentsFixture(f, paid); + actor = nexusSkillsModulesPaymentsPlayer(paid, f.actor); + assert.equal(actor.reserves, before.reserves + 6); assert.equal(actor.tanks, before.tanks - 6); + assert.equal(actor.spice, before.spice - quote.cost); + assert.equal(actor.revived, 3); assert.equal(actor.freeForcesRevived, 2); + if (config.tech) assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount, 'A second genuine free batch cannot accrue Axlotl twice'); + } + custody(f.game, paid); + const mentat = collectOnce(f, paid, f.tokenCount); + assert.equal(nexusSkillsModulesPaymentsPlayer(mentat, f.actor).revived, 3); + const nextTurn = advanceNexusSkillsModulesPayments(mentat, game => game.turn === 3); + const nextRevival = advanceNexusSkillsModulesPaymentsToPhase(nextTurn, 4); + assert.equal(nexusSkillsModulesPaymentsPlayer(nextRevival, f.actor).revived, 0); + assert.equal(nexusSkillsModulesPaymentsPlayer(nextRevival, f.actor).freeForcesRevived ?? 0, 0); + assert.deepEqual(nextRevival.nexusEmperorSecretHistory, paid.nexusEmperorSecretHistory); + assert.deepEqual(nextRevival.nexusFremenRevivalHistory, paid.nexusFremenRevivalHistory); + }); + } + + void test(`${config.name} normal Emperor bank-auction purchase delivers the real lot once without payment, industry or normal Banker award`, () => { + const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'emperor-purchase', skill: 'spice-banker' }); + const auction = f.game.auction!, lot = auction.cards[auction.index]; + const buyer = nexusSkillsModulesPaymentsPlayer(f.game, f.actor); + assert.equal(auction.bid, 2); assert.equal(auction.bidder, f.actor); assert.equal(auction.allyPayment ?? 0, 0); + assert.ok(buyer.spice >= 2); assert.ok(!buyer.hand.some(card => card.id === lot.id)); + assert.ok(f.game.leaderSkills!.assignments.some(a => a.owner === f.actor && a.skill === 'spice-banker')); + rejected(f.game, f.opponent, f.paymentAction); + const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = 'Hard'; + const bot = botActions(view).find(action => action.type === 'nexusEmperorPurchase'); + assert.deepEqual(bot, f.paymentAction); + const paid = applyAction(f.game, f.actor, bot!); + assert.deepEqual(wallets(paid), wallets(f.game)); + assert.equal(nexusSkillsModulesPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).length, 1); + const receipt = paid.nexusEmperorPurchaseHistory![0]; + assert.equal(receipt.card, lot.id); assert.equal(receipt.price, 2); + 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); + assert.deepEqual(continued.nexusEmperorPurchaseHistory, paid.nexusEmperorPurchaseHistory); + assert.deepEqual(continued.techTokens, f.game.techTokens); custody(f.game, continued); + }); + + void test(`${config.name} source-clear ordinary non-Guild shipment uses native full price and phase-end Heighliners`, () => { + const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'native-shipment' }); + const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor); + const paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f)); + const after = nexusSkillsModulesPaymentsPlayer(paid, f.actor); + assert.equal(after.faction, 'atreides'); assert.equal(f.cost, f.amount); + assert.equal(after.spice, before.spice - f.cost); + assert.equal(after.reserves, before.reserves - f.amount); + assert.equal(after.forces[f.destination!], f.amount); assert.equal(after.shipped, true); + assert.equal(paid.nexusCards!.cards!.hands[f.actor], 'emperor', 'Ordinary shipping cannot spend a private unused Nexus singleton'); + if (config.tech) { + assert.equal(paid.techTokens!.heighliners.spice, f.tokenCount); + assert.equal(paid.techTokens!.heighliners.triggeredTurn, paid.turn); + } + custody(f.game, paid); rejected(paid, f.actor, f.paymentAction); + collectOnce(f, paid, f.tokenCount); + }); +} + +for (const kind of ['emperor-revival', 'fremen-revival'] as const) { + void test(`Advanced combined ${kind} real bot returns one original elite and respects the shared per-turn cap`, () => { + const f = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, tech: true, strongholds: true, + ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 }); + const view = viewGame(f.game, f.actor); + const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!; + assert.deepEqual(offer.eliteOptions, [0, 1]); + rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 }); + view.players.find(p => p.id === f.actor)!.bot = 'Hard'; + const bot = botActions(view).find(action => action.type === f.paymentAction.type); assert.ok(bot); + const paid = applyAction(f.game, f.actor, bot); + const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor), after = nexusSkillsModulesPaymentsPlayer(paid, f.actor); + assert.equal(after.elites!.reserves, before.elites!.reserves + 1); + assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1); + assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3); + assert.deepEqual(wallets(paid), wallets(f.game)); + rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 }); + custody(f.game, paid); collectOnce(f, paid, f.tokenCount); + }); +} + +void test('combined ordinary-first Emperor extra-three and early-only Fremen preserve their different ledgers and exact-three boundary', () => { + const extra = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true }); + const ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 }); + const paid = payNexusSkillsModulesPaymentsFixture(extra, ordinary); + const before = nexusSkillsModulesPaymentsPlayer(ordinary, extra.actor), after = nexusSkillsModulesPaymentsPlayer(paid, extra.actor); + assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3); + assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2); + assert.equal(paid.techTokens!.axlotl.spice, ordinary.techTokens!.axlotl.spice); + const fremen = createNexusSkillsModulesPaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true }); + const late = applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }); + assert.ok(viewGame(late, fremen.actor).nexusFremenRevival!.blocked); + rejected(late, fremen.actor, fremen.paymentAction); + for (const kind of ['emperor-revival', 'fremen-revival'] as const) { + const short = createNexusSkillsModulesPaymentsFixture({ kind, advanced: true, strongholds: true, tanks: 2 }); + rejected(short.game, short.actor, short.paymentAction); + assert.equal(nexusSkillsModulesPaymentsPlayer(short.game, short.actor).tanks, 2); + custody(short.game, short.game); + } +}); + +for (const ownerFaction of ['guild', 'fremen'] as const) { + void test(`original ${ownerFaction} native shipment cannot activate Heighliners from excluded source identity`, () => { + const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', ownerFaction, advanced: true, strongholds: true }); + const before = nexusSkillsModulesPaymentsPlayer(f.game, f.actor); + const paid = finishNexusSkillsModulesPaymentsFixture(f, payNexusSkillsModulesPaymentsFixture(f)); + const after = nexusSkillsModulesPaymentsPlayer(paid, f.actor); + assert.equal(after.faction, ownerFaction); assert.equal(after.forces[f.destination!], f.amount); + assert.equal(after.reserves, before.reserves - f.amount); assert.equal(after.spice, before.spice - f.cost); + assert.equal(f.cost, ownerFaction === 'fremen' ? 0 : Math.ceil(f.amount / 2)); + assert.equal(paid.techTokens!.heighliners.spice, 0); + assert.notEqual(paid.techTokens!.heighliners.triggeredTurn, paid.turn); + custody(f.game, paid); collectOnce(f, paid, 0); + }); +} + +void test('an actual Guild interception blocks settlement and original phase-end collection; stopping it pays no invoice or Heighliners', () => { + const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', advanced: true, strongholds: true, nativeGuild: true }); + const guild = f.game.players.find(p => p.faction === 'guild')!; + const counter = holdNexusSkillsModulesPaymentsCard(f.game, guild.id, 'karama', f.staging); + const before = structuredClone(f.game); + let game = payNexusSkillsModulesPaymentsFixture(f); + assert.equal(game.decision?.kind, 'guildShipment'); assert.ok(game.pendingShipment); + assert.deepEqual(wallets(game), wallets(before)); + assert.equal(game.techTokens!.heighliners.spice, 0); + for (const player of game.players) rejected(game, player.id, { type: 'ready' }); + game = applyAction(game, guild.id, { type: 'card', mode: 'special', card: counter.id }); + game = finishNexusSkillsModulesPaymentsFixture(f, game); + assert.deepEqual(wallets(game), wallets(before)); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, f.actor).reserves, nexusSkillsModulesPaymentsPlayer(before, f.actor).reserves); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, f.actor).forces[f.destination!], undefined); + assert.ok(game.discard.some(card => card.id === counter.id)); + assert.equal(game.techTokens!.heighliners.spice, 0); + custody(before, game); collectOnce(f, game, 0); +}); + +void test('actual initialized module setup preserves authenticated IDs, dealt hands and saved skill offers; explicit missing skill is not replaced', () => { + const setup = initializeNexusSkillsModulesPaymentsSetup({ advanced: true, strongholds: true, + seatIds: ['authenticated-owner', 'authenticated-second', 'authenticated-third'], skill: 'sandmaster' }); + const before = structuredClone(setup); + const accepted = initializeNexusSkillsModulesPaymentsSetup({ initial: setup }); + assert.deepEqual(accepted, before); + const selected = advanceNexusSkillsModulesPayments(accepted, state => state.leaderSkills!.assignments.length === 1); + assert.equal(selected.leaderSkills!.assignments.length, 1); + const f = createNexusSkillsModulesPaymentsFixture({ initial: setup, skill: 'sandmaster' }); + assert.equal(f.actor, 'authenticated-owner'); assert.deepEqual(f.initial, before); + assert.deepEqual(f.game.players.map(p => p.id), before.players.map(p => p.id)); + assert.deepEqual(setup, before); + assert.ok(f.game.leaderSkills!.assignments.some(a => a.owner === f.actor && a.skill === 'sandmaster')); + assert.throws(() => createNexusSkillsModulesPaymentsFixture({ initial: setup, skill: 'spice-banker' })); + assert.deepEqual(setup, before); + custody(f.game, payNexusSkillsModulesPaymentsFixture(f)); +}); + +void test('earned combined Axlotl pile stays unavailable while an actual original private Truthtrance is unsettled', () => { + const f = createNexusSkillsModulesPaymentsFixture({ advanced: true, strongholds: true }); + let paid = payNexusSkillsModulesPaymentsFixture(f); + const card = holdNexusSkillsModulesPaymentsCard(paid, f.opponent, 'truthtrance', f.staging); + const before = structuredClone(paid); + paid = applyAction(paid, f.opponent, { type: 'card', card: card.id }); + assert.ok(paid.truthtrance); + assert.equal(paid.phase, 4); + assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount); + assert.deepEqual(wallets(paid), wallets(before)); + for (const player of paid.players) rejected(paid, player.id, { type: 'ready' }); + const settled = finishNexusSkillsModulesPaymentsFixture(f, paid); + assert.ok(!settled.truthtrance); + assert.equal(settled.phase, 4); + assert.equal(settled.techTokens!.axlotl.spice, f.tokenCount); + assert.deepEqual(wallets(settled), wallets(before)); + assert.ok(settled.discard.some(c => c.id === card.id)); + custody(before, settled); + collectOnce(f, settled, f.tokenCount); +}); + +void test('two actual ordinary off-planet shipments in the same combined phase accrue only one original Heighliners pile', () => { + const f = createNexusSkillsModulesPaymentsFixture({ kind: 'native-shipment', advanced: true, strongholds: true }); + let game = advanceNexusSkillsModulesPaymentsToPhase(f.afterClosingDraw, 5); + const before = structuredClone(game); + const shippers: string[] = []; + for (let n = 0; n < 2; n++) { + assert.equal(game.phase, 5); + const actor = game.active!, player = nexusSkillsModulesPaymentsPlayer(game, actor); + assert.notEqual(player.faction, 'guild'); assert.notEqual(player.faction, 'fremen'); + assert.ok(!shippers.includes(actor)); shippers.push(actor); + const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) + && game.players.every(p => Object.keys(p.forces).every(key => !key.startsWith(`${t.id}:`)))); + assert.ok(target); + const sector = target.sectors[0], wallet = player.spice, reserves = player.reserves; + game = applyAction(game, actor, { type: 'ship', territory: target.id, sector, amount: 1, smuggler: false }); + game = finishNexusSkillsModulesPaymentsFixture(f, game); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).reserves, reserves - 1); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).forces[`${target.id}:${sector}`], 1); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, actor).spice, wallet - 1); + assert.equal(game.techTokens!.heighliners.spice, f.tokenCount); + assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn); + if (n === 0) { + game = applyAction(game, actor, { type: 'endMovement' }); + game = finishNexusSkillsModulesPaymentsFixture(f, game); + } + } + custody(before, game); + collectOnce(f, game, f.tokenCount); +}); + +void test('classic Nexus Skills and selected components retain the original once-per-phase Bureaucrat payment diversion', () => { + for (const config of configurations) { + const f = createNexusSkillsModulesPaymentsFixture({ ...config, kind: 'native-shipment', skill: 'bureaucrat', + seatIds: ['owner', 'payer', 'guild', 'other'] }); + const payer = f.game.players.find(player => player.id !== f.actor)!; + const payee = f.game.players.find(player => player.id !== f.actor && player.id !== payer.id && player.id !== payer.ally)!; + const before = structuredClone(f.game); + let game = applyAction(f.game, payer.id, { type: 'bribe', target: payee.id, amount: 5 }); + assert.equal(game.decision?.kind, 'bureaucratPayment'); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, payer.id).spice, payer.spice - 5); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, payee.id).bribes, payee.bribes); + game = applyAction(game, f.actor, { type: 'decision', event: game.bureaucratPaymentEvent, redirect: true }); + assert.equal(nexusSkillsModulesPaymentsPlayer(game, payee.id).bribes, payee.bribes + 3); + assert.equal(viewGame(game, f.actor).bureaucrat.usedThisPhase, true); + assert.deepEqual(game.techTokens, before.techTokens, 'A bribe is not a Tech industry payment'); + assert.deepEqual(game.strongholdCards, before.strongholdCards); + const again = applyAction(game, payer.id, { type: 'bribe', target: payee.id, amount: 5 }); + assert.equal(again.decision ?? null, null); + assert.equal(nexusSkillsModulesPaymentsPlayer(again, payee.id).bribes, payee.bribes + 8); + assert.equal(again.bureaucratPayments!.used.length, 1); + nexusSkillsModulesPaymentsInventory(again); + } +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 816d7021..97747bfa 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -42,7 +42,8 @@ const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' - | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'; + | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills' + | 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -120,6 +121,14 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) => const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({ ...scenario, profile: 'nexus-skills', ordinal: 1410 + index, })); +const NEXUS_SKILLS_MODULE_SCENARIOS: readonly Scenario[] = + (['nexus-skills-tech', 'nexus-skills-stronghold', 'nexus-skills-stronghold-tech'] as const) + .flatMap((profile, band) => NEXUS_SKILLS_SCENARIOS + .filter(scenario => (profile === 'nexus-skills-tech' || scenario.rules === 'advanced') && + (profile === 'nexus-skills-stronghold' || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1510 + band * 50 + index }))); +const nexusSkillsModuleProfile = (profile: string) => profile === 'nexus-skills-tech' || + profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech'; const MODULE_SCENARIOS: readonly Scenario[] = [ ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ @@ -157,11 +166,14 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = (profile === 'nexus-stronghold' || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); const nexusModuleProfile = (profile: string) => - profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || pairedNexusModuleProfile(profile); + profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || + pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile); const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' || - profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech'; + profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' || + profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech'; const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || - profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech'; + profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' || + profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech'; const pairedNexusModuleProfile = (profile: string) => profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech'; const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [ @@ -418,7 +430,7 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = ].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3) .map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile }))); const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; -const skillProfile = (profile: string) => profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); +const skillProfile = (profile: string) => nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && (scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); @@ -459,7 +471,7 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|nexus-skills|paired-nexus-skills|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + @@ -475,6 +487,7 @@ function usage() { + '\nPaired-nexus-tech adds original Tech to both Ixians+Tleilaxu OR CHOAM+Richese and classic opponents, one selected deck, three-through-six seats, Basic/Advanced. Paired-nexus-stronghold is Advanced2..6; paired-nexus-stronghold-tech is Advanced3..6. Native Cunning, real closing deals, typed forces/physical Face Dancers/No-Fields, original industry/rewards and held effects remain. No mixed families, E3, Skills, other overlays, new rulings, public starts or save conversion.' + '\nNexus-skills uses classic/base33/all14 Skills/all12 Nexus, Basic/Advanced two through six seats, with original setup/posture, actual closing draws and physical effects. Native factions, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and unresolved effects remain guarded; no public start or played-game conversion.' + '\nPaired-nexus-skills adds all14 Skills to both original Ixians+Tleilaxu OR CHOAM+Richese, classic opponents and one family deck/all12 Nexus, Basic/Advanced2..6. Native setup, actual closing draws, typed skills/Cunning, Face Dancers and one-invoice physical marker pairs reuse original rules. Tech/Strongholds/HW, mixed families, E3, Banker/Mentat previews, borrowed Smuggler/pair companions, public starts and pending rulings remain excluded.' + + '\nNexus-skills-tech preserves original Tech3..6 Basic/Advanced with classic/base33/all14/all12; nexus-skills-stronghold preserves original Advanced Strongholds2..6; selecting both requires3..6 Advanced. Real first-Storm/end-Mentat ownership, phase-end industry, held subsidy, typed rescue and mandatory original-winner token transfer remain. Native rosters, HW, Banker/Mentat previews, other overlays, public starts and pending rulings stay separate.' ); } @@ -658,7 +671,7 @@ function resumedGame(path: string) { game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS].find( + const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && @@ -890,6 +903,7 @@ async function main() { : pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS : profile === 'nexus-skills' ? NEXUS_SKILLS_SCENARIOS : profile === 'paired-nexus-skills' ? PAIRED_NEXUS_SKILLS_SCENARIOS + : nexusSkillsModuleProfile(profile) ? NEXUS_SKILLS_MODULE_SCENARIOS : nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index b6fa0676..5a1d1063 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -34,6 +34,7 @@ async function main() { '\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+. 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, Homeworlds in this composition, expansions, unrelated previews, unresolved effects and public starts remain separate.', '\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.', '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families, E3, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', + '\nIn the classic/base33/all14/all12 skill-Nexus entry, selected unused original Tech Tokens additionally require3–6 Basic/Advanced; selected Stronghold Cards require2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, skill rescue and mandatory original-winner Tech use original handlers. Paired/native skill-Nexus remains without Tech/Strongholds in this bound.', ); return; }