diff --git a/docs/BUREAUCRAT_PAYMENTS.md b/docs/BUREAUCRAT_PAYMENTS.md index 1a71737e..23820068 100644 --- a/docs/BUREAUCRAT_PAYMENTS.md +++ b/docs/BUREAUCRAT_PAYMENTS.md @@ -33,12 +33,13 @@ A verbal promise alone transfers no spice. ## Connected boundary and explicit interpretations -The connected paths are ordinary paid auctions, Guild shipment income, direct -Richese auction proceeds and actual bribe transfers in the base or CHOAM-only -skill profile. Basic Tleilaxu with Leader Skills and base opponents additionally -supports a single payer's actual paid revival price to Tleilaxu. Other expansion -payment sources remain outside this batch. Emperor gifts are excluded because -the user ruled on 11 October that gifts are not payments. +The connected sources are ordinary paid auctions, native Emperor/Guild and +Richese seller receipts, actual bribe transfers and the original paid Tleilaxu +revival price in admitted Leader Skills compositions. A separate free-revival +award is not part of that price. Registered all14 setups use the same receipt +capability for single and allied payers; optional-module and Advanced role +admission remains separate. Emperor gifts are excluded because the user ruled +on 11 October that gifts are not payments. ### User ruling — 11 October 2026 @@ -93,10 +94,12 @@ Allied funding is evaluated per actual payer, not by adding different payers' shares to create the five-spice trigger. A three-plus-three payment, or four-plus-three, has no eligible payer and proceeds through original funding, payment, card/force commitment and recipient income without a Bureaucrat offer. -In supported classic, E1, E2 and E3 profiles without Homeworlds, individually -qualifying allied-funded auction and shipment receipts now offer one optional -redirection: -five-plus-three, three-plus-five, five-plus-five and fully ally-funded five. +In admitted classic and native E1/E2/E3 Skills compositions, individually +qualifying allied-funded auction and shipment receipts offer one optional +redirection: five-plus-three, three-plus-five, five-plus-five and fully +ally-funded five. This does not require incidental Nexus Cards or one exact +selected family. Original World and phase-module receipts retain their native +allocation and callbacks. The receipt loses exactly two, never two per contributing payer. A share paid by the trainer or recipient cannot qualify, but does not mask a different eligible payer. Homeworld allied and split-recipient income is connected under @@ -448,3 +451,55 @@ unchanged, and only the redirection consumes the skill. This is explicitly stage UI evidence. After the reported outage, all 699 opening rooms and their original seat/recovery records matched the private backup; database integrity and foreign keys passed. The healthy server was reused. + +## Registered native allied receipts — 18 October 2026 + +The ordinary Skills receipt capability now governs both single and allied +funding. The separate allied-only classifier is removed, not aliased. Registered +selected-family all14 tables no longer need incidental Nexus Cards or one exact +family to use an otherwise supported auction or shipment receipt. Original +World/phase allocation, module admission and Advanced role limits stay separate. +This supersedes the narrower **dated 13 October scope bounds** above, not the +printed card, explicit user rulings or original economic callbacks. + +Before repair, a genuine Basic Ecaz/Moritani/Guild/Atreides/Emperor plain-Skills +table formed a real turn-two worm alliance. Moritani pledged five from twelve; +Ecaz's five-force nonstronghold shipment cost ten, paid five plus that escrow. +The extra allied guard rejected it byte-identically. After repair, Ecaz and +Moritani retain seven each, five forces arrive once, Ecaz reserves are nine, +and the escrow is consumed once. Redirecting gives Guild eight, from five to +thirteen; the Atreides trainer gains nothing and original Movement resumes. + +The shared individual-payer threshold, trainer/payee exclusions, private +contribution legs, two-spice Bank diversion and once-per-phase use are unchanged. +Native income cancellation still resolves first and creates no Bureaucrat +offer/use. Declining keeps the later phase opportunity. No revival price, +free-revival award, Banker invoice, gift classification or occupied split changes. + +The new E1 plain producer retains actual starting armies before funding: +HMS six, Carthag ten, Arrakeen ten and Tuek's five. Its older Nexus battle +producer's conserved board-return staging is retained only in that legacy path; +it is not claimed as natural native movement. A real Advanced no-Nexus shipment +still opens native Guild interception. An eight-force shipment paid three plus +Ixian escrow five leaves Harkonnen nine spice/two reserves with Carthag ten +untouched, and Ixians five spice/HMS six untouched. All four legal policies +redirect the eligible receipt, give Guild six (six to twelve), and resume the +next real movement seat. + +Three actual390px GameTable flows use isolated memory-only original producers: +Basic cost-ten/five-force shipment passes native Karama allowance and redirects +two, then reaches Collection; Advanced extra-selected-CHOAM auction three-plus-five +allows the full eight (Emperor ten to eighteen, no use, original next lot at +Guild); a fresh original counter branch cancels that income (Emperor stays ten, +no offer/use, original next lot, original Karama consumed). Prompts show gross, +recipient and payer-null without contribution legs. Browser errors and horizontal +overflow are absent. Temporary routes were removed and their memory released204; +no stored room, dev-server restart, deployment or assurance campaign is involved. + +Frozen17-file **442/442**, focused original nonstronghold1/1 and parent-owned +types/lint/build pass. New consumer boundaries live in +`tests/bureaucrat-e1-funding.test.ts` and `tests/bureaucrat-e3-funding.test.ts`; +default Nexus, classic/World/occupied/Betrayal and original phase payment +consumers remain in that union. Earlier fixture/helper/profile-only pins are +deleted rather than repinned. This is scoped local rule/control proof, not +complete Skills, module-combination, save/recovery or deployed acceptance. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f61cd018..a61852d8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,28 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native allied Bureaucrat receipts — 18 October. Prototyped / Partial.** +An original no-Nexus Eca/Mori all14 five-own/five-allied shipment rejected +unchanged before repair. Single and allied receipts now share the existing +ordinary Skills capability; the extra classifier is removed without an alias. +Registered selected families need no incidental Nexus or exact single family. +Individual thresholds, private shares, full costs/escrow, native cancellation, +Bank diversion and module/Advanced role admission remain. + +Real Basic cost10/five-force shipping leaves Eca/Mori seven each, Guild13, +Atreides10 and consumed escrow, then resumes Movement/Collection. New E1 plain +funding retains all native starting armies. Actual Advanced no-Nexus Guild +interception and all four policies settle an original3+5 receipt, preserve +Carthag10/HMS6 and continue to Ixian movement. + +Three actual390px flows cover native allowance→redirect, Advanced unused-CHOAM +auction allowance→full income/next lot and native cancellation→no offer/use. +No browser errors/overflow. Temporary memory-only routes were removed/released; +no stored room or server restart. Frozen17-file **442/442**, focused1/1, +types/lint/build pass; existing build warnings remain. No new source/ruling, +complete-mode, deployed, calibration or deferred-assurance claim. +[Current contract and evidence](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026). + **Registered native E3 selected decks — 18 October. Prototyped / Partial.** Sixteen ordinary plain/card all14 starts rejected before; all16 now reach native setup. Registered seats simply require their selected ordinary family; @@ -1509,9 +1531,9 @@ types/lint and build pass. No deployed, save/recovery or full-mode claim follows **Bureaucrat / Homeworld allied income and two 13 October rulings — 13 October.** Homeworlds + Leader Skills games now connect qualifying allied-funded ordinary -auction and Guild-shipment income (`bureaucratAlliedFundingModeSupported` returns -`homeworldLeaderSkillsProfile` for Homeworlds). Genuine Basic/Advanced classic -Homeworld Skills games train Bureaucrat on Atreides and seat native Guild; +auction and Guild-shipment income through the admitted Homeworld Skills +capability. Genuine Basic/Advanced classic Homeworld Skills games train +Bureaucrat on Atreides and seat native Guild; 53 consumer cases cover 5+3/3+5/5+5/fully donor-funded five, decline/redirect, sub-five nonpooling, owner/payee exclusion without masking an eligible payer, private gross-only invoices with payer null, stale/cross-actor rejection, four diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 23bae27f..e00f89b4 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 18 October 2026 — Registered native allied Bureaucrat receipts + +A genuine Basic no-Nexus Eca/Mori all14 table, real worm alliance and original +Mori pledge5 reproduced an unchanged rejection of the cost10/five-force shipment. +The extra allied-only classifier is deleted; all engine guard/stamp/validation +callers use the existing ordinary receipt capability, with no alias. Obsolete +private guard arguments and profile-only tests are removed, not re-pinned. +Thresholds, private contribution legs, original native costs/callbacks, Bank2, +once-per-phase use and module/Advanced role admission stay. + +Default-preserving E1/E3 original producers add no-Nexus normal starts and an +unused selected CHOAM deck. New plain E1 funding preserves native starting +armies rather than inheriting the older Nexus battle producer's board-return +staging. Actual Advanced native Guild interception still precedes the receipt. +Four legal policies preserve Carthag10/HMS6, settle3+5 once and resume Ixian +movement. No physical source, income award or phase is invented. + +Frozen17-file **442/442**, focused original nonstronghold1/1, types/lint/build and +actual controllers pass. Three390px original GameTable flows prove native +allowance→Bank2/Collection, full Advanced extra-deck auction income→next lot, +and original Karama cancellation→no offer/use with one acquired card. Errors +and overflow are absent. Temporary routes/memory are removed/released204; +existing games and server are untouched. Existing build warnings remain. +No new source ruling, full-mode, deployed, calibration or assurance claim. +[Current scope/evidence](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026). + ## 18 October 2026 — Registered native E3 selected-deck Skills Sixteen normal mixed-native/unused-deck/plain/card starts reject before. @@ -3078,8 +3104,8 @@ receives the fee minus two and exactly two goes to the Bank. Neither printed car addresses the other, so this is an explicit ruling rather than a source derivation. A new bounded composition implements it: `guildBetrayalNexusLeaderSkillsProfile` (classic Nexus plus Leader Skills plus -the Betrayal preview) is admitted by `bureaucratPaymentModeSupported`, -`bureaucratAlliedFundingModeSupported` and `guildBetrayalModeSupported`, and +the Betrayal preview) is admitted by the ordinary Bureaucrat receipt capability +and `guildBetrayalModeSupported`, and `initializeGuildBetrayalLeaderSkillsGameForAudit` deals the composition from a fresh classic native-Guild lobby. No other Betrayal or preview composition is admitted, and existing no-skills Betrayal behavior is unchanged. @@ -3122,9 +3148,9 @@ save/deployed or full-mode claim is opened by this bounded proof. ## 13 October 2026 — Homeworld allied income and two Bureaucrat rulings -`bureaucratAlliedFundingModeSupported` now returns `homeworldLeaderSkillsProfile` -for Homeworlds games, so qualifying allied-funded ordinary auction and -Guild-shipment income in Homeworlds + Leader Skills compositions offer the +The Homeworld Skills capability now connects qualifying allied-funded +ordinary auction and Guild-shipment income in Homeworlds + Leader Skills +compositions to the one-time two-spice redirection. Nothing broader opened: multi-expansion, preview overlays and the occupation/Betrayal previews remain excluded. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index f4d018a2..52123f5e 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -7,6 +7,22 @@ and fixture opt-ins are removed; existing games are not retrofitted. Earlier dated references to a separate entry describe historical checkpoints. See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). +## Registered native allied Bureaucrat receipts — 18 October 2026 + +Single and actual allied receipts now share the ordinary Skills capability. +Registered all14 selected-family tables need no incidental Nexus Cards or exact +single family for otherwise supported auction/shipment funding. Original +module/Advanced role admission, individual payer thresholds, private shares, +native income cancellation, full costs and Bank2 remain. No new ruling or +played-save conversion occurs. + +Real no-Nexus E1/E3 normal starts, actual Advanced Guild interception and an +unused selected CHOAM deck retain original armies, escrow and continuation. +Frozen17-file442/442, types/lint/build and three actual390px native controls +pass. All four legal policies settle the genuine Advanced E1 receipt without +changing Carthag10/HMS6. Full-mode/AI calibration and deferred assurance remain. +[Source, exact receipts and limits](BUREAUCRAT_PAYMENTS.md#registered-native-allied-receipts--18-october-2026). + ## Registered native E3 selected decks — 18 October 2026 Fresh all14 now validates every registered actual seat against its selected diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 4b139c98..dd247f94 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -50,13 +50,6 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea (game.expansions.length === 1 && game.expansions[0] === 'choam') || nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); } -export function bureaucratAlliedFundingModeSupported(game: LeaderSkillProfile): boolean { - if (!bureaucratPaymentModeSupported(game)) return false; - if (game.homeworlds) return homeworldLeaderSkillsProfile(game); - if (game.expansions.length === 1 && (game.expansions[0] === 'ecaz' || game.expansions[0] === 'ix')) - return pairedNexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game); - return !game.expansions.length || game.expansions.length === 1 && game.expansions[0] === 'choam'; -} export function bureaucratUsed( used: readonly BureaucratPaymentUse[], turn: number, diff --git a/game/engine.ts b/game/engine.ts index 4899a24d..008fcbec 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7,7 +7,7 @@ import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsu import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { leaderSkillCard } from './leader-skill-cards'; -import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment'; +import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment'; import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question'; import { usesPlanetologistBattleSpecial, planetologistSpecialNeedsChoice, @@ -9079,15 +9079,13 @@ function bureaucratPaymentQuote(g: Game,source: Pick share.amount > 0); const owner = nativeBureaucrat(g)?.owner; if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) || !paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return; requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.'); - requireRule(paid.length === 1 && !privateFunding || bureaucratAlliedFundingModeSupported(g), - 'Bureaucrat on allied funding or multiple payer contributions in this module composition is awaiting integration.'); } function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number, contributions?:BureaucratPaymentSource['contributions']): @@ -9103,7 +9101,7 @@ function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:stri const paid = shares.filter(share => share.amount > 0); if (!privateFunding && paid.length === 1) return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount); - if (!bureaucratAlliedFundingModeSupported(g)) return {}; + if (!bureaucratPaymentModeSupported(g)) return {}; const owner = nativeBureaucrat(g)?.owner; const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee); return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee, @@ -9125,7 +9123,7 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) { Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source), 'The saved Bureaucrat payment changed its actual payer, payee, amount or event.'); if (source.contributions) { - requireRule(bureaucratAlliedFundingModeSupported(g) && source.privateFunding === true && + requireRule(bureaucratPaymentModeSupported(g) && source.privateFunding === true && (source.kind === 'auction' || source.kind === 'shipment') && source.contributions.length >= 1 && source.contributions.length <= 2 && source.contributions.every(share => g.players.some(player => player.id === share.payer)) && @@ -17854,7 +17852,7 @@ function checkShipmentIncomeFunding(g: Game, p: Player, cost: number, allyPaymen if (!guild) return; const amounts = shipmentIncomeContributions(g, p, cost, allyPayment); checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment}, - ...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0); + ...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])]); validateGuildPaymentContributions(g, amounts.reduce((sum, amount) => sum + amount, 0), amounts); } function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string, @@ -32834,7 +32832,7 @@ function applyActionInner( validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable); if (amount !== null) { const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner); - checkBureaucratContributors(g,parties.payee,parties.shares,allyPayment > 0); + checkBureaucratContributors(g,parties.payee,parties.shares); } g.richeseAuction = submitRicheseBid( lot, @@ -32888,7 +32886,7 @@ function applyActionInner( ? contribution(g, p, a.bid, action.allyPayment) : 0; const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment); - checkBureaucratContributors(g,parties.payee,parties.shares,a.allyPayment > 0); + checkBureaucratContributors(g,parties.payee,parties.shares); a.bidder = id; a.passed = []; } else if (!a.passed.includes(id)) a.passed.push(id); diff --git a/game/reference.ts b/game/reference.ts index e45cf9b8..bb4b7a03 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4956,6 +4956,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way/Chemistry, or sealed/actually late-played Portable Snooper. Named protection stays distinct; copying the used role imports no activation, numeric modifier or late-play right. Sealed sources offer after reveal; a new late source may offer before the unresolved outcome, preserving prior false traitor decisions. No fresh opportunity follows a successful traitor declaration. The selected Worthless discards even after a win. Older base/printed-only battles keep their original source windows. Current World/Stronghold contexts retain independent original effects; own earned Carthag may add Snooper to a copied Shield but cannot create protection without a used source. A native Diplomat can protect its selected disc; captives grant no normal band. Surviving trained losers retain the separate printed/copied-strength retreat policy. Mirror/numeric inheritance remains separate.', 'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.', + 'Registered all14 tables use one ordinary Bureaucrat receipt capability for both single and allied funding. Selected native E1/E2/E3 decks, original Worlds and supported phase modules need no incidental Nexus Cards or exact single-family selection. A native worm alliance is not a Nexus Card. Module and Advanced role admission remain separate; full combined-mode acceptance is unfinished.', 'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.', 'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.', 'Physical Fremen Ambassador relocation offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement retain their own limits. Normal Banker income is mandatory in fresh supported ordinary Skills setups, with provisional original-earned-faction custody and no retrofit. Diplomat retreat retains its conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz coalition skills follow their bounded contracts; shared-Duke assignment, exceptional roles and other combined modules remain unfinished. Normal module starts are labelled previews, not complete-mode acceptance.', diff --git a/tests/bureaucrat-betrayal-funding.test.ts b/tests/bureaucrat-betrayal-funding.test.ts index 9d105afb..6dbe37be 100644 --- a/tests/bureaucrat-betrayal-funding.test.ts +++ b/tests/bureaucrat-betrayal-funding.test.ts @@ -3,11 +3,8 @@ import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; -import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature } from '../game/bureaucrat-payment'; -import { guildBetrayalNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; -import { validateLeaderSkills } from '../game/leader-skills'; +import { bureaucratPaymentSignature } from '../game/bureaucrat-payment'; import { validateNexusCards } from '../game/nexus-cards'; -import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal'; import { betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture, @@ -80,21 +77,6 @@ function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted: rejectUnchanged(ended, f.shipper, { type: 'endMovement' }); } -void test('Fresh native Guild classic setup genuinely trains its distinct Bureaucrat and preserves all14 custody', () => { - const f = createBureaucratBetrayalFundingFixture({ donorAmount: 3 }); - assert.equal(f.initial.status, 'setup'); - assert.equal(f.initial.leaderSkills!.assignments.length, 0); - assert.equal(f.trained.status, 'setup'); - assert.equal(f.trained.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, f.trainer); - assert.ok(![f.shipper, f.holder, f.guild, f.donor].includes(f.trainer)); - validateLeaderSkills(f.trained.leaderSkills!, f.trained.players); - const skills = f.trained.leaderSkills!; - assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), - LEADER_SKILL_CARDS.map(c => c.id).sort()); - assert.equal(guildBetrayalNexusLeaderSkillsProfile(f.game), true); - assert.equal(bureaucratPaymentModeSupported(f.game), true); - assert.equal(bureaucratAlliedFundingModeSupported(f.game), true); -}); for (const advanced of [false, true]) { for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) { diff --git a/tests/bureaucrat-e1-funding.test.ts b/tests/bureaucrat-e1-funding.test.ts index 44d78f1d..9e1f609e 100644 --- a/tests/bureaucrat-e1-funding.test.ts +++ b/tests/bureaucrat-e1-funding.test.ts @@ -4,7 +4,6 @@ import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { territory } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; -import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported } from '../game/bureaucrat-payment'; import { advancePairedIxNexusSkills } from './fixture-paired-ix-nexus-skills'; import { allowE1Responses, bureaucratE1PhysicalCards, chooseBureaucratE1Payment, @@ -67,21 +66,6 @@ function assertShipmentCosts(f: BureaucratE1ShipmentFixture, game: Game): void { assert.deepEqual(bureaucratE1PhysicalCards(game), f.physical); } -void test('paired E1 no-Homeworlds nexus skills admit both Bureaucrat mode gates and nothing broader', () => { - const base = createBureaucratE1FundingBase({}); - assert.deepEqual(base.game.expansions, ['ix']); - assert.equal(base.game.homeworlds, undefined); - assert.equal(bureaucratPaymentModeSupported(base.game), true); - assert.equal(bureaucratAlliedFundingModeSupported(base.game), true); - const single = structuredClone(base.game); - single.players = single.players.filter(p => p.id !== 'tleilaxu'); - assert.equal(bureaucratPaymentModeSupported(single), false); - const multi = structuredClone(base.game); - multi.expansions = ['ix', 'choam']; - assert.equal(bureaucratPaymentModeSupported(multi), false); - assert.equal(bureaucratAlliedFundingModeSupported(multi), false); -}); - for (const advanced of [false, true]) for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) for (const redirect of [false, true]) { @@ -401,3 +385,62 @@ void test('paired E1 Nexus Guild route: Guild Cunning is the Guild\'s own card a assert.deepEqual(seat(paid, base.guild).forces, { ...seat(before, base.guild).forces, [`${target}:${sector}`]: 5 }); }); + +// --------------------------------------------------------------------------- +// The same genuine paired E1 roster selected without its Nexus Cards module: +// no Nexus state is fabricated, and the real turn-two worm window and native +// alliance remain the only route to an allied auction/shipment receipt. +// --------------------------------------------------------------------------- + + +for (const redirect of [false, true]) { + void test(`Basic no-Nexus paired E1 allied auction 5+3: ${redirect ? 'Bank two' : 'full Emperor income'} once, one card of record and the original next lot`, () => { + const f = createBureaucratE1AuctionFixture({ nexus: false, payerShare: 5, donorShare: 3 }); + assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorShare); + assert.equal(seat(f.beforeAction, f.payer).spice, seat(f.beforePledge, f.payer).spice); + const paid = payBureaucratE1Auction(f); + const pending = allowE1Responses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.bureaucratPayments?.pending?.source.payee, f.emperor); + assert.equal(pending.bureaucratPayments?.pending?.source.payer, f.payer); + assert.equal(pending.bureaucratPayments?.pending?.source.amount, f.amount); + const done = allowE1Responses(chooseBureaucratE1Payment(pending, redirect)); + assertAuctionOutcome(f, done, redirect ? 2 : 0); + assert.equal(done.bureaucratPayments?.pending, undefined); + }); + + void test(`Basic no-Nexus paired E1 allied shipment 5+3: ${redirect ? 'Bank two' : 'full Guild income'} once, one army commitment and real Movement continuation`, () => { + const f = createBureaucratE1ShipmentFixture({ nexus: false, primary: 5, pledge: 3 }); + const { game: paid } = declareBureaucratE1Shipment(f); + const pending = allowE1Responses(paid); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.bureaucratPayments?.pending?.source.payee, f.guild); + assert.equal(pending.bureaucratPayments?.pending?.source.payer, f.payer); + assert.equal(pending.bureaucratPayments?.pending?.source.amount, f.amount); + const done = allowE1Responses(chooseBureaucratE1Payment(pending, redirect)); + assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + f.amount - (redirect ? 2 : 0)); + assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0); + assertShipmentCosts(f, done); + assert.equal(done.phase, 5); + assert.equal(done.active, f.payer); + const ended = applyAction(structuredClone(done), f.payer, { type: 'endMovement' }); + assert.notEqual(ended.active, f.payer); + assert.deepEqual(counters(ended), counters(done)); + }); +} + +void test('Basic no-Nexus paired E1 receipts never pool two sub-five shares and never mint a card', () => { + const auction = createBureaucratE1AuctionFixture({ nexus: false, payerShare: 3, donorShare: 3 }); + const auctionDone = allowE1Responses(payBureaucratE1Auction(auction)); + assert.notEqual(auctionDone.decision?.kind, 'bureaucratPayment'); + assert.equal(auctionDone.bureaucratPayments?.pending, undefined); + assert.equal(auctionDone.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(seat(auctionDone, auction.emperor).spice, seat(auction.beforePledge, auction.emperor).spice + 6); + assertAuctionOutcome(auction, auctionDone, 0); + const shipment = createBureaucratE1ShipmentFixture({ nexus: false, primary: 3, pledge: 3 }); + const shipmentDone = allowE1Responses(declareBureaucratE1Shipment(shipment).paid); + assert.notEqual(shipmentDone.decision?.kind, 'bureaucratPayment'); + assert.equal(shipmentDone.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(seat(shipmentDone, shipment.guild).spice, seat(shipment.beforePledge, shipment.guild).spice + 6); + assertShipmentCosts(shipment, shipmentDone); +}); diff --git a/tests/bureaucrat-e3-funding.test.ts b/tests/bureaucrat-e3-funding.test.ts index 4fcc73fb..e118d340 100644 --- a/tests/bureaucrat-e3-funding.test.ts +++ b/tests/bureaucrat-e3-funding.test.ts @@ -3,7 +3,8 @@ import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; -import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean } from './fixture-paired-e3-nexus-modules'; +import { advancePairedE3Nexus, quietPairedE3NexusChoice, pairedE3NexusClean, + pairedE3NexusInventory } from './fixture-paired-e3-nexus-modules'; import { allowE3Responses, bureaucratE3PhysicalCards, chooseBureaucratE3Payment, createBureaucratE3AuctionFixture, createBureaucratE3ShipmentFixture, @@ -304,3 +305,112 @@ for (const advanced of [false, true]) { } }); } + +void test('Basic no-Nexus E3 allied nonstronghold shipment pays ten for five physical forces; native cancellation precedes one Bank diversion', () => { + const f = createBureaucratE3ShipmentFixture({ + nexus: false, primary: 5, pledge: 5, target: 'habbanya_erg', counter: true, + }); + assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - 5); + assert.equal(f.beforeAction.aid[f.donor].amount, 5); + assert.deepEqual(counters(f.beforeAction), counters(f.beforePledge)); + const { paid } = declareBureaucratE3Shipment(f); + assert.equal(paid.response?.kind, 'guildIncome'); + assert.equal(paid.response?.amount, 10); + assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true); + assertShipmentCosts(f, paid); + assert.equal(seat(paid, f.payer).forces[`${f.target}:17`], 5); + const canceled = allowE3Responses(applyAction(structuredClone(paid), f.trainer, + { type: 'card', card: f.counterCard!, mode: 'cancel' })); + assert.equal(seat(canceled, f.guild).spice, seat(f.beforePledge, f.guild).spice); + assert.equal(canceled.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(viewGame(canceled, f.trainer).bureaucrat.pending, null); + assert.notEqual(canceled.decision?.kind, 'bureaucratPayment'); + assert.equal(canceled.discard.filter((card) => card.id === f.counterCard).length, 1); + assertShipmentCosts(f, canceled); + assert.equal(canceled.phase, 5); + assert.equal(canceled.active, f.payer); + + const pending = allowE3Responses(structuredClone(paid)); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + assert.equal(pending.decision?.player, f.trainer); + for (const viewer of [f.trainer, f.emperor]) { + const invoice = viewGame(pending, viewer).bureaucrat.pending!; + assert.equal(invoice.payer, null); + assert.equal(invoice.amount, 10); + assert.equal(invoice.payee, f.guild); + assert.equal('contributions' in invoice, false); + assert.equal('signature' in invoice, false); + } + const done = allowE3Responses(chooseBureaucratE3Payment(pending)); + assertShipmentCosts(f, done); + assert.equal(seat(done, f.guild).spice, seat(f.beforePledge, f.guild).spice + 8); + assert.equal(done.bureaucratPayments?.used.length, 1); + assert.deepEqual(hand(done, f.trainer), hand(f.beforeAction, f.trainer)); + assert.equal(done.decision, null); + assert.equal(done.phase, 5); + assert.equal(done.active, f.payer); + pairedE3NexusInventory(done); + const moved = applyAction(structuredClone(done), f.payer, { type: 'endMovement' }); + assert.equal(moved.active, null); + assert.equal(moved.phase, 7); + assert.equal(moved.decision, null); + assert.deepEqual(counters(moved), counters(done)); +}); + +void test('Advanced no-Nexus E3 auction with an unused selected CHOAM deck qualifies the donor alone and resumes the original lot after native allowance', () => { + const f = createBureaucratE3AuctionFixture({ + advanced: true, nexus: false, families: ['choam'], payerShare: 3, donorShare: 5, counter: true, + }); + assert.equal(f.worm.nexus, true); + assert.ok(f.worm.players.every((p) => p.ally === null)); + assert.equal(seat(f.alliance, f.payer).ally, f.donor); + assert.equal(seat(f.alliance, f.donor).ally, f.payer); + assert.equal(seat(f.beforeAction, f.donor).spice, seat(f.beforePledge, f.donor).spice - 5); + assert.equal(f.beforeAction.aid[f.donor].amount, 5); + const paid = payBureaucratE3Auction(f); + assert.equal(paid.response?.kind, 'emperorIncome'); + assert.equal(viewGame(paid, f.trainer).responseControls?.cancelCards.includes(f.counterCard!), true); + assertAuctionCosts(f, paid); + const canceled = allowE3Responses(applyAction(structuredClone(paid), f.trainer, + { type: 'card', card: f.counterCard!, mode: 'cancel' })); + assert.equal(seat(canceled, f.emperor).spice, seat(f.beforePledge, f.emperor).spice); + assert.equal(canceled.bureaucratPayments?.used.length ?? 0, 0); + assert.equal(viewGame(canceled, f.trainer).bureaucrat.pending, null); + assert.notEqual(canceled.decision?.kind, 'bureaucratPayment'); + assert.equal(canceled.discard.filter((card) => card.id === f.counterCard).length, 1); + assertAuctionCosts(f, canceled); + assert.deepEqual(hand(canceled, f.payer), [...hand(f.beforeAction, f.payer), f.card]); + assert.equal(canceled.currentAuctionSale, null); + assert.equal(canceled.auction!.index, f.beforeAction.auction!.index + 1); + + const pending = allowE3Responses(structuredClone(paid)); + assert.equal(pending.decision?.kind, 'bureaucratPayment'); + for (const viewer of [f.trainer, f.guild]) { + const invoice = viewGame(pending, viewer).bureaucrat.pending!; + assert.equal(invoice.payer, null); + assert.equal(invoice.amount, 8); + assert.equal(invoice.payee, f.emperor); + assert.equal('contributions' in invoice, false); + assert.equal('signature' in invoice, false); + } + const done = allowE3Responses(chooseBureaucratE3Payment(pending)); + assertAuctionOutcome(f, done, 2); + assert.deepEqual(counters(done), counters(f.beforeAction)); + assert.deepEqual(hand(done, f.trainer), hand(f.beforeAction, f.trainer)); + assert.equal(done.auction!.active, canceled.auction!.active); + assert.equal(done.auction!.cards[done.auction!.index].id, canceled.auction!.cards[canceled.auction!.index].id); + pairedE3NexusInventory(done); +}); + +void test('No-Nexus E3 actual auction shares neither pool sub-five amounts nor qualify the trainer or native payee', () => { + for (const [donor, payerShare, donorShare] of [ + ['moritani', 4, 4], [ALLIANCE.trainer, 3, 5], [ALLIANCE.auctionPayee, 3, 5], + ] as const) { + const f = createBureaucratE3AuctionFixture({ nexus: false, donor, payerShare, donorShare }); + const done = allowE3Responses(payBureaucratE3Auction(f)); + assert.notEqual(done.decision?.kind, 'bureaucratPayment'); + assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null); + assertAuctionOutcome(f, done, 0); + assert.deepEqual(counters(done), counters(f.beforeAction)); + } +}); diff --git a/tests/bureaucrat-homeworld-funding.test.ts b/tests/bureaucrat-homeworld-funding.test.ts index 90864062..2b93f304 100644 --- a/tests/bureaucrat-homeworld-funding.test.ts +++ b/tests/bureaucrat-homeworld-funding.test.ts @@ -3,7 +3,6 @@ import test from 'node:test'; import { applyAction, initializeHomeworldOccupationGameForAudit, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; -import { bureaucratAlliedFundingModeSupported } from '../game/bureaucrat-payment'; import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills'; import { advanceHomeworldFunding, allowHomeworldFundingResponses as allow, chooseHomeworldFunding as choose, @@ -60,7 +59,6 @@ for (const advanced of [false, true]) for (const kind of ['auction', 'shipment'] for (const [payerShare, donorShare] of [[5, 3], [3, 5], [5, 5], [0, 5]]) for (const redirect of [false, true]) { void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld ${kind} ${payerShare}+${donorShare} ${redirect ? 'redirects two' : 'declines'} with full debits and original continuation`, () => { const f = fixture({ kind, advanced, payerShare, donorShare }); - assert.equal(bureaucratAlliedFundingModeSupported(f.game), true); assert.equal(player(f.alliance, f.payer).ally, f.donor); assert.equal(player(f.alliance, f.donor).ally, f.payer); assert.equal(player(f.beforeAction, f.donor).spice, player(f.beforePledge, f.donor).spice - donorShare); diff --git a/tests/fixture-bureaucrat-e1-funding.ts b/tests/fixture-bureaucrat-e1-funding.ts index bf9d08b1..cefb02a9 100644 --- a/tests/fixture-bureaucrat-e1-funding.ts +++ b/tests/fixture-bureaucrat-e1-funding.ts @@ -9,8 +9,9 @@ import { territory } from '../game/board'; import { advancePairedIxNexusSkills, createPairedIxNexusSkillsFixture, finishPairedIxNexusSkillsBattle, pairedIxNexusSkillsPlayer, - revealPairedIxNexusSkillsBattle, type PairedIxNexusSkillsFixture, + revealPairedIxNexusSkillsBattle, type PairedIxSkillsFixture, } from './fixture-paired-ix-nexus-skills'; +import { skillOfferRolls } from './fixture-independent-card-modules'; export type BureaucratE1FundingStep = { actor: string; action: Action }; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; @@ -25,8 +26,9 @@ export const BUREAUCRAT_E1_REVIVAL_ROSTER = ['ixians', 'tleilaxu', 'atreides', ' export const BUREAUCRAT_E1_REVIVAL_TRAINER = 'harkonnen'; /** Original 47/14/12 setup → real Storm → real Mentat → conserved worm → - * real alliance → real closing draws. Only the native all14 shuffle is scoped; - * wallets, phases, assignments and armies are never assigned. */ + * real alliance and, unless the Nexus Cards module is omitted, its real closing + * native draws. Only the native all14 shuffle is scoped; wallets, phases, + * assignments and armies are never assigned. */ function entropy(operation: () => T, rolls: number[] = []): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); @@ -60,33 +62,39 @@ function skillRolls(selected: LeaderSkillId[]): number[] { return shuffleRolls(source, [...desired, ...remainder]); } /** Only the native all14 shuffle order is scoped, so the named classic seat can - * genuinely select the Bureaucrat card from its own original offer. */ + * genuinely select the Bureaucrat card from its own original offer. The default + * selects the original Nexus Cards module; `nexus: false` instead selects the + * ordinary Leader Skills module in the real lobby and starts it normally. */ function pairedE1Initial( code: string, factions: readonly string[], trainer: string, skills: Record, advanced: boolean, + nexus: boolean, ): Game { let game = createGame(code, newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction as FactionId)); - game.nexusCards ??= { cards: null, phase: null }; + if (nexus) game.nexusCards ??= { cards: null, phase: null }; + else game = applyAction(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); assert.ok(factions.includes(trainer) && skills[trainer] === 'bureaucrat' && skills.ixians === 'suk-graduate'); const selected = game.players.map(p => skills[p.faction]); - return entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)); + return nexus ? entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)) + : entropy(() => applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }), + skillOfferRolls(game, skills)); } /** Build the shared paired E1 fixture with the native skills requested. The * shared helper assigns each seat the first card of its genuine offer. */ -function pairedE1Skills(factions: readonly string[], trainer: string, advanced: boolean): PairedIxNexusSkillsFixture { +function pairedE1Skills(factions: readonly string[], trainer: string, advanced: boolean, nexus = true): PairedIxSkillsFixture { const skills: Record = { ixians: 'suk-graduate', tleilaxu: 'sandmaster', guild: 'smuggler', emperor: 'rihani-decipherer', atreides: 'prana-bindu-adept', harkonnen: 'killer-medic', [trainer]: 'bureaucrat', }; const fixture = createPairedIxNexusSkillsFixture({ - initial: pairedE1Initial(`BUREAUCRATE1${factions.length}${advanced ? 'A' : 'B'}`, factions, trainer, skills, advanced), - advanced, skill: 'suk-graduate', + initial: pairedE1Initial(`BUREAUCRATE1${factions.length}${advanced ? 'A' : 'B'}`, factions, trainer, skills, advanced, nexus), + advanced, nexus, skill: 'suk-graduate', }); assert.equal(fixture.afterSetup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, trainer); return fixture; @@ -107,9 +115,9 @@ export function chooseBureaucratE1Payment(game: Game, redirect = true): Game { } export interface BureaucratE1FundingBase { - fixture: PairedIxNexusSkillsFixture; + fixture: PairedIxSkillsFixture; actions: BureaucratE1FundingStep[]; - /** Turn-2 Nexus window before the requested real alliance forms. */ + /** Turn-2 worm spice-blow window before the requested real alliance forms. */ worm: Game; /** Genuine mutual alliance between the payer and the requested donor. */ alliance: Game; @@ -123,14 +131,15 @@ export interface BureaucratE1FundingBase { guild: string; emperor: string; } -/** Genuine paired E1 setup with skills, a real worm Nexus and a real mutual - * alliance between the payer and the requested donor. Never assigns wallets, - * armies, phase, assignment or alliance state. */ +/** Genuine paired E1 setup with skills, the real turn-two worm window and a real + * mutual alliance between the payer and the requested donor; the original Nexus + * Cards module is selected by default and omitted by `nexus: false`. Never + * assigns wallets, armies, phase, assignment or alliance state. */ export function createBureaucratE1FundingBase({ - advanced = false, payer = 'harkonnen', donor = 'ixians', -}: { advanced?: boolean; payer?: string; donor?: string } = {}): BureaucratE1FundingBase { + advanced = false, nexus = true, payer = 'harkonnen', donor = 'ixians', +}: { advanced?: boolean; nexus?: boolean; payer?: string; donor?: string } = {}): BureaucratE1FundingBase { assert.notEqual(payer, donor); - const fixture = pairedE1Skills(BUREAUCRAT_E1_ROSTER, BUREAUCRAT_E1_TRAINER, advanced); + const fixture = pairedE1Skills(BUREAUCRAT_E1_ROSTER, BUREAUCRAT_E1_TRAINER, advanced, nexus); const actions: BureaucratE1FundingStep[] = []; let game = structuredClone(fixture.beforeAlliance); assert.equal(game.turn, 2); assert.equal(game.phase, 1); @@ -178,9 +187,9 @@ export function bureaucratE1PhysicalCards(game: Game): string[] { } export function createBureaucratE1AuctionFixture({ - advanced = false, payerShare = 5, donorShare = 3, donor = 'ixians', -}: { advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string } = {}): BureaucratE1AuctionFixture { - const base = createBureaucratE1FundingBase({ advanced, donor }); + advanced = false, nexus = true, payerShare = 5, donorShare = 3, donor = 'ixians', +}: { advanced?: boolean; nexus?: boolean; payerShare?: number; donorShare?: number; donor?: string } = {}): BureaucratE1AuctionFixture { + const base = createBureaucratE1FundingBase({ advanced, nexus, donor }); const actions = [...base.actions]; let game = structuredClone(base.game); for (let n = 0; game.auction!.active !== base.payer && n < game.players.length + 2; n++) { @@ -239,9 +248,9 @@ export interface BureaucratE1ShipmentFixture extends BureaucratE1FundingBase { } export function createBureaucratE1ShipmentFixture({ - advanced = false, primary = 5, donor = 'ixians', pledge = 3, -}: { advanced?: boolean; primary?: number; donor?: string; pledge?: number } = {}): BureaucratE1ShipmentFixture { - const base = createBureaucratE1FundingBase({ advanced, donor }); + advanced = false, nexus = true, primary = 5, donor = 'ixians', pledge = 3, +}: { advanced?: boolean; nexus?: boolean; primary?: number; donor?: string; pledge?: number } = {}): BureaucratE1ShipmentFixture { + const base = createBureaucratE1FundingBase({ advanced, nexus, donor }); const actions = [...base.actions]; let game = structuredClone(base.game); const payer = base.payer; @@ -285,7 +294,7 @@ export function declareBureaucratE1Shipment( // --------------------------------------------------------------------------- export interface BureaucratE1RevivalFixture { - fixture: PairedIxNexusSkillsFixture; + fixture: PairedIxSkillsFixture; actions: BureaucratE1FundingStep[]; staging: string[]; game: Game; @@ -318,9 +327,9 @@ function holdE1Card( * to the tanks, then Tleilaxu's native expanded-limit benefit genuinely lets a * five-force paid revival exceed five spice. Never assigns wallets or tanks. */ export function createBureaucratE1RevivalFixture({ - advanced = false, payer = 'atreides', amount = 5, counter = false, -}: { advanced?: boolean; payer?: string; amount?: number; counter?: boolean } = {}): BureaucratE1RevivalFixture { - const fixture = pairedE1Skills(BUREAUCRAT_E1_REVIVAL_ROSTER, BUREAUCRAT_E1_REVIVAL_TRAINER, advanced); + advanced = false, nexus = true, payer = 'atreides', amount = 5, counter = false, +}: { advanced?: boolean; nexus?: boolean; payer?: string; amount?: number; counter?: boolean } = {}): BureaucratE1RevivalFixture { + const fixture = pairedE1Skills(BUREAUCRAT_E1_REVIVAL_ROSTER, BUREAUCRAT_E1_REVIVAL_TRAINER, advanced, nexus); const actions: BureaucratE1FundingStep[] = []; const staging: string[] = []; assert.notEqual(payer, BUREAUCRAT_E1_REVIVAL_TRAINER); diff --git a/tests/fixture-bureaucrat-e3-funding.ts b/tests/fixture-bureaucrat-e3-funding.ts index eed8092f..bf8856a3 100644 --- a/tests/fixture-bureaucrat-e3-funding.ts +++ b/tests/fixture-bureaucrat-e3-funding.ts @@ -4,6 +4,7 @@ import { advancePairedE3Nexus, createPairedE3NexusSetup, pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus, + type PairedE3NexusOptions, } from './fixture-paired-e3-nexus-modules'; export type BureaucratE3FundingStep = { actor: string; action: Action }; @@ -13,10 +14,12 @@ export const BUREAUCRAT_E3_ROSTER = ['ecaz', 'moritani', 'guild', 'atreides', 'e /** The single classic seat trained with the native Bureaucrat skill. */ export const BUREAUCRAT_E3_TRAINER = 'atreides'; +export type BureaucratE3FundingOptions = Pick; export interface BureaucratE3FundingBase { actions: BureaucratE3FundingStep[]; staging: string[]; - /** Turn-2 Spice blow Nexus window: real alliances may still form here. */ + /** Turn-2 real worm alliance window, independent of optional Nexus Cards. */ worm: Game; /** Turn-2 Bidding boundary with the requested real mutual alliance formed. */ game: Game; @@ -61,14 +64,14 @@ function holdE3Card( return incoming.id; } -/** Genuine paired E3 setup with skills, a real turn-2 worm Nexus and a real +/** Genuine paired E3 setup with skills, a real turn-2 worm alliance window and a * mutual alliance between the payer and the requested donor. Never assigns * wallets, armies, phase, assignment or alliance state. */ export function createBureaucratE3FundingBase( - { advanced = false, donor = 'moritani' }: { advanced?: boolean; donor?: string } = {}, + { donor = 'moritani', ...options }: BureaucratE3FundingOptions & { donor?: string } = {}, ): BureaucratE3FundingBase { - const options = { roster: [...BUREAUCRAT_E3_ROSTER], advanced, skills: true }; - const setup = createPairedE3NexusSetup(options); + const advanced = options.advanced ?? false; + const setup = createPairedE3NexusSetup({ ...options, advanced, roster: [...BUREAUCRAT_E3_ROSTER], skills: true }); assert.ok(setup.afterSetup.leaderSkills!.assignments.some( (a) => a.owner === BUREAUCRAT_E3_TRAINER && a.skill === 'bureaucrat' && a.leader, )); @@ -145,11 +148,11 @@ export function bureaucratE3PhysicalCards(game: Game): string[] { } export function createBureaucratE3AuctionFixture({ - advanced = false, payerShare = 5, donorShare = 3, donor = 'moritani', counter = false, -}: { - advanced?: boolean; payerShare?: number; donorShare?: number; donor?: string; counter?: boolean; + payerShare = 5, donorShare = 3, donor = 'moritani', counter = false, ...options +}: BureaucratE3FundingOptions & { + payerShare?: number; donorShare?: number; donor?: string; counter?: boolean; } = {}): BureaucratE3AuctionFixture { - const base = createBureaucratE3FundingBase({ advanced, donor }); + const base = createBureaucratE3FundingBase({ ...options, donor }); const actions = [...base.actions]; const staging = [...base.staging]; let game = pairedE3NexusReload(base.game); @@ -208,33 +211,38 @@ export interface BureaucratE3ShipmentFixture extends BureaucratE3FundingBase { donorAmount: number; amount: number; payee: string; + cost: number; + counterCard?: string; physical: string[]; } export function createBureaucratE3ShipmentFixture({ - advanced = false, primary = 5, partner = 'moritani', pledge = 3, -}: { - advanced?: boolean; primary?: number; partner?: string; pledge?: number; + primary = 5, partner = 'moritani', pledge = 3, target = 'habbanya_ridge_sietch', counter = false, ...options +}: BureaucratE3FundingOptions & { + primary?: number; partner?: string; pledge?: number; + target?: 'habbanya_ridge_sietch' | 'habbanya_erg'; counter?: boolean; } = {}): BureaucratE3ShipmentFixture { - const base = createBureaucratE3FundingBase({ advanced, donor: partner }); + const base = createBureaucratE3FundingBase({ ...options, donor: partner }); const actions = [...base.actions]; const staging = [...base.staging]; let game = pairedE3NexusReload(base.game); game = advancePairedE3Nexus(game, (s) => s.turn === 2 && s.phase === 5 && s.active === base.payer && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); assert.equal(game.turn, 2); - const target = 'habbanya_ridge_sietch'; assert.ok(game.players.every((p) => !Object.keys(p.forces).some((key) => key.startsWith(target)))); const physical = bureaucratE3PhysicalCards(game); + const counterCard = counter ? holdE3Card(game, base.trainer, (card) => card.effect === 'karama', staging) : undefined; const beforePledge = pairedE3NexusReload(game); const aid: Action = { type: 'pledgeAid', amount: pledge }; actions.push({ actor: partner, action: aid }); game = applyAction(game, partner, aid); assert.equal(game.aid[partner].amount, pledge); const beforeAction = pairedE3NexusReload(game); - const amount = primary + pledge; + const cost = primary + pledge; + const amount = cost / (target === 'habbanya_erg' ? 2 : 1); + assert.ok(Number.isInteger(amount) && amount > 0, 'The declared shares pay the exact original shipment cost'); const action: Action = { type: 'ship', territory: target, sector: 17, amount, elite: 0, allyPayment: pledge }; - return { ...base, actions, staging, game, physical, - beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, payee: base.guild }; + return { ...base, actions, staging, game, physical, counterCard, + beforePledge, beforeAction, target, action, primary, donorAmount: pledge, amount, cost, payee: base.guild }; } /** Declare once. In Advanced the original Guild interception commits the ship. */ diff --git a/tests/fixture-paired-e3-nexus-modules.ts b/tests/fixture-paired-e3-nexus-modules.ts index 72dd7b4b..a6db54c9 100644 --- a/tests/fixture-paired-e3-nexus-modules.ts +++ b/tests/fixture-paired-e3-nexus-modules.ts @@ -32,6 +32,9 @@ export interface PairedE3NexusOptions { initial?: Game; roster?: readonly FactionId[]; advanced?: boolean; + nexus?: boolean; + /** Additional ordinary decks selected before setup; no extra faction seat. */ + families?: readonly ('ix' | 'choam')[]; skills?: boolean; training?: Partial>; homeworlds?: boolean; @@ -145,7 +148,7 @@ function selectedSkills(g: Game, training?: PairedE3NexusOptions['training']): L return g.players.map(p => training?.[p.faction] ?? preferred[p.faction]!); } function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] { - const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null; + const original = traitorDeck(g.players, g.expansions.includes('ix')), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null; const source = original.filter(c => c !== loyal), desired = [...source]; const target = faction === 'ecaz' ? source.find(card => leaders('ecaz').some(l => l.id === card)) : leaders('guild').find(l => l.name === 'Master Bewt')?.id; @@ -160,16 +163,20 @@ function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] { export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): PairedE3NexusSetup { const roster = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', 'moritani', 'guild', 'emperor']; const advanced = options.advanced ?? options.initial?.advanced ?? true; + const nexus = options.nexus ?? true; + const families = options.families ?? options.initial?.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') ?? []; let g = options.initial ? pairedE3NexusReload(options.initial) - : createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']); + : createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]); if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); - assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']); + assert.equal(g.advanced, advanced); assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort()); + assert.equal(new Set(families).size, families.length); assert.deepEqual(g.players.map(p => p.faction), roster); assert.ok(g.players.length >= 2 && g.players.length <= 6 && new Set(roster).size === roster.length); assert.ok(roster.includes('ecaz') && roster.includes('moritani')); assert.ok(roster.every(f => ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', ...(advanced ? [] : ['harkonnen'])].includes(f))); const skills = options.skills ?? (options.initial ? !!g.leaderSkills : false); + assert.ok(nexus || skills, 'The no-Nexus route keeps the original all14 Skills module'); const discovery = options.discovery ?? !!g.discoveryEnabled; const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards }; @@ -181,14 +188,27 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa if (g.status === 'lobby') { for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) g = stepPairedE3Nexus(g, { actor: g.host, action: { type, enabled } }, actions); - g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null }; + g.discoveryEnabled = discovery; + if (nexus) g.nexusCards ??= { cards: null, phase: null }; + else { + assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component'); + g = stepPairedE3Nexus(g, { actor: g.host, + action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions); + } for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions); - if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g); + const initialize = () => { + if (nexus) return skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g); + const step: PairedE3NexusStep = { actor: g.host, + action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } }; + actions.push(structuredClone(step)); + return applyAction(pairedE3NexusReload(g), step.actor, step.action); + }; + if (options.initial) g = initialize(); else { const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g, options.training); const rest = sourceSkills.filter(c => !selected.includes(c)); const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest); - const cards = treacheryDeck(['ecaz']), remaining = [...cards]; + const cards = treacheryDeck(g.expansions), remaining = [...cards]; const prefix = g.players.flatMap(p => Array.from({ length: p.faction === 'harkonnen' ? 2 : 1 }, () => { const worthless = remaining.findIndex(c => c.kind === 'worthless'); const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); assert.ok(at >= 0); @@ -196,16 +216,17 @@ export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): Pa })); const rolls = [...(skills ? permutation(sourceSkills, skillOrder) : []), ...(discovery ? Array(7).fill(0xffffffff) : []), - ...Array(NEXUS_FACTIONS.length - 1).fill(0xffffffff), + ...(nexus ? Array(NEXUS_FACTIONS.length - 1).fill(0xffffffff) : []), ...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)]), - ...Array(spiceDeck().length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff), + ...Array(spiceDeck(g.expansions.includes('ix')).length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff), ...(!skills && !roster.includes('beneGesserit') ? traitorRolls(g, options.assassinationFaction) : [])]; - g = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g)); + g = lottery(rolls, initialize); } } assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery); for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); - assert.ok(g.nexusCards?.cards); + if (nexus) assert.ok(g.nexusCards?.cards); + else assert.equal(g.nexusCards, undefined); const offered = pairedE3NexusReload(g), selected = selectedSkills(g, options.training); for (let n = 0; g.status === 'setup' && n < 300; n++) { let next: PairedE3NexusStep; @@ -279,6 +300,11 @@ export function createPairedE3NexusProgramme(options: PairedE3NexusOptions = {}) for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]]) g = stepPairedE3Nexus(g, { actor, action: { type: 'alliance', target } }, f.actions); const alliance = pairedE3NexusReload(g); + if (!g.nexusCards) { + g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); + pairedE3NexusInventory(g); + return { ...f, game: g, alliance, drawing: null, draws: [], maker, claim, firstMentat }; + } g = advancePairedE3Nexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); const cards = g.nexusCards!.cards!; cards.deck = ['ecaz', 'moritani', ...cards.deck.filter(c => c !== 'ecaz' && c !== 'moritani')]; @@ -350,10 +376,12 @@ export function quotePairedE3NexusBattle(g: Game): BattleResolutionQuote { pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true }); } export function pairedE3NexusInventory(g: Game): void { - const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players); - assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + if (g.nexusCards) { + const nx = g.nexusCards.cards!; validateNexusCards(nx, g.players); + assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + } const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; - assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); + assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(g.expansions).map(c => c.id).sort()); assert.equal(g.richeseCache?.length ?? 0, 0); if (g.leaderSkills) { validateLeaderSkills(g.leaderSkills, g.players); @@ -372,7 +400,7 @@ export function pairedE3NexusInventory(g: Game): void { } const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? []; assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired, - ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort()); + ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort()); assert.equal(pairedE3NexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5); assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID); validateAmbassadors(g.ecazAmbassadors!); assert.deepEqual(g.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); diff --git a/tests/fixture-paired-ix-nexus-skills.ts b/tests/fixture-paired-ix-nexus-skills.ts index 091db8c6..76cc84f7 100644 --- a/tests/fixture-paired-ix-nexus-skills.ts +++ b/tests/fixture-paired-ix-nexus-skills.ts @@ -6,10 +6,11 @@ import { import { territory } from '../game/board'; import type { Card } from '../game/cards'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; -import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { pairedNexusLeaderSkillsProfile, selectedDeckLeaderSkillsProfile } from '../game/leader-skill-profile'; import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; import { traitorDeck } from '../game/traitors'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { skillOfferRolls } from './fixture-independent-card-modules'; export type PairedIxNexusSkillsStep = { actor: string; action: Action }; export type PairedIxNexusSkillsOptions = { @@ -17,6 +18,9 @@ export type PairedIxNexusSkillsOptions = { * Existing first hands, offers, actor IDs and stock are authoritative. */ initial?: Game; advanced?: boolean; + /** Default selects the original Nexus Cards module; `false` starts the same + * roster and all14 lottery from the plain Leader Skills module instead. */ + nexus?: boolean; program?: 'suboids' | 'faceDance'; skill?: LeaderSkillId; band?: 'normal' | 'skilled'; @@ -56,6 +60,9 @@ export type PairedIxNexusSkillsFixture = { actions: PairedIxNexusSkillsStep[]; staging: string[]; }; +/** The no-Nexus producer has no drawing frame at all: the three Nexus-draw + * fields are genuinely absent rather than empty or fabricated. */ +export type PairedIxSkillsFixture = Omit; export function pairedIxNexusSkillsPlayer(game: Game, actor: string): Player { const player = game.players.find(p => p.id === actor); assert.ok(player, `Missing original E1 seat ${actor}`); @@ -174,10 +181,17 @@ function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`); } /** Original 47/14/12 native setup → Storm → real Mentat → conserved worm → - * real alliance → real closing native draws. Board/card relocation is explicit; - * wallets, phases, turns, effects, stock and sealed plans are never assigned. */ -export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxNexusSkillsFixture { + * real alliance → real closing native draws. The original Nexus Cards module is + * selected by default; `nexus: false` starts the identical roster and all14 + * lottery from the plain module, so no Nexus state is fabricated or erased. + * Board/card relocation is explicit; wallets, phases, turns, effects, stock and + * sealed plans are never assigned. */ +export function createPairedIxNexusSkillsFixture(options?: PairedIxNexusSkillsOptions & { nexus?: true }): PairedIxNexusSkillsFixture; +export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions & { nexus: false }): PairedIxSkillsFixture; +export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions): PairedIxSkillsFixture; +export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxSkillsFixture { const advanced = options.advanced ?? options.initial?.advanced ?? true; + const nexus = options.nexus ?? true; const program = options.program ?? 'suboids', band = options.band ?? 'skilled'; let game = options.initial ? structuredClone(options.initial) : createGame('PAIREDIXNEXUSSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); @@ -202,16 +216,30 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt const initial = structuredClone(game); if (game.status === 'lobby') { assert.ok(!game.leaderSkills, 'Original all14 custody cannot be replaced'); - game.nexusCards ??= { cards: null, phase: null }; - assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + if (nexus) { + game.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + } else { + assert.equal(game.nexusCards, undefined, 'A no-Nexus programme must begin without selected or played Nexus Cards'); + const step: PairedIxNexusSkillsStep = { actor: game.host, + action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }; + actions.push(step); game = applyAction(game, step.actor, step.action); + } for (const p of game.players) if (!p.ready) { const step: PairedIxNexusSkillsStep = { actor: p.id, action: { type: 'ready' } }; actions.push(step); game = stepPairedIxNexusSkills(game, step); } - game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)); + if (nexus) game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)); + else { + const training = Object.fromEntries(game.players.map((p, index) => [p.faction, selected[index]])); + const step: PairedIxNexusSkillsStep = { actor: game.host, + action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } }; + actions.push(step); game = entropy(() => applyAction(game, step.actor, step.action), skillOfferRolls(game, training)); + } staging.push('Scoped original all14 shuffle selects native offers before starting Treachery deals; no offers are replaced'); } - assert.ok(pairedNexusLeaderSkillsProfile(game)); + assert.ok(nexus ? pairedNexusLeaderSkillsProfile(game) : selectedDeckLeaderSkillsProfile(game)); + if (!nexus) assert.equal(game.nexusCards, undefined); assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0); assert.equal(game.leaderSkills!.assignments.length, 0); let offered = structuredClone(game), trainer = ''; @@ -255,7 +283,9 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt const afterFirstStorm = structuredClone(game); orderSpice(game, ['land', 'land'], staging); game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions); - reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion'); + if (nexus) { + reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion'); + } game = advancePairedIxNexusSkills(game, g => g.phase === 8 && clean(g), actions); let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsStep; while (game.turn === 1) { @@ -270,17 +300,20 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt const beforeAlliance = structuredClone(game); const allianceActions: PairedIxNexusSkillsStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } })); for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); } - game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions); - const cards = game.nexusCards!.cards!; - assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu')); - cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')]; - staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands'); - const beforeNexusDraw = structuredClone(game); - const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor, - action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } })); - for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); } - game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions); - const afterNexusDraw = structuredClone(game); + let drawing: Pick | undefined; + if (nexus) { + game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions); + const cards = game.nexusCards!.cards!; + assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu')); + cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')]; + staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands'); + const beforeNexusDraw = structuredClone(game); + const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor, + action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } })); + for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); } + game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions); + drawing = { beforeNexusDraw, drawActions, afterNexusDraw: structuredClone(game) }; + } game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions); reserveBoard(game); const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen'; @@ -311,11 +344,11 @@ export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOpt { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } }, ]; return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, - beforeAlliance, allianceActions, beforeNexusDraw, drawActions, afterNexusDraw, beforeBattle, battleStep, + beforeAlliance, allianceActions, ...drawing, beforeBattle, battleStep, game, ixians, tleilaxu, opponent, partners, trainer, skill, band, program, kind, location, replacementSource, secondLocation, planActions, actions, staging }; } -export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, state = f.game): Game { +export function preparePairedIxNexusSkillsBattle(f: PairedIxSkillsFixture, state = f.game): Game { let game = structuredClone(state); for (let i = 0; i < 160; i++) { const step = nextPairedIxNexusSkillsStep(game); @@ -325,12 +358,12 @@ export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, } throw Error('Paired E1 native skill posture did not reach private plans'); } -export function beginPairedIxNexusSkillsCunning(f: PairedIxNexusSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game { +export function beginPairedIxNexusSkillsCunning(f: PairedIxSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game { const offer = viewGame(state, f.ixians).nexusSuboids?.offer; assert.ok(offer); assert.equal(offer.blocked, null); return stepPairedIxNexusSkills(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } }); } -export function revealPairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, +export function revealPairedIxNexusSkillsBattle(f: PairedIxSkillsFixture, options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsStep[] } = {}): Game { let game = preparePairedIxNexusSkillsBattle(f, options.state ?? f.game); if (options.cunning) game = advancePairedIxNexusSkills(beginPairedIxNexusSkillsCunning(f, game), clean); @@ -348,14 +381,14 @@ export function finishPairedIxNexusSkillsBattle(state: Game, export function finishPairedIxNexusSkillsTurn(state: Game): Game { return advancePairedIxNexusSkills(state, g => g.turn > state.turn); } -export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxNexusSkillsFixture, state: Game): Game { +export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxSkillsFixture, state: Game): Game { assert.ok(f.secondLocation); let game = advancePairedIxNexusSkills(state, g => g.phase === 6 && clean(g) && !!g.active); const actor = game.active!; assert.ok(actor === f.ixians || actor === f.opponent); game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: 'carthag', target: actor === f.ixians ? f.opponent : f.ixians } }); return preparePairedIxNexusSkillsBattle(f, game); } -export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxNexusSkillsFixture, state: Game): Game { +export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxSkillsFixture, state: Game): Game { let game = advancePairedIxNexusSkills(state, g => g.phase === 5 && clean(g)); reserveBoard(game); place(game, f.ixians, f.location, 3); place(game, f.tleilaxu, f.location, 2); game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g));