From 8d5c8da6c46966c23065fc3034c5d6d946a35528 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:54:54 +1100 Subject: [PATCH] Compose original World Nexus core --- components/game-table.tsx | 5 +- docs/CURRENT_STATUS.md | 26 ++++ docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md | 33 ++++ docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md | 27 ++++ docs/IMPLEMENTATION_STATUS.md | 37 +++++ docs/NEXUS_CARD_RULES.md | 63 ++++++++ docs/RULE_DECISIONS.md | 1 + game/engine.ts | 19 ++- game/faction-module-profile.ts | 4 +- game/leader-skill-profile.ts | 1 + game/reference.ts | 3 +- ...ure-classic-independent-nexus-reactions.ts | 7 +- tests/fixture-classic-independent-nexus.ts | 2 + tests/fixture-classic-nexus-core.ts | 2 +- tests/fixture-nexus-core-world-battles.ts | 147 ++++++++++++++++++ tests/fixture-nexus-core-world-payments.ts | 122 +++++++++++++++ .../nexus-core-world-battles-runtime.test.ts | 140 +++++++++++++++++ .../nexus-core-world-payments-runtime.test.ts | 131 ++++++++++++++++ 18 files changed, 754 insertions(+), 16 deletions(-) create mode 100644 tests/fixture-nexus-core-world-battles.ts create mode 100644 tests/fixture-nexus-core-world-payments.ts create mode 100644 tests/nexus-core-world-battles-runtime.test.ts create mode 100644 tests/nexus-core-world-payments-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 23f0c7f0..cb027527 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1293,8 +1293,9 @@ export function GameTable({ {classicNexusPreview && (

Classic Nexus reactions preview · Basic/Advanced classic factions, original base33 - or selected independent36 Treachery, optionally all14 Leader Skills. Original - Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions + or selected independent36 Treachery, optionally all14 Leader Skills. Homeworld/Nexus + core currently keeps base33; independent World cards remain a separate preview boundary. + Original Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions retain original card, force and payment authority. Acknowledgement timing and purchase/bonus ordering remain provisional. Other modules and complete Nexus acceptance remain separate.{' '} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f042c240..54597b0e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,32 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Original World Nexus core — 18 October. Prototyped / Partial.** +Plain base33 World/Nexus lacked native reaction states; one public capability +now composes original Homeworlds with base33, optional14 and original +Tech3+/Advanced Strongholds2+. Actual host controls retain every selected +module through private training/Traitors into first Storm; salusa elite5 and +unassigned tokens/cards prove no premature owners. Independent36 World/Nexus +stays a named pending gate; other envelopes remain separate. + +Real World fee sources pass their original gates: Guild Junction→Giedi +departure2, Cunning visitor return through native permission/rate/fee2; +Caladan→Carthag borrowed fee1/fee5 settles to the distinct holder with typed +custody waiting. Own-Kaitain temporary-Sardaukar battles keep ordinary-only +casualties and true Salusa custody across high/low support; Giedi native-call +cancellation follows original capture/execution/cleanup and one private +replacement. Normal Smuggler companions now also supply the funded fee in core. + +Frozen45-file **448/448**, types/lint/build pass. The initial low-Salusa bound +failure was fixture-only (cap7 vs wallet2) and is asserted honestly; no engine +rule changed. Two actual390px journeys and four legal policies with 13–20 +owned choices each pass; no calibration. One HMR route invalidation was +re-entered through its original scene and a DELETE204 cleanup was diagnosed +ERR_ABORTED—diagnostics preserved. Temporary memory/routes/programmes +removed/released204, no saved room/reset/restart/deployment. Independent +World/Nexus, occupation variants, full combinations and assurance stay open. +[Source, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026). + **Classic core phase modules — 18 October. Prototyped / Partial.** Fresh classic Nexus now composes original Tech3+ and Advanced Strongholds2+ with selected33/36 and optional14. Normal host controls retain all five diff --git a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md index 1b34547a..fcd00c4c 100644 --- a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md @@ -21,3 +21,36 @@ These quotes are not yet wired into live off-planet battle discovery, aftermath, Focused checks pass 62 combat/Stone Burner cases, 29 battle-resolution cases, nine Homeworld army/discovery cases and eight Salusa engine cases. These groups overlap existing regression coverage and are not a combined full-suite count. The Salusa cases use genuine Advanced setup, typed shipments and actual battle preparation, sealed plans, traitor actions and casualty settlement. They cover high/low transitions, normal support costs, ordinary Karama, JSON restoration, opponent privacy, physical conservation and legal decisions from every AI level. The first integrated pass passed **3,218 offline cases**, typecheck, lint, production build and **40 HTTP cases**. Independent review then reproduced a Ghola deadlock: raising Salusa from one to two Sardaukar during combat could invalidate an already sealed paid-support plan. A temporary explicit release boundary now blocks only that population-changing Sardaukar Ghola return during the Emperor’s battle, before any card or force moves. Regressions cover both a sealed plan and a binding prescience dial before either full plan, JSON rejection without mutation, subsequent battle completion and the later legal Sardaukar return. The player view, AI allowance and controls expose the same restriction; ordinary force and leader targets remain available. This is an unfinished interaction, not an official prohibition. The final check after the guard passes **3,221 offline cases**, typecheck and lint; the final production build passes. The 40 HTTP cases passed before the guard; the guard’s new coverage executes production engine actions with JSON restoration. All **2,648** earlier baseline rooms retain their versions and state hashes. No new browser acceptance was performed for this slice. No live Homeworld invasion, complete Homeworld game, full module combination or browser acceptance is claimed by this checkpoint. The recurring automation remains removed. + +## Original BASE33 World/Nexus battle programmes — 18 October 2026 + +The earlier foundation-only descriptions above are historical. The bounded +`fixture-nexus-core-world-battles.ts` programme uses normal original lobby +start/setup, a real turn-two worm/alliance/closing Nexus draw, paid native-source +invasion and the engine's actual battle preparation. It never assigns played +hands, armies, wallets, leaders, clocks, alliances or module ownership. Only +unused original deck/lottery positions select sources. + +The Kaitain route moves eight actual ordinary Emperor counters to Salusa and +leaves seven ordinary native defenders. Emperor Cunning keeps its existing +Advanced/five-ordinary/no-actual-Sardaukar eligibility. High Salusa retains five +real stars; the low route legally ships four real stars to empty Arrakis desert, +leaving one on Salusa. The ordinary counters temporarily counting as Sardaukar +do not change that population or become starred casualties. Both routes use the +shared support/casualty calculation and preserve Salusa custody after battle. + +The Giedi Prime route invades the actual native Harkonnen pool with Atreides, +uses an originally offered opposing Traitor identity and allows only the native +ordinary Traitor caller. The invader's separately drawn Harkonnen Nexus card +can cancel that call: the physical identity retires, normal combat resumes, +and the original private replacement occurs at Mentat. Advanced optional-all14 +routes select the original trained disc through the real visibility control; +original capture/execute and cleanup remain engine-owned. Actually earned +Arrakeen/Carthag/Tuek's Sietch cards do not grant generic own-World effects. + +A finite original-action probe exercised high/low Kaitain Cunning and +Basic/Advanced Giedi Prime cancellation, including Advanced capture/execution +and Mentat replacement. The new outcome regressions have not been run in this +lane; parent integration owns verification. No numeric policy or combat helper +change was needed. Independent36 own-World fee/conversion interaction remains +separately gated and is not exercised here. diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index 27d58f18..da5da0e9 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -16,6 +16,33 @@ no World, counter, wallet, owner or played history is manufactured. [Latest registered scope and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026); [prior standalone/paired World459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026). + +## Original World Nexus payment chronologies — 18 October 2026 + +Two focused chronologies now exercise genuine original World card-granted +shipment sources against unchanged shared engine helpers. The Basic base33 +scene ships Guild's first three native Junction counters to Giedi Prime, +spends the actual Cunning singleton, then returns the same three typed +visitors home: the second declaration opens the real native return permission +and native rate windows before the funded Guild fee gate, and the rival +Harkonnen-face holder cannot Use a second Guild payment. The Advanced +all14/Tech/Strongholds scene ships five actual selected Caladan counters into +Carthag under the original Richese Secret Ally discount and once without it; +the distinct Emperor holder of the real Guild singleton claims the one-spice +discounted fee or the full five-spice ordinary fee, and typed visitor/native +custody, source pools and unrelated seats stay untouched throughout. + +Observed through the finite original-action programme +`/tmp/dune-world-nexus-payments-smoke.mts` (`node --import tsx`, exit0): +permission→rate gate kinds, invoice prices 2/1/5 with `originalReceiver` +guild, wallet and reserve deltas only at the owner acknowledgement, the +visitor pool leaving Giedi Prime for Junction without any counter creation, +and the once-only rejection of a repeated `homeworldShip`. New files are +`tests/fixture-nexus-core-world-payments.ts` and +`tests/nexus-core-world-payments-runtime.test.ts`; no shared engine, fixture +or profile file changed. Test runners, types, lint, build, formatters and git +were not run here; the frozen-suite verification remains with the parent. + ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 Original Homeworld/Nexus now composes natives from both selected Ix+CHOAM diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8d504baa..10d119be 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,42 @@ # Dune implementation status +## 18 October 2026 — Original World Nexus core + +Plain base33 World/Nexus started without native reaction states, and +independent36 World/Nexus rejected without an explicit boundary message. The +public core now composes original Homeworlds with base33, optional14 and +original Tech/Strongholds; the narrower independent predicate excludes +Homeworlds and both boundary errors now name the pending interaction. Actual +host controls retain all selected modules through original readiness, private +training, Traitor selection and World stock into first Storm (salusa elite5, +no premature owners). Ixian paid2 replacement preserves real World/phase +custody and the following lot. + +Two disjoint lanes delivered original World fee and battle sources: a Basic +Guild Junction→Giedi→Cunning-return chronology through native +permission/rate/fee gates, Advanced Caladan→Carthag borrowed fee1/fee5 to the +distinct holder, own-Kaitain temporary-Sardaukar battles with high/low Salusa +support and ordinary-only casualties, and Giedi native-call cancellation with +original capture/execution/cleanup/replacement. Parent integrated pending +World custody bindings, suspended Cunning frame reuse, the Smuggler funded-fee +source and narrower independent messaging; no engine numeric rule changed. + +Frozen45-file **448/448**, types/lint/build pass. Initial9/9 failed on a +fixture-only low-Salusa bound assumption (shared cap7 vs wallet2); the worker's +restored high3/low8 rejection assertion and honest comment were accepted. +Nullable World descriptor typing in two new files was corrected after parent +type feedback. Two actual390px journeys and four legal policies (13–20 owned +choices each) cover the lobby, borrowed Caladan fee and both battle families +with varying outcomes; no calibration claim. + +One mirror HMR route invalidation reset the isolated scene; it was re-entered +through its original button and completed without gameplay exceptions. A +cleanup DELETE204 was again diagnosed ERR_ABORTED. Temporary routes, memory +and executable programmes are removed/released204; no saved room/reset/ +restart/deployment. Existing build warnings, independent World/Nexus, +occupation variants, full combinations and deferred assurance remain open. +[Canonical source and owner proof](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026). + ## 18 October 2026 — Classic Nexus with earned phase modules Original independent36/Nexus+Tech and all14/phase starts rejected through diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 48d4f2cc..eed580f2 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,69 @@ 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. +## Original World Nexus core — 18 October 2026 + +**Prototyped / Partial.** Fresh original base33 classic Homeworld+Nexus now +initializes the actual Guild/Harkonnen/Ixian reaction states through the public +core capability, optionally all14 Skills and original Tech3+/Advanced +Strongholds2+. Printed module independence composes; no independent36 cards or +native expansion families are admitted. Independent World/Nexus remains a +separate pending own-World fee/conversion interaction, and public guidance now +says so. Other Discovery/Semuta/reaction envelopes remain separate. + +Harkonnen/Ixian pending parents bind only genuinely present World custody; bare +ordinary parents gain no unconditional new keys. Signed Guild Cunning World +frames are admitted at the funded Guild fee gate, reading the actual suspended +frame rather than an invented alias. Normal Smuggler companions also supply the +funded fee: classic core permits the genuine discounted physical invoice while +the original standalone profiles retain their narrower sources. No smuggled +companion can ride a No-Field or card-granted route. + +- Basic base33 Guild: real native Junction departure3 to Giedi Prime (printed2) + uses the shipment; real Cunning then returns the same three typed visitors + home through native return permission, native rate and the Guild fee gate. + Wrong-face Emperor cannot Use a second Guild payment; Pass pays the actual2 + once, removes the visitor pool and preserves every physical Guild counter. +- Advanced all14+Tech+Strongholds: five actual selected Caladan counters enter + Carthag. The Richese-discounted fee1 and ordinary fee5 both settle to the + distinct actual Guild-card holder; typed custody waits at the gate, and the + used singleton leaves its holder once. +- Own-Kaitain battles: paid Guild invasion plus legal Emperor inter-World + movement leave seven ordinary defenders. Cunning keeps its five-ordinary/ + no-actual-Sardaukar rule; temporary-role counters pay support (high Salusa's + real stars support free) but only ordinary counters die, and true Salusa + custody never changes. Low Salusa keeps the shared cap at7 while the wallet + bounds the live plan; a genuine desert star export shows why. +- Giedi Prime: the native pool defends with its actual offered Traitor. Only + Harkonnen receives an ordinary call; the invader's independently drawn + Harkonnen Nexus may cancel it. Original capture/execution, printed bounty, + cleanup and one private actual Mentat replacement follow. Earned + Arrakeen/Carthag/Tuek cards confer no generic World effects. + +New original-action files: `tests/fixture-nexus-core-world-payments.ts`, +`tests/nexus-core-world-payments-runtime.test.ts`, +`tests/fixture-nexus-core-world-battles.ts`, +`tests/nexus-core-world-battles-runtime.test.ts`. Frozen45-file **448/448**, +types/lint/build pass. Initial9/9 exposed a fixture-only low-Salusa bound +mistake; the shared cap(7)/wallet(2) distinction is now asserted honestly and +no engine calculation changed. Two workers exercised finite programmes; their +sources/results are preserved in owner proof and removed. Normal Smuggler fee +gates were reproduced then integrated by the owner. + +Two actual390px journeys finish the original module-selected Advanced lobby +through genuine training/Traitor/World stock into first Storm (salusa elite5, +no premature token/card owners), and the borrowed Caladan fee mirror: +live Richese offer→guildShipment gate→Emperor Use→payer11/holder11/Guild6, +carthag5, Caladan10→5, discard [richese,guild], Heighliners pending1. Four +legal policies complete13–20 owned choices each across fee and battle paths +with varying winners; no calibration. One browser cleanup DELETE204 was again +diagnosed ERR_ABORTED after HMR route changes, and a route-invalidation reset +was re-entered through its original scene button; diagnostics are reported, +not erased. Temporary routes/memory/programmes removed/released204; no saved +room/reset/restart/deployment. Owner proof: +`/tmp/dune-world-nexus-core-18oct.json`. Full combinations, remaining +occupation variants and deferred assurance remain open. + ## Classic Nexus original phase modules — 18 October 2026 **Prototyped / Partial.** Fresh classic Nexus core now retains original diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0e8e5edc..1983cdc9 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -706,6 +706,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Classic independent native Nexus reactions | Fresh independent36/Nexus12 optional14 initializes the existing Ixian purchase and actual Harkonnen declared-Traitor effect stamps; played games remain unchanged. Public selection binds canonical faces and the original census, with a new independent marker only where selected. Under standing choose-recommended authority, inherit the existing bounded native-counter-first/neutral acknowledgement and replacement-before-Harkonnen-bonus conventions, visibly provisional, not new publisher or universal rulings. Personal/allied source, real Mentat replacement and original paid/card/force mechanics remain. Other native/module/reaction envelopes stay separate. | [Current source and actual controls](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026) | | Classic Nexus core / funded card shipments | Original Nexus independence admits fresh classic2–6 Basic/Advanced base33 or selected independent36, optionally all14, without a three-card dependency. Initialize actual Guild/Harkonnen/Ixian reaction states only at fresh setup. Original signed Cunning reserve/cross/return and Richese one-counter grants retain native counters, funding and physical continuation through the same Guild fee gate. One Guild singleton cannot simultaneously fund Cunning and rival Use; actual wrong-face Pass remains neutral. Actual third-party Bureaucrat sees the real recipient and printed payer exclusions. No new numerical/universal ruling, played reflag or wider module opening; inherited timing stays provisional. | [Current source and exact proof](NEXUS_CARD_RULES.md#classic-nexus-core-and-funded-card-shipments--18-october-2026) | | Classic Nexus core / original phase modules | Fresh classic base33 or selected36/Nexus12, optionally14, composes original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced through the public core capability. Real first-Storm tokens, owner-count pending income, actual end-Mentat held cards and original matching payout remain. Fixed paid force groups do not become free under Recruits; free requests accrue once, pending income cannot fund earlier costs, and fees/Harass are not Revival/shipment. Earned local bank support does not fund reserve fees; physical effective Carthag defense, native counters/cleanup/winner token/private replacement keep their original order. No new numerical or universal policy, played reflag or wider family/World/Discovery opening. | [Source, controls and owner-frozen439/439](NEXUS_CARD_RULES.md#classic-nexus-original-phase-modules--18-october-2026) | +| Original World Nexus core | Fresh base33 classic Homeworld/Nexus composes optional all14 and original Tech3+/Advanced Strongholds2+ through the public core capability; independent36 World/Nexus stays a separate pending own-World fee/conversion gate with explicit public messaging. Pending parents bind only present World custody; signed Cunning World frames and genuine discounted Smuggler invoices supply the funded Guild fee while standalone envelopes keep narrower sources. Typed visitor/native pools, printed tariffs, native permission/rate order, singleton spending, temporary-role support and ordinary-only casualties, native Traitor callers and original cleanup/replacement remain. No new numeric policy, played reflag or wider envelope opening. | [Owner-frozen448/448, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026) | | Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) | | Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) | | Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index f9ea1465..a2c7caae 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9714,6 +9714,7 @@ function harkonnenNexusBetrayalParent(g: Game): string { hand:p.hand,traitors:p.traitors,revealedTraitors:p.revealedTraitors,leaders:p.leaders, forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})), deck:g.deck,discard:g.discard,traitorReserve:g.traitorReserve,nexus:g.nexusCards, + ...(g.homeworlds ? {homeworlds:g.homeworlds} : {}), response:g.response,decision:g.decision,truthtrance:g.truthtrance, phaseOpening:g.phaseOpening,pendingKarama:g.pendingKarama, pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard, @@ -10043,6 +10044,7 @@ function ixianNexusReplacementParent(g: Game): string { order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})), deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards, + ...(g.homeworlds ? {homeworlds:g.homeworlds} : {}), ...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding, richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved, ...(g.richeseAuction ? { richeseAuction:g.richeseAuction } : {}) } : {}), @@ -10269,8 +10271,8 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation 'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)), 'The saved reserve shipment contains foreign source fields.'); requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField && - !shipment.smuggler && !shipment.smugglerCompanion && !shipment.richesePair && - !shipment.guildSecretEvent && + !shipment.richesePair && !shipment.guildSecretEvent && + ((shipment.smuggler && classicNexusCoreProfile(g)) || (!shipment.smuggler && !shipment.smugglerCompanion)) && ((!shipment.guildNexusEvent && !shipment.nexusEvent) || classicNexusCoreProfile(g)), 'This Guild Betrayal profile requires a supported original physical shipment source.'); validatePhysicalShipment(g, shipment); @@ -10302,8 +10304,8 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation ['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost', 'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)), 'The saved Homeworld shipment contains foreign source fields.'); - requireRule(!shipment.guildSecretEvent && !shipment.guildNexusEvent, - 'This Guild Betrayal profile supports native Homeworld shipment routes only.'); + requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || classicNexusCoreProfile(g)), + 'This Guild Betrayal profile requires a supported original Homeworld shipment source.'); const quote = validateHomeworldShipment(g, shipment); shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment; originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null; @@ -10740,7 +10742,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { const homeworlds = homeworldLeaderSkillsProfile(state); const independent = independentTreacheryLeaderSkillsProfile(state); if (state.ecazTreachery) { - requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original phase modules; Discovery and unrelated preview contexts remain gated.'); + requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original phase modules without Homeworlds; independent World/Nexus and unrelated preview contexts remain gated.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } @@ -10826,7 +10828,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { g.ecazTreachery = true; const homeworlds = !!g.homeworlds; requireRule(!homeworlds || ecazHomeworldProfile(g), - 'Independent cards with Homeworlds need a fresh supported selected-deck roster with original Tech or Advanced Strongholds.'); + 'Independent World cards need a supported selected-deck roster and original phase modules without Nexus; independent World/Nexus remains a separate pending interaction.'); const initialized = initializeSetupGameForAudit(g, homeworlds, classicIndependentNexusProfile(g), false, false, false, false, true, true); if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized); @@ -28236,8 +28238,9 @@ function nexusGuildCunningIntegrity(g: Game) { for (const d of decisions) validateGuildShipmentDecision(g,d); for (const rate of rates) validateGuildRateResponse(g,rate); } else if (active.shipment.kind === 'homeworld') { - requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.'); - validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment); + const pending = g.pendingHomeworldShipment ?? (suspended?.source === 'homeworld' ? suspended.shipment : null); + requireRule(pending?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.'); + validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',pending); homeworldShipmentIntegrity(g); } else if (active.shipment.kind === 'guild') { requireRule(suspended?.source === 'guildTransport', 'Guild Cunning lost its original transport payment acknowledgement.'); diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts index 84f45f74..35220ea3 100644 --- a/game/faction-module-profile.ts +++ b/game/faction-module-profile.ts @@ -31,12 +31,12 @@ type ClassicNexusProfile = Partial & Pick 6 || - game.homeworlds || (game.strongholdCards && !game.advanced) || game.sandtrout || + (game.homeworlds && game.ecazTreachery) || (game.strongholdCards && !game.advanced) || game.sandtrout || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.richeseBetrayalPreview || diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 9570f44a..57b4be1e 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -463,6 +463,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean /** Original Homeworld/Skills under the existing classic or native Nexus roster contracts. */ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (!game.homeworlds) return false; + if (classicNexusCoreProfile(game)) return true; if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game); if (!homeworldLeaderSkillModulesSupported(game)) return false; const players = game.players!; diff --git a/game/reference.ts b/game/reference.ts index 7a44079c..52c8b601 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4104,7 +4104,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.', 'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.', 'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.', - 'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.', + 'The card collection below is available for reference. Homeworld starts are enabled in supported fresh preview envelopes; the remaining occupation variants and combinations stay under verification.', + 'Original base33 World/Nexus now composes optional all14, Tech3+ and Advanced Strongholds2+ with real first-Storm token owners and end-Mentat card claims. Guild World shipments and Cunning visitor returns pass native permission/rate before the funded fee; borrowed Caladan fee1/fee5 settles to the distinct holder while typed custody waits. Temporary-Sardaukar own-Kaitain battles keep ordinary-only casualties and true Salusa custody; Giedi native-call cancellation follows original capture/execution/cleanup and one private replacement. Normal Smuggler companions may also supply the fee in core. Independent36 World/Nexus remains a separate pending own-World fee/conversion gate with public messaging; other envelopes stay separate.', ], example: 'Atreides with six reserves on Caladan uses the high face. Dropping to five uses the low face and removes the next-Spice-Blow inspection advantage. Either face adds two to native battle strength on Caladan.', diff --git a/tests/fixture-classic-independent-nexus-reactions.ts b/tests/fixture-classic-independent-nexus-reactions.ts index 95a4eddb..81104954 100644 --- a/tests/fixture-classic-independent-nexus-reactions.ts +++ b/tests/fixture-classic-independent-nexus-reactions.ts @@ -49,7 +49,10 @@ export function drawClassicNexusReaction(face: FactionId, owner: string, return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) }; } -export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) { +type ClassicNexusReactionOptions = Pick; + +export function createIndependentHarkonnenCall(options: ClassicNexusReactionOptions & { remote?: boolean } = {}) { const counter = baseDeck().find(card => card.effect === 'karama')!.id; const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : HARASS_WITHDRAW_CARD; const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options, @@ -98,7 +101,7 @@ export function createIndependentHarkonnenCall(options: { advanced?: boolean; sk return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter }; } -export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen'; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) { +export function createIndependentIxianPurchase(options: ClassicNexusReactionOptions & { owner?: 'atreides' | 'harkonnen' } = {}) { const owner = options.owner ?? 'atreides'; const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : 'ecaz-recruits'; const fixture = drawClassicNexusReaction('ixians', owner, { ...options, diff --git a/tests/fixture-classic-independent-nexus.ts b/tests/fixture-classic-independent-nexus.ts index fb56c45f..7aa5a5e3 100644 --- a/tests/fixture-classic-independent-nexus.ts +++ b/tests/fixture-classic-independent-nexus.ts @@ -16,6 +16,7 @@ export type ClassicIndependentNexusOptions = { independent?: boolean; tech?: boolean; strongholds?: boolean; + homeworlds?: boolean; roster?: readonly FactionId[]; training?: Partial>; /** Only original, still-undealt starting Treachery slots are ordered. */ @@ -40,6 +41,7 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent act(game.host, { type: 'moduleOption', module: option, enabled: true }); if (options.tech) act(game.host, { type: 'techTokens', enabled: true }); if (options.strongholds) act(game.host, { type: 'strongholdCards', enabled: true }); + if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true }); for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); const rolls = options.skills ? skillOfferRolls(game, options.training) : []; diff --git a/tests/fixture-classic-nexus-core.ts b/tests/fixture-classic-nexus-core.ts index 94df54ae..b6887a11 100644 --- a/tests/fixture-classic-nexus-core.ts +++ b/tests/fixture-classic-nexus-core.ts @@ -7,7 +7,7 @@ import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixt import { advanceClassicIndependentNexus, type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus'; import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus'; -export type ClassicNexusCoreOptions = Pick; +export type ClassicNexusCoreOptions = Pick; function drawSecondFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) { assert.equal(game.nexusCards!.phase!.stage, 'drawing'); const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face); diff --git a/tests/fixture-nexus-core-world-battles.ts b/tests/fixture-nexus-core-world-battles.ts new file mode 100644 index 00000000..44b29d13 --- /dev/null +++ b/tests/fixture-nexus-core-world-battles.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { combatArmy } from '../game/combat-location'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; +import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus'; +import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus'; + +export type WorldNexusBattleStep = ClassicIndependentNexusStep; +export type WorldNexusBattleOptions = { advanced?: boolean; skills?: boolean; tech?: boolean; strongholds?: boolean }; +type WorldNexusInvasion = { before: Game; action: Action; cost: number; after: Game }; +export const worldNexusClean = (game: Game) => classicDiscoveryNexusClean(game) && + !game.pendingGuildBetrayal && !game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal; +export { reactionPlayer as worldNexusPlayer }; + +/** Continue only existing public controls. Human sealed plans are never invented. + * The named trained disc is concealed through the actual visibility choice. */ +export function advanceWorldNexusBattle(game: Game, until: (game: Game) => boolean, + actions: WorldNexusBattleStep[] = [], trained: readonly string[] = []): Game { + for (let count = 0; count < 1200; count++) { + if (until(game)) return game; + if (game.pendingGuildBetrayal) { + game = advanceClassicIndependentNexus(game, state => !state.pendingGuildBetrayal, actions); + continue; + } + const step = nextClassicDiscoveryNexusStep(game); + if (step?.action.type === 'leaderSkillVisibility') step.action.hide = trained.includes(step.actor); + if (step) game = reactionAction(game, step.actor, step.action, actions); + else game = advanceClassicIndependentNexus(game, until, actions); + } + throw Error('Original World Nexus battle did not reach its owned boundary'); +} + +function chooseWorldBattle(game: Game, location: string, actions: WorldNexusBattleStep[], trained: readonly string[]) { + const choice = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === location); + assert.ok(choice, 'The paid invasion must produce its actual World battle'); + game = reactionAction(game, choice.chooser, { type: 'chooseBattle', territory: location, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions); + return advanceWorldNexusBattle(game, state => !!state.battle && !state.battle.revealed && + !state.battle.preparation && state.battle.preLeader?.closed !== false && worldNexusClean(state), actions, trained); +} + +function invade(game: Game, actor: string, location: string, amount: number, actions: WorldNexusBattleStep[]): WorldNexusInvasion { + const before = structuredClone(game), view = viewGame(game, actor); + const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources); + const quote = homeworldShipmentChoice(view, location, + Object.fromEntries(Object.entries(sources).filter(([, force]) => force.normal + force.elite > 0))); + assert.ok(quote.action, quote.blocked ?? 'Use the original paid native invasion'); + game = reactionAction(game, actor, quote.action, actions); + game = advanceWorldNexusBattle(game, state => !state.pendingHomeworldShipment && worldNexusClean(state), actions); + return { before, action: quote.action, cost: quote.cost, after: game }; +} + +/** Genuine closing draw, paid Guild invasion and Emperor's legal inter-World + * movement leave seven ordinary Kaitain counters. The optional low Salusa + * route exports four actual stars to an empty desert, never converts them. */ +export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions & { salusa?: 'high' | 'low' } = {}) { + const fixture = drawClassicNexusReaction('emperor', 'emperor', { ...options, advanced: true, + independent: false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'], + training: options.skills ? { emperor: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined }); + const actions = fixture.actions, location = 'homeworld:emperor', trained = options.skills ? ['emperor'] : []; + let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && worldNexusClean(state), actions); + let arrival: WorldNexusInvasion | undefined; + let moved = false, exported = false; + while (game.phase === 5) { + const actor = game.active!; + if (worldNexusClean(game) && actor === 'guild' && !reactionPlayer(game, actor).shipped) { + arrival = invade(game, actor, location, 3, actions); game = arrival.after; + } else if (worldNexusClean(game) && actor === 'emperor' && !moved) { + if (options.salusa === 'low' && !exported) { + const desert = TERRITORIES.find(territory => territory.type === 'sand' && !territory.sectors.includes(game.storm) && + !game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${territory.id}:`)))); + assert.ok(desert); + const sources = nativeShipmentSources(viewGame(game, actor), 4, 4); assert.ok(sources); + game = reactionAction(game, actor, { type: 'ship', territory: desert.id, sector: desert.sectors[0], + amount: 4, elite: 4, homeworldSources: sources, allyPayment: 0 }, actions); + exported = true; + game = advanceWorldNexusBattle(game, worldNexusClean, actions); + } else { + const movement = viewGame(game, actor).homeworldMove; assert.ok(movement && !movement.blocked); + game = reactionAction(game, actor, { type: 'emperorHomeworldMove', event: movement.event, + origin: location, normal: 8, elite: 0 }, actions); + moved = true; + } + } else { + const step = nextClassicDiscoveryNexusStep(game); assert.ok(step); + game = reactionAction(game, step.actor, step.action, actions); + } + } + assert.ok(arrival && moved); + game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions); + const beforeBattle = structuredClone(game); + game = chooseWorldBattle(game, location, actions, trained); + const leaders = reactionPlayer(game, 'emperor').leaders.filter(leader => !leader.dead && !leader.usedAt); + const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'emperor'); + const leader = trainer ? leaders.find(leader => leader.id === trainer.leader)! : leaders.at(-1)!; + const opponentLeader = reactionPlayer(game, 'guild').leaders.filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(leader && opponentLeader); + assert.deepEqual(combatArmy(game, 'emperor', location), { normal: 7, elite: 0 }); + homeworldGameIntegrity(game); + const plans: WorldNexusBattleStep[] = [ + { actor: 'emperor', action: { type: 'battlePlan', dial: 4, support: options.salusa === 'low' ? 2 : 0, leader: leader.id } }, + { actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id } }, + ]; + return { ...fixture, game, beforeBattle, arrival, location, plans, trainer, leader: leader.id }; +} + +/** The original Harkonnen native pool defends Giedi Prime. Its actual offered + * Atreides Traitor is chosen as the invader's disc; only Harkonnen receives an + * ordinary call, while the invader owns the independently drawn cancel card. */ +export function createWorldNexusHarkonnenBattle(options: WorldNexusBattleOptions = {}) { + const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options, + independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'], + training: options.skills ? { harkonnen: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined }); + const actions = fixture.actions, location = 'homeworld:harkonnen'; + let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && state.active === 'atreides' && + worldNexusClean(state), actions); + const identity = reactionPlayer(game, 'atreides').leaders.find(leader => reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id; + assert.ok(identity, 'Use an actually offered native Traitor identity'); + const arrival = invade(game, 'atreides', location, 1, actions); game = arrival.after; + game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions); + const beforeBattle = structuredClone(game), trained = options.skills ? ['harkonnen'] : []; + game = chooseWorldBattle(game, location, actions, trained); + const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen'); + const leader = trainer ? reactionPlayer(game, 'harkonnen').leaders.find(leader => leader.id === trainer.leader)! + : reactionPlayer(game, 'harkonnen').leaders.toSorted((a, b) => b.strength - a.strength)[0]; + const plans: WorldNexusBattleStep[] = [ + { actor: 'atreides', action: { type: 'battlePlan', dial: 0, support: 0, leader: identity } }, + { actor: 'harkonnen', action: { type: 'battlePlan', dial: 0, support: 0, leader: leader.id } }, + ]; + homeworldGameIntegrity(game); + return { ...fixture, game, beforeBattle, arrival, location, identity, plans, trainer, leader: leader.id }; +} + +export function revealWorldNexusPlans(game: Game, plans: readonly WorldNexusBattleStep[], actions: WorldNexusBattleStep[] = []) { + for (const plan of plans) { + game = reactionAction(game, plan.actor, plan.action, actions); + game = advanceWorldNexusBattle(game, state => worldNexusClean(state) && !!state.battle && + !state.battle.preparation && state.battle.preLeader?.closed !== false, actions); + } + assert.ok(game.battle?.revealed); + return game; +} diff --git a/tests/fixture-nexus-core-world-payments.ts b/tests/fixture-nexus-core-world-payments.ts new file mode 100644 index 00000000..f4747996 --- /dev/null +++ b/tests/fixture-nexus-core-world-payments.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { territory } from '../game/board'; +import type { FactionId } from '../game/catalog'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; +import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus'; +import type { ClassicNexusCoreOptions } from './fixture-classic-nexus-core'; +import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus'; + +export type WorldNexusPaymentOptions = Pick; + +/** Select only the still-undealt original singleton, then use its actual closing draw. */ +function drawWorldPaymentFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) { + assert.equal(game.nexusCards!.phase!.stage, 'drawing'); + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face); + assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + return reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn, + card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions); +} + +export function worldPaymentWorld(game: Game, actor: string, native: string) { + const worlds = viewGame(game, actor).homeworlds?.worlds ?? []; + const world = worlds.find(home => home.native === native && !home.secondary); + assert.ok(world, `Seat ${native} must hold its actual primary World descriptor.`); + return world; +} + +/** Basic original base33 chronology by default: an actual Junction departure, + * actual Cunning, then an actual visitor return from Giedi Prime to Junction. + * The second route therefore needs native return permission as well as the + * native rate; neither a scalar reserve nor an Arrakis sector is relabelled. */ +export function createWorldNexusCunningPayment(options: WorldNexusPaymentOptions = {}) { + const counter = baseDeck().find(card => card.effect === 'karama')!.id; + const fixture = drawClassicNexusReaction('guild', 'guild', { ...options, + independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild', 'emperor'], + startingCards: { atreides: [counter] } }); + const actions = fixture.actions; + let game = drawWorldPaymentFace(fixture.game, 'emperor', 'harkonnen', actions); + game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'guild' && + classicDiscoveryNexusClean(state), actions); + const source = worldPaymentWorld(game, 'guild', 'guild'); + const destination = worldPaymentWorld(game, 'guild', 'harkonnen'); + const amount = 3, sources = { [source.id]: { normal: amount, elite: 0 } }; + assert.ok(source.forces.guild.normal >= amount); + const firstQuote = homeworldShipmentChoice(viewGame(game, 'guild'), destination.id, sources, 0); + assert.equal(firstQuote.blocked, null); + assert.ok(firstQuote.action); + const beforeFirst = structuredClone(game), firstAction = firstQuote.action; + game = reactionAction(game, 'guild', firstAction, actions); + game = advanceClassicIndependentNexus(game, state => reactionPlayer(state, 'guild').shipped && + !state.pendingGuildBetrayal && classicDiscoveryNexusClean(state), actions); + const afterFirst = structuredClone(game), offer = viewGame(game, 'guild').nexusGuildCunning!.offer!; + assert.equal(offer.blocked, null); + game = reactionAction(game, 'guild', { type: 'endMovement', nexus: offer.event }, actions); + game = advanceClassicIndependentNexus(game, state => state.nexusGuildCunningHistory?.at(-1)?.stage === 'secondShipment' && + classicDiscoveryNexusClean(state), actions); + const beforeSecond = structuredClone(game); + const returnSource = worldPaymentWorld(game, 'guild', 'harkonnen'); + const returnDestination = worldPaymentWorld(game, 'guild', 'guild'); + assert.equal(returnSource.forces.guild.normal, amount, 'Only the first shipment\'s actual visitors occupy Giedi Prime.'); + const returnSources = { [returnSource.id]: { normal: amount, elite: 0 } }; + const quote = homeworldShipmentChoice(viewGame(game, 'guild'), returnDestination.id, returnSources, 0); + assert.equal(quote.blocked, null); + assert.ok(quote.action); + return { ...fixture, game: beforeSecond, beforeFirst, afterFirst, beforeSecond, actions, counter, amount, + source: source.id, destination: destination.id, sources, firstAction, firstQuote, + returnSources, quote, action: quote.action, actor: 'guild', holder: 'emperor' }; +} + +/** Actual normal/Richese shipment: Caladan's selected native counters enter + * Carthag, while Emperor holds the distinct original Guild singleton. Advanced + * all14/Tech/Strongholds is the default second chronology, not a copied scene. */ +export function createWorldNexusBorrowedPayment(options: WorldNexusPaymentOptions & { discounted?: boolean } = {}) { + const discounted = options.discounted ?? true; + const fixture = drawClassicNexusReaction(discounted ? 'richese' : 'harkonnen', 'atreides', { + advanced: true, skills: true, tech: true, strongholds: true, ...options, + independent: false, homeworlds: true, roster: ['atreides', 'guild', 'harkonnen', 'emperor'], + training: options.skills === false ? undefined + : { atreides: 'diplomat', guild: 'mentat', harkonnen: 'warmaster', emperor: 'sandmaster' }, + }); + const actions = fixture.actions; + let game = drawWorldPaymentFace(fixture.game, 'emperor', 'guild', actions); + game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' && + classicDiscoveryNexusClean(state), actions); + const amount = 5, place = territory('carthag'); + assert.ok(!place.sectors.includes(game.storm)); + const native = worldPaymentWorld(game, 'atreides', 'atreides'); + const sources = nativeShipmentSources(viewGame(game, 'atreides'), amount, 0); + assert.ok(sources); + assert.deepEqual(sources[native.id], { normal: amount, elite: 0 }); + const action: Action = { type: 'ship', territory: place.id, sector: place.sectors[0], + amount, elite: 0, allyPayment: 0, homeworldSources: sources }; + if (discounted) { + const offer = viewGame(game, 'atreides').nexusRichese!; + assert.equal(offer.blocked, null); + action.nexus = offer.event; + } + return { ...fixture, game, beforeShipment: structuredClone(game), actions, action, amount, + sources, source: native.id, key: `${place.id}:${place.sectors[0]}`, actor: 'atreides', + holder: 'emperor', discounted }; +} + +/** The original native gates between a World declaration and its fee: each + * recorded kind is a real live window stepped by the shared controller, never + * a synthetic phase label. Stops before the owner fee acknowledgement. */ +export function worldNexusPaymentGates(game: Game, actor: string, action: Action, + actions?: ClassicIndependentNexusStep[]) { + const kinds: string[] = []; + game = reactionAction(game, actor, action, actions); + for (let count = 0; !game.pendingGuildBetrayal && count < 40; count++) { + kinds.push(game.response?.kind ?? game.decision?.kind ?? `${game.phase}/${game.active}`); + const step = nextClassicDiscoveryNexusStep(game); + assert.ok(step, 'The original controller must own every native gate or arrival choice.'); + game = reactionAction(game, step.actor, step.action, actions); + } + assert.ok(game.pendingGuildBetrayal, 'The original declaration must reach its Guild fee gate.'); + return { game, kinds }; +} diff --git a/tests/nexus-core-world-battles-runtime.test.ts b/tests/nexus-core-world-battles-runtime.test.ts new file mode 100644 index 00000000..ec7c0018 --- /dev/null +++ b/tests/nexus-core-world-battles-runtime.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldPopulations } from '../game/homeworld-population'; +import { reactionAction } from './fixture-classic-independent-nexus-reactions'; +import { + advanceWorldNexusBattle, createWorldNexusEmperorBattle, createWorldNexusHarkonnenBattle, + revealWorldNexusPlans, worldNexusClean, worldNexusPlayer, +} from './fixture-nexus-core-world-battles'; + +for (const salusa of ['high', 'low'] as const) { + void test(`original own-Kaitain Cunning with ${salusa} Salusa spends support but kills only physical ordinary counters`, () => { + const f = createWorldNexusEmperorBattle({ salusa, skills: true, tech: true, strongholds: true }); + let game = f.game; + const emperor = worldNexusPlayer(game, 'emperor'), guild = worldNexusPlayer(game, 'guild'); + const beforeSpice = emperor.spice, beforeStars = structuredClone(emperor.elites); + const beforeSalusa = structuredClone(game.homeworlds!.custody!.salusa); + const populations = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!); + const home = populations.find(home => home.location === f.location)!; + assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [7, 'high', 2]); + assert.deepEqual(beforeSalusa, { normal: 8, elite: salusa === 'high' ? 5 : 1 }); + assert.equal(populations.find(home => home.card === 'salusa_secundus')!.side, salusa); + assert.equal(f.trainer!.leader, f.leader); + assert.equal(game.battle!.leaderSkillHidden!.emperor, true); + assert.equal(f.plans[0].action.leader, f.trainer!.leader); + // Arrakeen and Tuek's Sietch were actually earned at END Mentat; neither + // becomes generic off-planet strength or support in this native battle. + assert.equal(game.strongholdCards!.owners.arrakeen, 'atreides'); + assert.equal(game.strongholdCards!.owners.tueks_sietch, 'guild'); + assert.deepEqual(viewGame(game, 'emperor').battle!.strongholdEffects, { guild: null, emperor: null }); + const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!; + assert.equal(offer.blocked, null); + game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event }); + game = advanceWorldNexusBattle(game, state => worldNexusClean(state)); + const forces = viewGame(game, 'emperor').battle!.ownForces!; + assert.deepEqual([forces.normal, forces.elite, forces.temporaryElite], [7, 0, 5]); + assert.equal(!!forces.eliteFreeSupport, salusa === 'high'); + assert.equal(maxCombatDial(forces), 12); + assert.equal(maxCombatSupport(forces), salusa === 'high' ? 2 : 7); + assert.ok(casualtyOptions(forces, 4, salusa === 'high' ? 0 : 2).some(loss => loss.normal === 2 && loss.elite === 0)); + // High Salusa's five real stars grant free elite support, so only the two + // ordinary non-temporary counters can pay (shared cap 2). Low Salusa lifts + // the shared counter cap to 7, but the desert export has already left the + // Emperor 2 spice, so the live plan bound stays 0–2 and anything above it + // is rejected unchanged. + const badSupport = salusa === 'high' ? 3 : 8; + assert.throws(() => applyAction(game, 'emperor', { ...f.plans[0].action, support: badSupport })); + assert.throws(() => applyAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event })); + game = revealWorldNexusPlans(game, f.plans); + assert.deepEqual(viewGame(game, 'emperor').battle!.traitorVoters, ['emperor']); + assert.throws(() => applyAction(game, 'guild', { type: 'traitorCall', call: false })); + game = advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state)); + assert.equal(game.lastBattleContext!.winner, 'emperor'); + assert.equal(game.lastBattleContext!.result, 'normal'); + assert.deepEqual(combatArmy(game, 'emperor', f.location), { normal: 5, elite: 0 }); + assert.deepEqual(combatArmy(game, 'guild', f.location), { normal: 0, elite: 0 }); + assert.equal(worldNexusPlayer(game, 'emperor').tanks, emperor.tanks + 2); + assert.equal(worldNexusPlayer(game, 'guild').tanks, guild.tanks + 3); + assert.equal(worldNexusPlayer(game, 'emperor').spice, beforeSpice - (salusa === 'high' ? 0 : 2)); + assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, beforeStars); + assert.deepEqual(game.homeworlds!.custody!.salusa, beforeSalusa); + const record = game.nexusSardaukarHistory!.at(-1)!; + assert.equal(record.casualties!.outcome, 'complete'); + assert.ok(record.casualties!.options.every(loss => loss.elite === 0)); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1); + assert.equal(viewGame(game, 'emperor').nexusSardaukar!.active, false); + homeworldGameIntegrity(game); + }); +} + +for (const advanced of [false, true]) { + for (const cancel of [false, true]) { + void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} Giedi Prime native call ${cancel ? 'is canceled with retirement and replacement' : 'retains its original traitor outcome'}`, () => { + const f = createWorldNexusHarkonnenBattle({ advanced, skills: advanced, tech: advanced, strongholds: advanced }); + let game = revealWorldNexusPlans(f.game, f.plans); + const native = worldNexusPlayer(game, 'harkonnen'), invader = worldNexusPlayer(game, 'atreides'); + const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === f.location)!; + assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [10, 'high', 2]); + assert.deepEqual(viewGame(game, 'atreides').battle!.traitorVoters, ['harkonnen']); + assert.throws(() => applyAction(game, 'atreides', { type: 'traitorCall', call: true })); + if (advanced) { + assert.equal(game.strongholdCards!.owners.carthag, 'harkonnen'); + assert.deepEqual(viewGame(game, 'harkonnen').battle!.strongholdEffects, { harkonnen: null, atreides: null }); + assert.equal(game.battle!.plans.harkonnen.leader, f.trainer!.leader); + } + game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true }); + const event = game.pendingNexusHarkonnenBetrayal!.source.event; + assert.equal(viewGame(game, 'atreides').nexusHarkonnenBetrayalReaction!.canUse, true); + assert.equal(game.pendingNexusHarkonnenBetrayal!.source.declaration.identity, f.identity); + game = reactionAction(game, 'atreides', { type: cancel ? 'nexusHarkonnenBetrayalUse' : 'nexusHarkonnenBetrayalPass', event }); + if (cancel) { + assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.includes(f.identity), false); + assert.equal(game.traitorReserve!.filter(identity => identity === f.identity).length, 1); + assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1); + } + let captive: string | undefined; + if (advanced) { + game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'captureOffer' && !state.response); + assert.equal(game.decision!.kind, 'captureOffer'); + game = reactionAction(game, 'harkonnen', { type: 'decision', accept: true }); + game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'capturedLeader' && !state.response); + assert.ok(game.decision?.kind === 'capturedLeader'); + captive = game.decision.leader; + assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!.capturedBy, 'harkonnen'); + const spice = worldNexusPlayer(game, 'harkonnen').spice; + game = reactionAction(game, 'harkonnen', { type: 'decision', mode: 'execute' }); + assert.equal(worldNexusPlayer(game, 'harkonnen').spice, spice + 2); + const executed = worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!; + assert.equal(executed.dead, true); + assert.equal(executed.deaths, 2); + assert.equal(executed.capturedBy, undefined); + } + const replacementTop = cancel ? game.traitorReserve![0] : null; + game = advanceWorldNexusBattle(game, state => state.phase === 8 && worldNexusClean(state)); + assert.equal(game.lastBattleContext!.territory, f.location); + assert.equal(game.lastBattleContext!.winner, 'harkonnen'); + assert.equal(game.lastBattleContext!.result, cancel ? 'normal' : 'traitor'); + assert.deepEqual(combatArmy(game, 'harkonnen', f.location), { normal: 10, elite: 0 }); + assert.deepEqual(combatArmy(game, 'atreides', f.location), { normal: 0, elite: 0 }); + assert.equal(worldNexusPlayer(game, 'harkonnen').tanks, native.tanks); + assert.equal(worldNexusPlayer(game, 'atreides').tanks, invader.tanks + 1); + assert.equal(game.pendingNexusHarkonnenReplacement, null); + assert.equal(game.nexusHarkonnenReplacementHistory!.length, cancel ? 1 : 0); + if (cancel) { + assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, replacementTop); + assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.length, 4); + } else { + assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen'); + assert.ok(worldNexusPlayer(game, 'harkonnen').revealedTraitors!.includes(f.identity)); + assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)!.dead, true); + } + assert.throws(() => applyAction(game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event })); + homeworldGameIntegrity(game); + }); + } +} diff --git a/tests/nexus-core-world-payments-runtime.test.ts b/tests/nexus-core-world-payments-runtime.test.ts new file mode 100644 index 00000000..3999f959 --- /dev/null +++ b/tests/nexus-core-world-payments-runtime.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; +import { + createWorldNexusBorrowedPayment, + createWorldNexusCunningPayment, + worldNexusPaymentGates, +} from './fixture-nexus-core-world-payments'; + +const custody = (game: Game, id: string) => { + const seat = reactionPlayer(game, id); + return { forces: seat.forces, reserves: seat.reserves, elites: seat.elites ?? null, tanks: seat.tanks }; +}; +const worldForces = (game: Game, actor: string, id: string) => { + const world = (viewGame(game, actor).homeworlds?.worlds ?? []).find(entry => entry.id === id); + assert.ok(world); + return world.forces; +}; +const guildCounterTotal = (game: Game) => { + const guild = reactionPlayer(game, 'guild'); + const visitors = (viewGame(game, 'guild').homeworlds?.worlds ?? []) + .filter(world => world.native !== 'guild'); + return guild.reserves + Object.values(guild.forces).reduce((sum, n) => sum + n, 0) + + visitors.reduce((sum, world) => sum + (world.forces.guild?.normal ?? 0) + (world.forces.guild?.elite ?? 0), 0); +}; + +void test('Basic base33 World Cunning return passes native permission, native rate and the funded Guild fee gate', () => { + const f = createWorldNexusCunningPayment(); + assert.equal(f.game.phase, 5); + assert.equal(f.game.active, 'guild'); + assert.equal(reactionPlayer(f.game, 'guild').shipped, true, 'The first Junction departure already used the shipment.'); + assert.equal(f.firstQuote.cost, 2); + assert.equal(f.quote.cost, 2); + assert.equal(f.quote.amount, 3); + assert.equal(f.quote.ownPayment, 2); + assert.equal(f.action.type, 'homeworldShip'); + assert.equal(f.action.destination, 'homeworld:guild'); + assert.deepEqual(f.action.sources, { 'homeworld:harkonnen': { normal: 3, elite: 0 } }); + assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment'); + assert.equal(viewGame(f.game, 'guild').nexusGuildCunning!.active!.movesLeft, 0, + 'The granted Cunning move is reserved for this second shipment, not normal movement.'); + const unrelated = ['atreides', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const); + + const gates = worldNexusPaymentGates(f.game, 'guild', f.action); + assert.deepEqual(gates.kinds, ['guildTransportPermission', 'guildRate'], + 'The return route opens both native gates before any fee.'); + const invoice = gates.game.pendingGuildBetrayal!.invoice; + assert.equal(invoice.price, 2); + assert.equal(invoice.ownSpice, 3, 'No escrow is taken before the fee acknowledgement.'); + assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']); + assert.equal(reactionPlayer(gates.game, 'guild').reserves, 12); + assert.deepEqual(worldForces(gates.game, 'guild', 'homeworld:harkonnen').guild, { normal: 3, elite: 0 }); + assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, false, + 'The original Guild singleton was already spent on Cunning itself.'); + assert.throws(() => reactionAction(gates.game, 'emperor', + { type: 'guildBetrayalUse', event: invoice.event }), /cannot take this shipment payment/); + + let game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalPass', event: invoice.event }); + const guild = reactionPlayer(game, 'guild'); + assert.equal(worldForces(game, 'guild', 'homeworld:harkonnen').guild, undefined, + 'The actual visitor pool leaves Giedi Prime.'); + assert.deepEqual(worldForces(game, 'guild', 'homeworld:guild').guild, { normal: 15, elite: 0 }); + assert.equal(guild.reserves, 15); + assert.equal(guildCounterTotal(game), guildCounterTotal(gates.game), 'No physical Guild counter is created or lost.'); + assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']); + assert.equal(game.nexusCards!.cards!.hands.emperor, 'harkonnen'); + for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before); + + game = reactionAction(game, 'guild', { type: 'endMovement' }); + assert.equal(game.nexusGuildCunningHistory!.at(-1)!.stage, 'completed'); + assert.equal(viewGame(game, 'guild').nexusGuildCunning ?? null, null); + assert.notEqual(game.active, 'guild'); + assert.throws(() => reactionAction(game, 'guild', f.action), /unused shipment/, + 'The once-only card, payment and physical continuation cannot repeat.'); +}); + +void test('Advanced all14 Richese borrowed Caladan shipment pays the distinct Guild holder its one-spice fee', () => { + const f = createWorldNexusBorrowedPayment(); + assert.equal(f.source, 'homeworld:atreides'); + assert.deepEqual(f.sources, { 'homeworld:atreides': { normal: 5, elite: 0 } }); + assert.equal(f.action.type, 'ship'); + assert.equal(typeof f.action.nexus, 'string'); + assert.deepEqual(worldForces(f.game, 'atreides', 'homeworld:atreides').atreides, { normal: 10, elite: 0 }); + const unrelated = ['guild', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const); + + const gates = worldNexusPaymentGates(f.game, 'atreides', f.action); + assert.deepEqual(gates.kinds, ['guildShipment']); + const invoice = gates.game.pendingGuildBetrayal!.invoice; + assert.equal(invoice.price, 1); + assert.equal(invoice.originalReceiver, 'guild'); + assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']); + assert.equal(reactionPlayer(gates.game, 'atreides').reserves, 10, 'Typed custody waits for the fee.'); + assert.equal(reactionPlayer(gates.game, 'atreides').forces[f.key] ?? 0, 0); + assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true, + 'The distinct actual holder may still claim this fee.'); + + const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event }); + assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 1); + assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 1); + assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice); + assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5); + assert.equal(reactionPlayer(game, 'atreides').reserves, 5); + assert.deepEqual(worldForces(game, 'atreides', 'homeworld:atreides').atreides, { normal: 5, elite: 0 }); + assert.deepEqual(game.nexusCards!.cards!.discard, ['richese', 'guild']); + assert.equal(game.nexusRicheseHistory!.at(-1)!.stage, 'shipped'); + for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before); +}); + +void test('the ordinary Advanced all14 Caladan shipment pays its full five-spice fee to the distinct Guild holder', () => { + const f = createWorldNexusBorrowedPayment({ discounted: false }); + assert.equal(f.action.nexus, undefined); + const gates = worldNexusPaymentGates(f.game, 'atreides', f.action); + assert.deepEqual(gates.kinds, ['guildShipment']); + const invoice = gates.game.pendingGuildBetrayal!.invoice; + assert.equal(invoice.price, 5); + assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['atreides', 'emperor']); + assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true); + + const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event }); + assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 5); + assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 5); + assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice); + assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5); + assert.equal(reactionPlayer(game, 'atreides').reserves, 5); + assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']); + assert.equal(game.nexusCards!.cards!.hands.emperor, null, + 'The used Guild singleton leaves its holder once; no duplicate exists.'); + assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen', + 'The rival Harkonnen face stays held by its own drawing seat.'); +});