diff --git a/components/game-table.tsx b/components/game-table.tsx index a18a01c8..f9581468 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -13,6 +13,7 @@ import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; +import { NexusAdvisors } from './nexus-advisors'; import { TerrorBoardMarkers } from './terror-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; @@ -1153,12 +1154,9 @@ export function GameTable({ className="board-player-circle" transform={`translate(${593 + Math.cos(angle) * 562},${587 + Math.sin(angle) * 562})`} > - - Player circle {index + 1}, sector {circleSector}:{' '} - {player - ? `${player.name} · ${faction(player.faction).name}` - : 'Empty'} - + {`Player circle ${index + 1}, sector ${circleSector}: ${player + ? `${player.name} · ${faction(player.faction).name}` + : 'Empty'}`} + {!g.nexusCards?.waiting.length && } {g.biddingEnd && } {g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && ( @@ -1805,6 +1804,7 @@ export function GameTable({ choamBattleAid: 'CHOAM allied combat funding', worthlessKarama: 'Worthless card as Karama', advisorFlip: 'Bene Gesserit token flip', + nexusAdvisorFlip: 'Bene Gesserit Nexus advisor conversion', eliteStrength: 'Elite battle strength', fremenSupport: 'Fremen full strength without spice', capture: 'Harkonnen leader capture', @@ -1977,6 +1977,7 @@ export function GameTable({ )} {g.response.intent && g.response.source !== 'ambassador' && + g.response.kind !== 'nexusAdvisorFlip' && g.response.kind !== 'faceDancerReplacement' && (

{g.response.intent}

)} diff --git a/components/nexus-advisors.tsx b/components/nexus-advisors.tsx new file mode 100644 index 00000000..d54c0b33 --- /dev/null +++ b/components/nexus-advisors.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { gameTerritories } from '@/game/board'; +import { + nexusAdvisorAction, + nexusAdvisorCanAct, +} from '@/game/nexus-advisor-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +export function NexusAdvisors({ game, act, busy }: Props) { + const state = game.nexusAdvisors; + if (state?.pending) { + const places = gameTerritories(game); + return ( +
+

+ {game.players.find((p) => p.id === state.pending!.owner)?.name ?? + 'Bene Gesserit'}{' '} + declared advisor conversion in{' '} + {state.pending.territories + .map( + (id) => + places.find((t) => t.id === id)?.name ?? + 'an unavailable territory', + ) + .join(', ')} + . +

+

+ Karama can cancel the entire selection. The advisors become fighters + together after this response. +

+
+ ); + } + if ( + game.nexusCards?.card !== 'beneGesserit' || + game.players.find((p) => p.id === game.me)?.faction !== 'beneGesserit' || + !state?.offer + ) + return null; + return ( + + ); +} + +function AdvisorSelection({ game, act, busy }: Props) { + const offer = game.nexusAdvisors!.offer!; + const [selected, setSelected] = useState([]); + const places = gameTerritories(game); + const eligible = offer.territories.filter( + (choice) => !choice.blocked && choice.count > 0, + ); + const disabled = busy || !nexusAdvisorCanAct(game); + const action = nexusAdvisorAction(game, offer.event, selected); + return ( +
+

Bene Gesserit Nexus · Cunning

+

+ Choose territories whose advisors will become fighters. Every Bene + Gesserit force in each selected territory changes together, across all + sectors. +

+

+ This spends your Nexus card once. Karama may cancel the entire + selection. It costs no spice and uses no shipment or movement allowance. +

+ {offer.blocked &&

{offer.blocked}

} +
+ + +
+ {offer.territories.map((choice) => { + const name = + places.find((t) => t.id === choice.territory)?.name ?? + 'Unavailable territory'; + return ( +
+ + {choice.blocked &&

{choice.blocked}

} +
+ ); + })} + {!eligible.length && ( +

+ No advisor group is currently available for this conversion. +

+ )} +

+ {selected.length} {selected.length === 1 ? 'territory' : 'territories'}{' '} + selected. +

+ +
+ ); +} diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index 5290ced5..31df26e0 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held

-

Atreides Nexus actions are available at their battle timing; other card effects are not playable yet.

+

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.

{game.nexusAtreides && offer.card === 'atreides' && (
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index 84bc029d..f199aa39 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -1,5 +1,7 @@ # Component inventory and verification ledger +**Bene Gesserit Nexus Cunning update, 10 September 2026:** existing physical advisors now have an owned whole-territory conversion checklist with counts, select-all control and explicit blocked reasons. One played Nexus card opens one Karama response for the entire public selection; no additional card, token or artwork is created. Legal selected groups become fighters in place without consuming shipment, movement or spice. Fresh accompanied advisors and storm cases remain explicitly pending rulings. Borrowed Voice, Betrayal and incomplete mode starts remain gated. [NEXUS_ADVISOR_RUNTIME.md](NEXUS_ADVISOR_RUNTIME.md) records the bounded runtime and verification status; final acceptance is pending. + **Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Atreides Cunning and Secret Ally have inspected battle controls and private history. Harkonnen Cunning and Secret Ally now draw from the physical Traitor Deck before an exact private return; Tleilaxu receives Face Dancers while previously revealed dancers stay protected. The owner selects actual leader identities with enlarged inspection, and observers receive only pending metadata. Native Tleilaxu Cunning now replaces all revealed Face Dancers atomically, drawing before returning the old identities and preserving unrevealed custody and ordinary cycling. Ixian Cunning gives Suboids strength one without support spice for the rest of the turn, while Cyborgs retain their own rules. Both use existing physical components and private offers; Ixian activation becomes public. This adds no duplicate physical deck or new artwork. Tleilaxu Secret Ally, Betrayal, remaining effects and complete module games remain unfinished and gated. See [NEXUS_HARKONNEN_RUNTIME.md](NEXUS_HARKONNEN_RUNTIME.md), [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md), [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for prior verification and limits. [NEXUS_CUNNING_RUNTIME.md](NEXUS_CUNNING_RUNTIME.md) records the new native Cunning behavior; its final checkpoint acceptance remains pending. **Homeworld update, 9 September 2026:** all 13 cards and 26 physical faces are verified, including native population/battle values and occupied income symbols. The internal gallery supplies both faces, complete original gameplay prose, enlarged inspection and thirteen original bundled illustrations. Separate typed custody and population helpers are tested; actual Homeworld gameplay, occupation and effect integration remain unfinished and gated. See [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) and [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). Earlier missing-Homeworld statements below are historical. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 59084ef7..43fb6d10 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**Bene Gesserit Nexus advisor checkpoint, 10 September 2026:** [NEXUS_ADVISOR_RUNTIME.md](NEXUS_ADVISOR_RUNTIME.md) records whole-territory, multi-sector Cunning conversion during the native owner’s Advanced Shipment and Movement action. One Karama cancels the entire declared batch; source positions, counts and action allowances are preserved. Saved responses survive Truthtrance, Harkonnen exchanges and paid Nullentropy. Shared controls and all four bot profiles consume the public legality quote. **Types, lint, all 3,971 offline tests, production build and 40 HTTP/session tests pass.** Thirty-five new cases include pending-parent corruption, physical custody and concurrent production SQLite recovery. Two three-seat browser rooms passed desktop/phone selection, allowance/cancellation and private refresh; review also fixed checkbox spacing, accessible SVG titles and stale Nexus guidance. All **3,389** opening saved rooms retain their versions/hashes; only two isolated browser rooms and thirty HTTP-test rooms were added. Fresh advisor/storm rulings, borrowed Voice, Betrayal and complete module acceptance remain gated. The Emperor source audit prepares the next family. No release gate opened; no restart was due, and automation remains removed. + **Tleilaxu and Ixian Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_CUNNING_RUNTIME.md](NEXUS_CUNNING_RUNTIME.md) records atomic revealed Face Dancer replacement and full-strength Suboids in every battle for the rest of the turn. Basic and Advanced retain Cyborg support costs and exact physical losses. Review fixed fresh Ixian Nexus setup, future-plan search through an unspent Cunning card and its private preparation hint; prior commitments remain binding. **Types, lint, all 3,936 offline tests, production build and 40 HTTP/session tests pass.** Fifty-one new cases include real Face Dance, all four bots, mixed-force support, concurrent SQLite recovery and future Truthtrance/Prescience commitments. Desktop/phone card play, private refresh and a seven-strength/two-support sealed plan passed. The hourly restart preserved all **3,359** then-existing games; final comparison preserves all **3,327** opening room versions/hashes. Tleilaxu Secret Ally, Betrayal and remaining Nexus effects stay gated pending their recorded requirements. No expansion release gate opened; automation remains removed. **Harkonnen Nexus exchange checkpoint, 10 September 2026:** [NEXUS_HARKONNEN_RUNTIME.md](NEXUS_HARKONNEN_RUNTIME.md) records draw-first Cunning and absent-faction Secret Ally, exact private returns, protected revealed Face Dancers, saved suspended actions and durable prior traitor declarations. Shared controls and all four bot profiles use projected identities. Independent review fixed lost preceding decisions and the paid Nullentropy action gate. **Types, lint, all 3,885 offline cases, production build and 40 HTTP/session cases pass.** Forty-six new cases cover rules, consumers, continuations and concurrent SQLite recovery. Two three-seat browser rooms passed draw/return, private restoration and desktop/phone inspection. All **3,295** preexisting room versions/hashes remain unchanged. Betrayal remains blocked pending the existing private response timing decision; complete Nexus and expansion games remain unfinished, and no release gate opens. The server was reused, no restart was due, and automation remains removed. diff --git a/docs/NEXUS_ADVISOR_RUNTIME.md b/docs/NEXUS_ADVISOR_RUNTIME.md new file mode 100644 index 00000000..c30ccd52 --- /dev/null +++ b/docs/NEXUS_ADVISOR_RUNTIME.md @@ -0,0 +1,37 @@ +# Bene Gesserit Nexus advisor conversion + +10 September 2026. This checkpoint implements the native **Cunning** stance conversion described in [NEXUS_BENE_GESSERIT_RULES.md](NEXUS_BENE_GESSERIT_RULES.md). It extends the [Nexus lifecycle](NEXUS_CARD_RUNTIME.md); it does not implement borrowed Voice or Betrayal, settle the outstanding source questions, or open incomplete mode and expansion starts. + +## Selection and physical result + +During its own active Shipment and Movement action in Advanced play, an unallied Bene Gesserit holder can select a nonempty set of eligible advisor territories. Every advisor in each selected territory becomes a fighter together, across all sectors. Positions, physical counts, spice, shipment usage and movement usage stay unchanged. The choice cannot target individual counters or leave mixed stances within a territory. + +The private server quote supplies each territory's total count and blocked reason. The controls begin with no selection, offer individual checkboxes and **Select all available**, and require one explicit submission. A blocked group cannot be included through the shared options helper. Fresh accompanied advisors and storm-related conversions retain explicit pending-ruling explanations; these guards are not presented as independently verified printed prohibitions. Stronghold capacity, allied presence, public No-Field presence and existing territorial restrictions remain in force. Homeworlds are not advisor territories. + +## One cancellation window + +Declaring the set spends the single physical Nexus card and opens the ordinary Karama response for this native advantage. The territory set is now public. Allowing the response converts the complete set; cancellation leaves every selected group as advisors while the Nexus card remains spent. No individual territory gets a separate play or cancellation window. + +The event-bound record retains original groups, turn, owner, the active Shipment and Movement action and pending/completed/canceled outcome. Validation binds the original response, including nested cancellation/discard continuations, before settlement. Refreshing or retrying does not spend another card or replay the flip. Canonical signatures detect inconsistent saved fields; they are not cryptographic authentication against a fully rewritten save. + +Other responses, owned decisions, Truthtrance, phase opening, closing Nexus draws and unfinished card transactions must finish before declaration. The table names the response and shows the declared territory names, with standard **Allow this power** and **Cancel with Karama** controls. The internal event is not printed as a player instruction. + +## Bots, privacy and evidence + +All four profiles use the same authorized choices. The policy selects legal groups whose advisor count exceeds the total public opposing fighter presence, preserving peaceful or apparently unfavorable groups. It does not read opposing hands, spice, traitors or concealed No-Field values. Hard and Brutal opponents use their existing Karama policy against a declared conversion threatening their fighters; Easy and Medium retain the ordinary pass policy. This is bounded legal-policy integration, not new strategy calibration. + +The focused consumer suites contain **eight** cases, including actual all-profile declaration/settlement, exact unchanged force/cost/usage checks, private pending projections, a public No-Field getter trap and the actual table's standard response actions. The final selection, including reference tests and the existing Ixian table controls, passed **23/23** with no title warnings. The table regression also exposed an existing SVG player-circle title formed from multiple React children; its title now uses one complete string so the accessible name renders correctly. Final verification passed types, lint, all **3,971 offline tests**, the production build and **40 HTTP/session tests**. This checkpoint adds **35** cases: ten pure quotes, eleven engine cases, eight consumer cases, three production SQLite recovery cases and three Nullentropy interruption cases. + +Implementation: [source quote](../game/nexus-advisors.ts), [shared options](../game/nexus-advisor-options.ts), [controls](../components/nexus-advisors.tsx), [engine](../game/engine.ts), [bots](../game/bots.ts). Consumer evidence: [controls](../tests/nexus-advisor-controls.test.ts), [bot actions](../tests/nexus-advisor-bots.test.ts). Structural server rendering does not substitute for browser acceptance. + +Fresh-advisor and storm permissions remain pending the existing user question. Borrowed Voice remains gated on its separate cancellation/timing interpretation, and Betrayal retains the existing private reaction-policy gate. Complete Bene Gesserit Nexus effects, complete module games and full expansion compliance remain unfinished. + +## Integrated acceptance + +Independent reviews found no additional runtime defect after the tested implementation. Actual Truthtrance and Harkonnen draw/return interruptions preserve the pending batch. Paid Nullentropy keeps its fee, restores the original advisor response after the private selection, and still allows either conversion or Karama cancellation. Missing or edited parent records reject reads, automatic continuation and actions without mutating the source. The SQLite cases exercise competing writes, lost responses, recreated room-store instances, private seat restoration and corruption rejection through production room code and migrations. + +Two isolated three-seat browser rooms exercised the controls with actual server requests: `M2QGRHGS` completed Arrakeen and multi-sector Pasty Mesa conversion; `YT2ENZUP` canceled the same two-territory batch with Karama. Both finished at version 4. Desktop and 390-pixel phone selection, the explicit fresh-advisor block, public pending response, all six private seat refreshes and final restoration passed without browser errors or horizontal overflow. Visual inspection prompted the checkbox spacing and stale Nexus guidance fixes. No existing room was staged or repurposed. + +The final database comparison preserved all **3,389** opening room versions and state hashes. The database now contains **3,421** rooms, adding only two named browser rooms and thirty isolated HTTP-test rooms. The running development server was reused; the previous controlled restart was about 00:45 UTC, so no hourly restart was due for this checkpoint. Saved games and the removed-automation preference remain preserved. + +The native conversion has focused acceptance only. Full module games, placed Terror/Ambassador token combinations and the unresolved printed-rule interactions retain their separate completion requirements. The [Emperor source audit](NEXUS_EMPEROR_RULES.md) prepares the next effect family without claiming runtime support. diff --git a/docs/NEXUS_BENE_GESSERIT_RULES.md b/docs/NEXUS_BENE_GESSERIT_RULES.md new file mode 100644 index 00000000..9d8a526c --- /dev/null +++ b/docs/NEXUS_BENE_GESSERIT_RULES.md @@ -0,0 +1,56 @@ +# Bene Gesserit Nexus: stance conversion and borrowed Voice + +Primary-source audit, 10 September 2026. This document separates printed rules, their implementation composition and unresolved interactions. It does not certify a complete Nexus module. The [common contract](NEXUS_CARD_RULES.md) governs physical custody and alliance disposal; the existing [Atreides audit](NEXUS_ATREIDES_RULES.md) records the still-pending private response policy. + +## Authority and acquisition + +- [Original printed Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), visually reinspected in the local photograph `/tmp/dune-nexus-cards.jpg`: all three Bene Gesserit panels. +- [GF9 E3 rulebook, pp.10–11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10): Homeworld restrictions, mode definitions and borrowed-Voice timing. The previously acquired publisher-authored [PDF mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) was read alongside fresh publisher-indexed text. +- [GF9 base rulebook, pp.18 and 23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18): advisors, Voice and inspection order. +- [GF9 November 2020 FAQ, pp.4–5 and 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=4): stance, storm, Voice categories and cancellation. Fresh official-domain indexed passages were retrieved. Direct requests returned 403; older purported local base/FAQ PDFs were HTML and were not accepted as evidence. The superseded April FAQ, tournament rules and fan compilations are not adopted. + +## Printed scope + +| Mode | Condition | Effect | +| --- | --- | --- | +| Cunning | Native Bene Gesserit holder | During that player's Shipment and Movement turn, select any or all advisor groups and convert them to fighters. All its forces in each territory must finish in one stance. | +| Secret Ally | Bene Gesserit absent | In the holder's battle, use the Bene Gesserit Voice advantage against the opponent's Treachery Card choice. | +| Betrayal | Another player is Bene Gesserit | Prevent that faction's attempted Voice. | + +The card specifically calls Secret Ally permission to “use the Bene Gesserit Voice advantage.” This differs from the Atreides card's standalone instruction to reveal a plan element; neither wording establishes blanket Nexus immunity. [Printed components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) + +E3 describes Cunning as enhancing a native advantage. Cards are discarded after use and cannot remain held by allied players. Secret Ally requires the named faction to be absent; it creates no seated ally. Its FAQ explicitly requires borrowed Voice before Prescience. Homeworlds are not territories; advisors cannot be sent there, and invading BG fighters cannot become advisors there. [E3, pp.10–11,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) + +## Existing restrictions + +Advisors are an Advanced feature. Ordinary free accompanying advisors cannot become fighters that turn while other forces remain. Ordinary Battle flips occur before any shipment. Voice can require or prohibit a weapon, defense, Worthless card or Cheap Hero; an opponent unable to comply is free of that requirement. Voice precedes Prescience; unaffected plan elements remain changeable. [Base, pp.18,23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18) + +November's FAQ requires one stance per territory, forbids flipping alongside an ally and forbids the ordinary pre-shipment flip in a storm-locked stronghold. It allows generic projectile/poison and shield/snooper commands, but special cards require their specific name. Weirding Way and Chemistry use their default classification for Voice rather than every legal slot. Karama stops one use, including alliance abilities, native Voice and native token flipping. [November FAQ, pp.4–5,7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=4) + +## Executable composition + +These are implementation consequences, not additional publisher quotations: + +- Cunning chooses a set of territories, not individual counters or sectors. Convert every BG counter in each selected territory, preserving locations and quantities. Selecting a territory containing several sectors cannot leave mixed stances. A nonempty selection supplies an actual use; no zero-effect disposal is inferred. +- The opportunity belongs to the owner's current Shipment and Movement action, including after shipment and before ending that action. It is not an anytime effect throughout everybody else's turns. Selection need not be restricted to the group being moved. Basic supplies no eligible advisors. +- Conversion is a stance change in place. It consumes no shipment, movement, reserves or spice and creates no arrival for Guild income, accompaniment, Terror or Ambassadors. Recompute fighter presence and subsequent movement/battle eligibility. Homeworld native reserves are not a selectable advisor group. +- Cunning enhances the native stance advantage. Treat its one announced territory set as one use for Karama: cancel that entire pending conversion, preserving every original advisor group. The card provides one selection, not independent successive plays. This follows the one-use cancellation rule; there is no separately retrieved worked batch example. +- Preserve stronghold capacity and allied-territory constraints; the card specifies no exemption. Use public faction presence, including public No-Field presence, without reading its denomination. Recheck the complete selection at settlement; reject atomically if it has become illegal. +- Secret Ally supplies one Voice in one owned battle and spends the card. It does not grant Voice in another faction's battle or future battles. Reuse native command classification, compliance, compulsory-card logic and existing promise validation; do not expose whether the opponent privately holds a matching card. +- Bind Voice to its original battle, owner, target and command. Close the opportunity before either Prescience or sealed plans make the command late. Native and Nexus inspections must share that boundary; this is consistent timing composition for Atreides Secret Ally, not a separately printed FAQ answer. + +## Material boundaries requiring a ruling + +No retrieved primary clarification resolves these specific combinations. Do not silently broaden permission or describe a conservative block as the printed rule. + +1. **Newly accompanied groups:** does Cunning's any/all permission override the same-turn advisor lock, or only the ordinary pre-shipment timing? The face does not name that prohibition. +2. **Storm:** does Cunning allow an in-place flip in a storm-locked stronghold? The FAQ's prohibition explicitly addresses the earlier ordinary Battle flip; its rationale concerns inability to battle. Applying it to this later card is plausible, not explicit. +3. **Borrowed Voice and Karama:** the card expressly grants a named advantage, while the normal native owner is absent. E3's secret-alliance terminology and the general alliance-cancellation rule support cancellation; absence of a native BG owner supports the opposite reading used for Atreides' differently worded effect. No retrieved Nexus FAQ selects one. Do not copy that earlier implementation choice as authority. + +The existing Betrayal response-privacy question remains pending; this audit adds no duplicate user question. It likewise does not settle historical Homeworld occupation ownership. Ordinary unoccupied, older, storm-free advisor groups are a clear selection subset, but broader claims must retain the boundaries above. + +## Suggested state and verification contract + +Keep stance choices public and event-bound: owner, turn, own-action identity, selected territory IDs, original counts/stances and settled/canceled result. A retry must not flip twice or consume a second card. For borrowed Voice, keep source and receipt separate from native ability ownership, with cancellation behavior dependent on the unresolved ruling. + +Exercise multi-territory and multi-sector selections, current-owner timing, conserved counters, Basic absence, occupied strongholds, No-Field getter traps, stale selection and saved continuations. Voice tests should cover generic and named special commands, impossible compliance, prior inspection, sealed plans, private hand permutation and bot actions using only projected choices. No runtime was changed by this audit. diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md new file mode 100644 index 00000000..bcf58f1f --- /dev/null +++ b/docs/NEXUS_EMPEROR_RULES.md @@ -0,0 +1,71 @@ +# Emperor Nexus: battle strength, purchase and revival + +Primary-source audit, 10 September 2026. This is a contract for future implementation, not a runtime-completion claim. The [common Nexus rules](NEXUS_CARD_RULES.md) and current release gates remain in force. No new user question was sent. + +## Authority + +- [Photograph of the original twelve GF9 Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), freshly reinspected at `/tmp/dune-nexus-cards.jpg`. +- [GF9 base rulebook, pp.13 and 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=13), freshly retrieved publisher-indexed text: Advanced support, casualty allocation, Sardaukar and Emperor payments. +- [GF9 November FAQ, pp.2–3 and 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2), freshly retrieved publisher-indexed text: special-force revival cap, ally payments, losses and Karama. +- [GF9 E3 rulebook, pp.9–11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11), publisher-indexed text and the previously acquired publisher-authored [mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf): modes, Homeworlds, Recruits and FAQ. +- [GF9 E1, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7) and [E2, p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7): free-revival income and La La La. The Salusa high face was visually reinspected in the component image at `/tmp/dune-card-economy-homeworlds.jpg`; provenance is retained in the [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md). + +No Emperor-specific Nexus FAQ or authenticated designer clarification resolving the combinations below was retrieved. Tournament compilations and older Avalon Hill rules are not substituted for GF9 authority. + +## The three printed panels + +| Mode | Condition | Printed operation, paraphrased | +| --- | --- | --- | +| Cunning | Native Emperor holder | Before formulating its Battle Plan, count five of its forces as Sardaukar if it has no Sardaukar in that battle. | +| Secret Ally: purchase | Emperor absent | When buying a Treachery Card, keep the buyer's spice, after showing possession of the amount otherwise payable. | +| Secret Ally: revival | Emperor absent | During Revival, revive three additional forces free beyond revival limits. This is an alternative to the purchase effect. | +| Betrayal: purchase | Emperor controlled by another player | During Bidding, force Emperor to pay for its ally's Treachery Card, covering at least the necessary amount. | +| Betrayal: battle | Emperor controlled by another player | While Battle Plans are being made, prevent Emperor's Sardaukar advantage. This is an alternative to the purchase reaction. | + +One card use buys one selected effect. The Cunning panel names five, without an up-to qualifier; its duration is this battle, unlike Ixian Cunning's express turn-wide duration. [Printed Emperor card](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) + +## Cunning: effective roles over conserved physical forces + +Native Sardaukar are an Advanced advantage. They have full strength two, except against Fremen where it is one. Ordinary Advanced support costs one spice per participating counter; unsupported counters contribute half their full strength. The winner may choose any losses consistent with dial and support. [Base, pp.13,19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19) + +Supported composition: + +1. Require an owned native card, an owned current battle, no actual Sardaukar in its eligible battle army, and an unsubmitted plan. A Sardaukar whose advantage was canceled is still physically Sardaukar; cancellation does not satisfy the absence condition. +2. Treat five existing ordinary counters as effective Sardaukar for that battle. Do not create forces, transfer starred pieces, alter elite reserves/Tanks or mark these counters as starred on the board. Clear their temporary role after the battle. +3. Keep two accounting layers: effective normal/temporary-Sardaukar strength for plans and losses, and actual normal/starred custody for removal. Losing three temporary Sardaukar sends three ordinary counters to Tanks. Losing the battle or an explosion removes actual counters through the ordinary physical path. +4. Against non-Fremen, a temporary Sardaukar contributes two supported or one unsupported. Against Fremen it contributes one supported or one-half unsupported. Do not import Fremen's free support. Basic has no useful Sardaukar enhancement; enabling doubled Basic strength would invent an Advanced advantage. E3 explicitly acknowledges Advanced-only Cunning effects. +5. Bind the battle/event, original eligible normal pool and temporary count. Do not enlarge a Homeworld's eligible defense army, count unrelated reserves or confer the benefit on allied forces. Preserve prior Truthtrance/Prescience commitments; include an owned future activation in legal-completion searches without actually spending the card. + +Karama can suppress Emperor's Sardaukar advantage before plan revelation. Cunning enhances that native advantage, so suppression applies to its temporary Sardaukar too; it does not destroy or move counters. The card supplies no general Karama immunity. [November FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) + +Salusa high names Sardaukar, without limiting support relief to starred tokens. Applying that relief to counters currently counted as Sardaukar is supported composition. Salusa population still uses its physical native starred pool; Cunning cannot raise its threshold. Preserve the existing [mid-battle Salusa threshold guard](HOMEWORLD_BENEFITS_RULES.md). Occupation effects remain subject to their unresolved lifecycle. [Homeworld components](HOMEWORLD_COMPONENT_AUDIT.md) + +## Secret Ally: retain the actual purchase price + +Use the final ordinary purchase price and verify the holder's spendable spice before accepting this effect. The card expressly requires possession; it cannot support bidding beyond one's means like Karama. The holder is unallied and Emperor is absent, so there is no formal ally contribution or Emperor income in this purchase. Retain the holder's balance rather than subtracting and later refunding it. Ordinary card custody, hand limit and purchased-card follow-ups still need a real completed sale. + +For an ordinary bank-paid auction, the practical result is one purchased card, unchanged buyer spice and a spent Nexus card. No bank-funded replacement payment is printed. The public proof need establish only that the price is held, not disclose excess private spice. These are implementation consequences of the component, not an extra auction rule. + +Special seller payments require a separate ruling: if Richese or another faction would receive that price, is the seller unpaid or paid by the bank? The text only tells the buyer to keep spice. Likewise, it does not expressly decide purchases outside Bidding, such as a card acquisition from an Ambassador. Do not silently route income or broaden a generic purchase hook to those sources. + +## Secret Ally: three additional free revivals + +The express additional/beyond-limits wording supplies a separate force allowance rather than spending the holder's ordinary three-force quota. It revives forces, not a leader or Cheap Hero. Recruits doubles free rates and changes the normal limit; it does not double a fixed three-counter card grant. [Printed card](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), [E3, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) + +The FAQ retains the one-per-turn Sardaukar/Fedaykin revival cap even through Ghola, Emperor alliance extras and Tleilaxu. Applying that distinction here preserves the special-type cap while extending total quantity. Emperor is absent, so an eligible native holder cannot actually be reviving Emperor Sardaukar. Free Cyborgs trigger no paid-Cyborg Ix bonus. Southern Hemisphere deployment still applies to eligible returned Fedaykin through the existing [deployment contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md). [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2) + +E1 pays Tleilaxu for factions using free revival or Ghola; E2's La La La prevents Free Revival during Revival. Their texts do not expressly distinguish this new Nexus return from the ordinary free-revival event. Preserve the already-pending [income accounting boundary](TLEILAXU_AMBASSADOR_RULES.md) rather than minting another payment or assuming immunity. No source gives this card a special exception to Tleilaxu's revival prohibition. [E1, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7), [E2, p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7) + +## Betrayal and remaining decisions + +Normal Emperor-funded auction help goes to the ally and is paid back to Emperor, according to November's FAQ. Therefore forcing its contribution cannot automatically be implemented as transferring its spice to the bank. Bind the actual allied purchase and its original price/contributions. [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2) + +The following material cases remain unresolved; no new question was sent: + +- **Short groups:** may Cunning operate with fewer than five eligible ordinary counters, or Secret Ally revive fewer than three when Tanks/typed limits cannot supply three? Neither panel says up to. Do not disguise the question as a silently truncated count. +- **Forced payment:** who chooses Emperor's compulsory contribution, what is the minimum when its ally already holds the full price, and what occurs if Emperor cannot cover the requested amount? The panel's necessary-amount wording does not settle the complete digital transaction. +- **Special purchases and revival suppression/income:** resolve the concrete boundaries above before connecting broad existing payment/free-revival adapters. + +The existing private reaction-policy question already covers Betrayal timing; do not duplicate it. A native holder and a Betrayal holder cannot possess the same unique Emperor card simultaneously, so do not manufacture Betrayal-versus-Cunning tests with duplicate physical copies. + +Suggested first package: one-battle Cunning with five available normal counters, genuine no-Sardaukar eligibility, native cancellation, physical casualty mapping, current Salusa support and prior commitments. Verify ordinary victories, traitors, explosions, Fremen, later battles, JSON response recovery, corruption, all-seat privacy and all bot profiles before claiming that bounded package. No runtime or tests were changed by this audit. diff --git a/game/bots.ts b/game/bots.ts index 239bf9a8..a01953f1 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2,6 +2,7 @@ import { nexusCardBotActions } from './nexus-card-options'; import { nexusTraitorBotActions } from './nexus-traitor-options'; import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options'; import { nexusSuboidBotActions } from './nexus-suboid-options'; +import { nexusAdvisorBotActions } from './nexus-advisor-options'; import { tupileIntelligenceActions } from './tupile-intelligence-options'; import { biddingEndActions, choamMarketPolicy } from './bidding-end-options'; import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options'; @@ -1325,9 +1326,11 @@ function policyActions(g: GameView): Action[] { g.auditor?.opponent === me.id && (g.auditor.count ?? 0) > 0; const movementThreat = - g.response.kind === 'fremenMovement' && + (g.response.kind === 'fremenMovement' && !!g.response.location && - fighterCount(me, splitLocation(g.response.location).territory) > 0; + fighterCount(me, splitLocation(g.response.location).territory) > 0) || + (g.response.kind === 'nexusAdvisorFlip' && + !!g.nexusAdvisors?.pending?.territories.some((territory) => fighterCount(me, territory) > 0)); const card = me.hand?.find((c) => g.responseControls?.cancelCards.includes(c.id), ); @@ -3323,6 +3326,8 @@ export function botActions(g: GameView): Action[] { : nexusTraitorBotActions(g); if (g.automaticContinuationPending) return []; if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); + const advisorConversion = nexusAdvisorBotActions(g); + if (advisorConversion.length) return advisorConversion; const suboids = nexusSuboidBotActions(g); if (suboids.length) return suboids; const faceDancers = nexusTleilaxuBotActions(g); diff --git a/game/engine.ts b/game/engine.ts index 460c9b10..bf6656db 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2,6 +2,7 @@ import { createNexusInspection, allowNexusInspection, answerNexusInspection, reo import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers'; import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids'; +import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors'; import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations'; import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards'; import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase'; @@ -791,6 +792,7 @@ export type ResponseWindow = { | 'eliteStrength' | 'fremenSupport' | 'advisorFlip' + | 'nexusAdvisorFlip' | 'bgCharity' | 'choamCharity' | 'choamInflation' @@ -1263,6 +1265,8 @@ export type Game = { nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; nexusSuboidHistory?: NexusSuboidReceipt[]; nexusSuboidLast?: { event: string; owner: string; turn: number }; + nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[]; + nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' }; nexusTraitorPending?: string | null; nexusTraitorParent?: { event: string; signature: string } | null; /** Null/absent disables the module; custody is installed at force placement. */ @@ -1746,6 +1750,118 @@ function commitNexusSuboids(g: Game, p: Player) { { faction: p.faction, name: 'Nexus Suboid strength' }); } +function nexusAdvisorFrame(g: Game, owner: string) { + const p = getPlayer(g, owner); + return JSON.stringify([g.status, g.turn, g.phase, g.active, g.movementRemaining, + p.shipped, p.moved, g.hajr, g.guildTimingGranted, g.guildTimingLocked]); +} +function nexusAdvisorRecordSignature(record: NonNullable[number]) { + return JSON.stringify([record.receipt.signature, record.stage, record.frame]); +} +function pendingNexusAdvisors(g: Game) { + return g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null; +} +function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) { + const record = pendingNexusAdvisors(g); + requireRule(record && response.kind === 'nexusAdvisorFlip' && + response.owner === record.receipt.owner && response.intent === record.receipt.event && + Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) && + Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length && + response.passed.every(id => g.players.some(p => p.id === id)), + 'The Nexus advisor conversion has lost its original response.'); + requireRule(g.status === 'playing' && g.phase === 5 && g.active === record.receipt.owner && + record.receipt.turn === g.turn && record.frame === nexusAdvisorFrame(g, record.receipt.owner), + 'The Nexus advisor conversion has lost its original Shipment and Movement action.'); + nexusRule(() => validateNexusAdvisors(g, record.receipt, true)); +} +function nexusAdvisorIntegrity(g: Game) { + const continuation = g.pendingTreacheryDiscard?.continuation; + const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, + continuation && 'resume' in continuation ? continuation.resume : null]; + const responses = contexts.flatMap(context => { + const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; + return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; + }).filter(response => response?.kind === 'nexusAdvisorFlip'); + const history = g.nexusAdvisorHistory; + if (history === undefined) { + requireRule(!g.nexusAdvisorLast && !responses.length, 'The Nexus advisor conversion has lost its saved history.'); + return; + } + requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, + 'Nexus advisor history requires its original module and played card.'); + const turns = new Set(); + for (const [index, record] of history.entries()) { + requireRule(record && record.receipt && typeof record.receipt === 'object' && + Object.keys(record).sort().join(',') === 'frame,receipt,signature,stage' && + ['pending', 'completed', 'canceled'].includes(record.stage) && + typeof record.frame === 'string' && record.signature === nexusAdvisorRecordSignature(record) && + record.receipt.event === JSON.stringify(['nexusAdvisors', record.receipt.turn, record.receipt.owner, index]) && + !turns.has(record.receipt.turn) && + (record.stage !== 'pending' || index === history.length - 1), + 'The Nexus advisor conversion history is malformed.'); + nexusRule(() => validateNexusAdvisors(g, record.receipt, record.stage === 'pending')); + turns.add(record.receipt.turn); + } + const last = history.at(-1)!; + requireRule(JSON.stringify(g.nexusAdvisorLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}), + 'The Nexus advisor conversion has lost its latest outcome.'); + requireRule((last.stage === 'pending') === (responses.length > 0), + 'The Nexus advisor conversion has lost or reopened its cancellation response.'); + if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), + 'The advisor conversion has lost its spent Nexus card.'); + for (const response of responses) validateNexusAdvisorResponse(g, response!); +} +function projectedNexusAdvisors(g: Game, id: string) { + if (!g.nexusCards?.cards) return null; + const record = pendingNexusAdvisors(g); + const pending = record ? {owner: record.receipt.owner, event: record.receipt.event, + territories: record.receipt.selections.map(selection => selection.territory)} : null; + const p = getPlayer(g, id); + if (p.faction !== 'beneGesserit' || g.nexusCards.cards.hands[id] !== 'beneGesserit') return {offer: null, pending}; + let blocked: string | null = null; + if (!g.advanced) blocked = 'Advisors are available only in the Advanced game.'; + else if (g.status !== 'playing' || g.phase !== 5 || g.active !== id) + blocked = 'Use Cunning during your own Shipment and Movement action.'; + else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; + else if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || + g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || record) + blocked = 'Finish the current interaction before converting advisors.'; + else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; + const territories = nexusRule(() => quoteNexusAdvisors(g, id)).territories; + if (!blocked && !territories.some(group => !group.blocked)) blocked = 'No advisor group is currently available to convert.'; + return {offer: {event: JSON.stringify(['nexusAdvisors', g.turn, id, g.nexusAdvisorHistory?.length ?? 0]), blocked, territories}, pending}; +} +function playNexusAdvisors(g: Game, p: Player, action: Action) { + const offer = projectedNexusAdvisors(g, p.id)?.offer; + requireRule(Object.keys(action).every(key => ['type', 'event', 'territories'].includes(key)) && + offer && action.event === offer.event && !offer.blocked, + offer?.blocked ?? 'Choose your current Nexus advisor conversion.'); + requireRule(Array.isArray(action.territories) && action.territories.every(t => typeof t === 'string'), + 'Select whole advisor territories.'); + const receipt = nexusRule(() => createNexusAdvisors(g, p.id, offer.event, action.territories as string[])); + const record = {receipt, stage: 'pending' as const, frame: nexusAdvisorFrame(g, p.id), signature: ''}; + record.signature = nexusAdvisorRecordSignature(record); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusAdvisorHistory ??= []).push(record); + g.nexusAdvisorLast = {event: receipt.event, stage: record.stage}; + g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []}; + log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`, + {faction: p.faction, name: 'Nexus advisor conversion declared'}); +} +function finishNexusAdvisors(g: Game, canceled: boolean) { + const record = pendingNexusAdvisors(g)!; + const p = getPlayer(g, record.receipt.owner); + if (!canceled) for (const selection of record.receipt.selections) delete p.advisors![selection.territory]; + record.stage = canceled ? 'canceled' : 'completed'; + record.signature = nexusAdvisorRecordSignature(record); + g.nexusAdvisorLast = {event: record.receipt.event, stage: record.stage}; + log(g, canceled + ? 'Karama prevented the entire Nexus advisor conversion. Every selected group remains advisors; the Nexus card stays spent.' + : `${p.name} converted every advisor in ${record.receipt.selections.map(s => territory(s.territory).name).join(', ')} to fighters. The forces remain in place and no shipment, movement or spice was spent.`, + {faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'}); +} + function battleInspectionContext(g: Game): BattleInspectionContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, @@ -6371,6 +6487,7 @@ function validateKaramaUse( currentIxAuctionDrawQuote(g, use.response, true, spendingCard); moritaniAllianceCancellationQuote(g, use.response); if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response); + if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response); if (isCombatResponseKind(use.response.kind)) currentCombatResponseQuote(g, { kind: 'response', @@ -11760,6 +11877,7 @@ function marketGholaIntegrity(g: Game) { nexusTraitorIntegrity(g); nexusFaceDancerIntegrity(g); nexusSuboidIntegrity(g); + nexusAdvisorIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); @@ -14146,6 +14264,7 @@ function finishResponse(g: Game, canceled: boolean) { currentFactionPayment(g); const response = g.response!; if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response); + if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response); const ecazCollectionQuote = response.kind === 'ecazCollection' ? currentEcazCollectionQuote(g, response, canceled) @@ -14174,6 +14293,10 @@ function finishResponse(g: Game, canceled: boolean) { ? moritaniAllianceCancellationQuote(g, response) : null; g.response = null; + if (response.kind === 'nexusAdvisorFlip') { + finishNexusAdvisors(g, canceled); + return; + } if (ecazCollectionQuote) { if (canceled) log( @@ -16970,6 +17093,7 @@ function applyActionInner( if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; } if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; } if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; } + if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; } if (t === 'nexusTraitorReturn') { requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; @@ -20439,6 +20563,7 @@ export function viewGame(state: Game, id: string) { nexusTraitors: projectedNexusTraitors(g, id), nexusTleilaxu: projectedNexusTleilaxu(g, id), nexusSuboids: projectedNexusSuboids(g, id), + nexusAdvisors: projectedNexusAdvisors(g, id), nexusAtreides: nexusAtreidesOffer(g, id), homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id), caladanReinforcement: projectedHomeworldVictoryReturn(g, id), diff --git a/game/karama-context.ts b/game/karama-context.ts index 0aed0a16..267a9ef8 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -80,6 +80,10 @@ const cancellationSources = { inspection: g.battle?.nexusInspection ?? null, preparation: g.battle?.preparation ?? null, }), + nexusAdvisorFlip: (g) => ({ + ...movementSource(g), + conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null, + }), advisor: (g, response) => response.advisorResume === 'ambassador' ? { diff --git a/game/nexus-advisor-options.ts b/game/nexus-advisor-options.ts new file mode 100644 index 00000000..ec33d848 --- /dev/null +++ b/game/nexus-advisor-options.ts @@ -0,0 +1,74 @@ +import type { Action, GameView } from './engine'; +import { fighterCount } from './advisors'; + +export function nexusAdvisorCanAct(game: GameView): boolean { + const owner = game.players.find((player) => player.id === game.me); + return !!( + game.status === 'playing' && + game.advanced && + game.phase === 5 && + game.active === game.me && + owner?.faction === 'beneGesserit' && + !owner.ally && + game.nexusCards?.card === 'beneGesserit' && + !game.automaticContinuationPending && + !game.truthtrance && + !game.response && + !game.decision && + !game.phaseOpening && + !game.nexusCards.waiting.length && + !game.nexusTraitors?.pending && + !game.nexusAdvisors?.pending && + game.nexusAdvisors?.offer && + !game.nexusAdvisors.offer.blocked + ); +} + +/** Whole-territory legality and quantities come from the private server quote. */ +export function nexusAdvisorAction( + game: GameView, + event: string, + territories: readonly string[], +): Action | null { + if (!nexusAdvisorCanAct(game)) return null; + const offer = game.nexusAdvisors!.offer!; + if ( + !event || + offer.event !== event || + !territories.length || + new Set(territories).size !== territories.length || + !territories.every((territory) => + offer.territories.some( + (choice) => + choice.territory === territory && + !choice.blocked && + Number.isSafeInteger(choice.count) && + choice.count > 0, + ), + ) + ) + return null; + return { type: 'nexusAdvisors', event, territories: [...territories] }; +} + +/** Prefer publicly favorable battles, using no hidden cards or No-Field values. + * This is the shared legal policy, not a calibrated battle-strength estimate. */ +export function nexusAdvisorBotActions(game: GameView): Action[] { + if (!nexusAdvisorCanAct(game)) return []; + const owner = game.players.find((player) => player.id === game.me)!; + const offer = game.nexusAdvisors!.offer!; + const territories = offer.territories + .filter((choice) => { + if (choice.blocked) return false; + const enemies = game.players + .filter((player) => player.id !== owner.id && player.id !== owner.ally) + .reduce( + (count, player) => count + fighterCount(player, choice.territory), + 0, + ); + return enemies > 0 && enemies < choice.count; + }) + .map((choice) => choice.territory); + const action = nexusAdvisorAction(game, offer.event, territories); + return action ? [action] : []; +} diff --git a/game/nexus-advisors.ts b/game/nexus-advisors.ts new file mode 100644 index 00000000..a3abd335 --- /dev/null +++ b/game/nexus-advisors.ts @@ -0,0 +1,363 @@ +import { + gameDistance, + gameTerritories, + location, + MOBILE_LOCATION, + splitLocation, + territory, + validLocation, +} from './board'; +import { + settledBoard, + type BoardContext, + type BoardSeat, +} from './board-resolution-quote'; +import { presenceAt, presenceByLocation } from './force-presence'; +import { fighterCount } from './advisors'; +import { territoryEntryBlock } from './occupancy'; +import { ecazOccupancyIdentity } from './ecaz-occupy'; + +export type NexusAdvisorContext = BoardContext & { turn: number }; +export type NexusAdvisorSelection = { + territory: string; + count: number; + locations: { location: string; count: number }[]; + noField: string | null; + stance: { lockedTurn?: number }; +}; +export type NexusAdvisorReceipt = { + version: 1; + event: string; + owner: string; + turn: number; + selections: NexusAdvisorSelection[]; + signature: string; +}; +function requireAdvisors( + condition: unknown, + message: string, +): asserts condition { + if (!condition) throw new Error(message); +} +function record(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) + ); +} +function exact(value: unknown, keys: string[]): boolean { + return ( + record(value) && + Object.keys(value).sort().join(',') === [...keys].sort().join(',') + ); +} +const whole = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; +const text = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +function validStance( + stance: unknown, + turn: number, +): stance is { lockedTurn?: number } { + return ( + record(stance) && + Object.keys(stance).every((key) => key === 'lockedTurn') && + (stance.lockedTurn === undefined || + (whole(stance.lockedTurn) && stance.lockedTurn <= turn)) + ); +} +function board(context: NexusAdvisorContext, owner: string) { + requireAdvisors( + whole(context.turn) && + context.turn > 0 && + typeof context.advanced === 'boolean' && + Array.isArray(context.players), + 'Nexus advisors need a valid public board and turn.', + ); + ecazOccupancyIdentity(context.players, owner, { + kind: 'territory', + id: 'polar_sink', + }); + const player = context.players.find((p) => p.id === owner)!; + requireAdvisors( + player.faction === 'beneGesserit', + 'Only native Bene Gesserit may convert Nexus advisors.', + ); + const sites = gameTerritories(context); + for (const p of context.players) + if (p.advisors !== undefined) { + requireAdvisors( + record(p.advisors), + 'Advisor stances need a valid territory map.', + ); + for (const [id, stance] of Object.entries(p.advisors)) { + // Homeworld invasions are outside the advisor territory subsystem. + if (id.startsWith('homeworld:')) continue; + requireAdvisors( + sites.some((site) => site.id === id) && + validStance(stance, context.turn), + 'Invalid original advisor territory stance.', + ); + } + } + const settled = settledBoard(context); + return { player, sites, settled }; +} +function selection(player: BoardSeat, to: string): NexusAdvisorSelection { + const locations = Object.entries(player.forces) + .filter(([key, count]) => count > 0 && splitLocation(key).territory === to) + .sort(([one], [two]) => one.localeCompare(two)) + .map(([key, count]) => ({ location: key, count })); + const marker = player.noField?.deployed?.location; + const noField = + marker?.territory === to ? location(marker.territory, marker.sector) : null; + const count = + locations.reduce((sum, group) => sum + group.count, 0) + (noField ? 1 : 0); + return { + territory: to, + count, + locations, + noField, + stance: + player.advisors![to].lockedTurn === undefined + ? {} + : { lockedTurn: player.advisors![to].lockedTurn }, + }; +} +function stormBlocked( + context: NexusAdvisorContext, + players: readonly BoardSeat[], + owner: BoardSeat, + to: string, +): boolean { + if (context.storm === 0 || territory(to).type === 'polar') return false; + const inStorm = (key: string) => + splitLocation( + key === MOBILE_LOCATION + ? (context.mobileStronghold?.location ?? key) + : key, + ).sector === context.storm; + const keys = (p: BoardSeat) => + Object.entries(presenceByLocation(p)) + .filter( + ([key, count]) => count > 0 && splitLocation(key).territory === to, + ) + .map(([key]) => key); + const own = keys(owner); + if (own.some(inStorm)) return true; + // Only storm-caused separation is an unresolved interaction. No general + // requirement to be in a battle-capable territory is imposed by this card. + for (const other of players) { + if ( + other.id === owner.id || + other.id === owner.ally || + !fighterCount(other, to) + ) + continue; + const theirs = keys(other); + if ( + own.some((x) => + theirs.some((y) => x === y || gameDistance(context, x, y) === 0), + ) && + !own.some((x) => + theirs.some( + (y) => + !inStorm(x) && + !inStorm(y) && + (x === y || gameDistance(context, x, y, inStorm) === 0), + ), + ) + ) + return true; + } + return false; +} +export function quoteNexusAdvisors( + context: NexusAdvisorContext, + owner: string, +): { + territories: { territory: string; count: number; blocked: string | null }[]; +} { + const { player, sites, settled } = board(context, owner); + if (!context.advanced) return { territories: [] }; + const territories = sites + .filter( + (site) => + player.advisors?.[site.id] && + presenceAt(player, site.id) > 0 && + !settled.released.some( + (release) => + release.player === owner && release.territory === site.id, + ), + ) + .map((site) => { + const original = selection(player, site.id); + let blocked = territoryEntryBlock(settled.players, owner, site.id); + if (!blocked && player.advisors![site.id].lockedTurn === context.turn) + blocked = + 'Whether Nexus Cunning overrides the same-turn advisor lock is unresolved.'; + if (!blocked && stormBlocked(context, settled.players, player, site.id)) + blocked = + 'Nexus advisor conversion involving storm-blocked forces awaits a ruling.'; + return { territory: site.id, count: original.count, blocked }; + }); + return { territories }; +} +function signature(receipt: Omit): string { + return JSON.stringify([ + 'nexusAdvisors', + receipt.version, + receipt.event, + receipt.owner, + receipt.turn, + receipt.selections.map((s) => [ + s.territory, + s.count, + s.locations.map((group) => [group.location, group.count]), + s.noField, + Object.hasOwn(s.stance, 'lockedTurn') ? [s.stance.lockedTurn] : null, + ]), + ]); +} +function validateReceipt(receipt: NexusAdvisorReceipt): void { + requireAdvisors( + exact(receipt, [ + 'version', + 'event', + 'owner', + 'turn', + 'selections', + 'signature', + ]) && + receipt.version === 1 && + text(receipt.event) && + text(receipt.owner) && + whole(receipt.turn) && + receipt.turn > 0 && + Array.isArray(receipt.selections) && + receipt.selections.length > 0 && + new Set(receipt.selections.map((s) => s.territory)).size === + receipt.selections.length, + 'Invalid saved Nexus advisor selection.', + ); + for (const chosen of receipt.selections) { + requireAdvisors( + exact(chosen, ['territory', 'count', 'locations', 'noField', 'stance']) && + text(chosen.territory) && + validLocation( + chosen.territory, + territory(chosen.territory).sectors[0], + ) && + whole(chosen.count) && + chosen.count > 0 && + Array.isArray(chosen.locations) && + validStance(chosen.stance, receipt.turn) && + (!Object.hasOwn(chosen.stance, 'lockedTurn') || + chosen.stance.lockedTurn !== undefined), + 'Invalid original Nexus advisor group.', + ); + const validKey = (key: unknown) => { + if (!text(key)) return false; + const loc = splitLocation(key); + return ( + loc.territory === chosen.territory && + key === location(loc.territory, loc.sector) && + validLocation(loc.territory, loc.sector) + ); + }; + requireAdvisors( + chosen.locations.every( + (group) => + exact(group, ['location', 'count']) && + validKey(group.location) && + whole(group.count) && + group.count > 0, + ) && + new Set(chosen.locations.map((group) => group.location)).size === + chosen.locations.length && + (chosen.noField === null || validKey(chosen.noField)) && + chosen.count === + chosen.locations.reduce((sum, group) => sum + group.count, 0) + + (chosen.noField ? 1 : 0), + 'The Nexus advisor receipt needs exact original public force counts.', + ); + } + requireAdvisors( + receipt.signature === signature(receipt), + 'The Nexus advisor selection has lost its original stance or force counts.', + ); +} +export function createNexusAdvisors( + context: NexusAdvisorContext, + owner: string, + event: string, + territories: string[], +): NexusAdvisorReceipt { + const quote = quoteNexusAdvisors(context, owner); + requireAdvisors( + text(event) && + Array.isArray(territories) && + territories.length > 0 && + territories.every(text) && + new Set(territories).size === territories.length, + 'Choose a nonempty distinct set of advisor territories.', + ); + for (const to of territories) { + const offer = quote.territories.find((offer) => offer.territory === to); + requireAdvisors( + offer && !offer.blocked, + offer?.blocked ?? 'Choose an original advisor territory.', + ); + } + const player = context.players.find((p) => p.id === owner)!; + const receipt: NexusAdvisorReceipt = { + version: 1, + event, + owner, + turn: context.turn, + selections: territories.map((to) => selection(player, to)), + signature: '', + }; + receipt.signature = signature(receipt); + validateNexusAdvisors(context, receipt, true); + return receipt; +} +export function validateNexusAdvisors( + context: NexusAdvisorContext, + receipt: NexusAdvisorReceipt, + pending: boolean, +): void { + validateReceipt(receipt); + requireAdvisors( + typeof pending === 'boolean' && + whole(context.turn) && + context.turn >= receipt.turn && + context.players.some( + (p) => p.id === receipt.owner && p.faction === 'beneGesserit', + ), + 'The Nexus advisor receipt needs its original owner and turn.', + ); + if (!pending) return; + requireAdvisors( + context.turn === receipt.turn, + 'Complete the Nexus advisor conversion in its original turn.', + ); + const quote = quoteNexusAdvisors(context, receipt.owner), + player = context.players.find((p) => p.id === receipt.owner)!; + for (const chosen of receipt.selections) { + const current = quote.territories.find( + (offer) => offer.territory === chosen.territory, + ); + requireAdvisors( + current && !current.blocked, + current?.blocked ?? 'The original advisor group is no longer available.', + ); + const actual = selection(player, chosen.territory); + requireAdvisors( + JSON.stringify(actual) === JSON.stringify(chosen), + 'The pending Nexus conversion must retain its exact original forces and stance.', + ); + } +} diff --git a/game/reference.ts b/game/reference.ts index d04ac30e..d058d256 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '), category: 'Advanced & expansions', coverage: 'Partial', - summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and Tleilaxu/Ixian Cunning are integrated. Other effects and complete module play remain in development.', + summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian and Bene Gesserit Cunning are integrated. Other effects and complete module play remain in development.', steps: [ 'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.', 'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.', @@ -3359,15 +3359,18 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.', 'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.', 'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.', + 'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.', + 'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.', + 'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.', 'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.', ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh and turn-wide Suboid strength. Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspection and exchange controls, atomic Tleilaxu refresh, and Ixian activation with Cyborg-only paid support. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, controls, bot and recovery regressions; final native Cunning acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength and whole-territory advisor conversion. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection with one Karama response. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; final advisor acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts'] }, ], }, ...[ diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index cf73bdf1..96dcbb91 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -74,6 +74,7 @@ const RESPONSES = { voice: true, prescience: true, nexusPrescience: true, + nexusAdvisorFlip: true, advisor: true, emperorIncome: true, richesePurchaseIncome: true, diff --git a/tests/fixture-nexus-advisors.ts b/tests/fixture-nexus-advisors.ts new file mode 100644 index 00000000..78ce500b --- /dev/null +++ b/tests/fixture-nexus-advisors.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { + nexusTraitorFixture, + nexusTraitorInventory, +} from './fixture-nexus-traitors'; +import { nexusAllow } from './fixture-nexus-cards'; + +/** Final identities and Advanced rules precede genuine Nexus setup. Only + * conserved board positions and the held physical Nexus identity are staged. */ +export function nexusAdvisorFixture( + options: { + seatIds?: [string, string, string]; + advanced?: boolean; + opponentFaction?: 'guild' | 'harkonnen' | 'richese'; + } = {}, +): Game { + const g = nexusTraitorFixture({ + ownerFaction: 'beneGesserit', + opponentFaction: options.opponentFaction, + advanced: options.advanced ?? true, + seatIds: options.seatIds, + phase: 5, + }); + const [owner, opponent] = g.players; + const cards = g.nexusCards!.cards!; + const index = cards.deck.indexOf('beneGesserit'); + assert.ok(index >= 0); + cards.deck[index] = cards.hands[owner.id]!; + cards.hands[owner.id] = 'beneGesserit'; + for (const p of g.players) { + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + p.shipped = false; + p.moved = 0; + if (p.elites) { + p.elites.forces = {}; + p.elites.reserves = 3; + p.elites.tanks = 0; + } + delete p.advisors; + } + owner.forces = { + 'arrakeen:10': 2, + 'pasty_mesa:5': 1, + 'pasty_mesa:6': 2, + 'carthag:11': 1, + 'polar_sink:0': 1, + }; + owner.reserves = 13; + if (g.advanced) + owner.advisors = { arrakeen: {}, pasty_mesa: {}, carthag: {} }; + opponent.forces = { 'arrakeen:10': 1, 'pasty_mesa:5': 1, 'carthag:11': 1 }; + opponent.reserves = 17; + Object.assign(g, { + active: owner.id, + order: g.players.map((p) => p.id), + ready: [], + decision: null, + response: null, + phaseOpening: null, + movementRemaining: null, + }); + nexusAdvisorInventory(g); + return g; +} + +export const nexusAdvisorPlayer = (g: Game) => + g.players.find((p) => p.faction === 'beneGesserit')!; +export const nexusAdvisorInventory = nexusTraitorInventory; + +export function holdAdvisorKarama(g: Game, seatId = g.players[1].id): string { + const index = g.deck.findIndex((card) => card.effect === 'karama'); + assert.ok(index >= 0); + const card = g.deck.splice(index, 1)[0]; + g.players.find((p) => p.id === seatId)!.hand.push(card); + return card.id; +} + +export function beginNexusAdvisorFlip(g: Game, territories: string[]): Game { + const owner = nexusAdvisorPlayer(g); + const offer = viewGame(g, owner.id).nexusAdvisors!.offer!; + assert.equal(offer.blocked, null); + return applyAction(g, owner.id, { + type: 'nexusAdvisors', + event: offer.event, + territories, + }); +} + +export const allowNexusAdvisorFlip = nexusAllow; diff --git a/tests/nexus-advisor-bots.test.ts b/tests/nexus-advisor-bots.test.ts new file mode 100644 index 00000000..cc9d8081 --- /dev/null +++ b/tests/nexus-advisor-bots.test.ts @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame } from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { botActions } from '../game/bots'; +import { nexusAdvisorBotActions } from '../game/nexus-advisor-options'; +import { + nexusAdvisorFixture, + nexusAdvisorInventory, + beginNexusAdvisorFlip, + allowNexusAdvisorFlip, + holdAdvisorKarama, +} from './fixture-nexus-advisors'; + +void test('all profiles announce favorable whole advisor groups and settle one conserved real conversion', () => { + const initial = nexusAdvisorFixture(); + for (const difficulty of DIFFICULTIES) { + const v = viewGame(initial, 'p'); + v.players[0].bot = difficulty; + const before = structuredClone(v); + const actions = botActions(v); + assert.equal(actions.length, 1); + assert.equal(actions[0].type, 'nexusAdvisors'); + assert.deepEqual([...(actions[0].territories as string[])].sort(), [ + 'arrakeen', + 'pasty_mesa', + ]); + assert.deepEqual(v, before); + const g = allowNexusAdvisorFlip(applyAction(initial, 'p', actions[0])); + assert.equal(g.players[0].advisors?.arrakeen, undefined); + assert.equal(g.players[0].advisors?.pasty_mesa, undefined); + assert.ok(g.players[0].advisors?.carthag); + assert.deepEqual(g.players[0].forces, initial.players[0].forces); + assert.equal(g.players[0].spice, initial.players[0].spice); + assert.equal(g.players[0].moved, initial.players[0].moved); + assert.equal(g.players[0].shipped, initial.players[0].shipped); + nexusAdvisorInventory(g); + } +}); + +void test('advisor policy respects blocked groups and uses only public fighter presence', () => { + const v = viewGame(nexusAdvisorFixture(), 'p'); + v.players[0].bot = 'Brutal'; + const offer = v.nexusAdvisors!.offer!; + offer.territories.find((t) => t.territory === 'arrakeen')!.blocked = + 'Fresh advisor conversion awaits its ruling.'; + for (const player of v.players) + for (const key of ['hand', 'traitors', 'faceDancers', 'spice']) + Object.defineProperty(player, key, { + get() { + throw new Error(`Private ${key} read`); + }, + }); + assert.deepEqual(nexusAdvisorBotActions(v)[0]?.territories, ['pasty_mesa']); + v.players[1].forces['pasty_mesa:5'] = 10; + assert.deepEqual(nexusAdvisorBotActions(v), []); + v.players[1].forces = {}; + assert.deepEqual(nexusAdvisorBotActions(v), []); + Object.defineProperty(v.players[1], 'noField', { + value: { + deployed: { + location: { territory: 'pasty_mesa', sector: 5 }, + get value() { + throw new Error('Read No-Field denomination'); + }, + get tokenId() { + throw new Error('Read No-Field identity'); + }, + }, + get tokens() { + throw new Error('Read private No-Field inventory'); + }, + }, + }); + assert.deepEqual(nexusAdvisorBotActions(v)[0]?.territories, ['pasty_mesa']); +}); + +void test('response bots use the ordinary whole-use Karama controls when their fighters are threatened', () => { + const initial = nexusAdvisorFixture(); + const card = holdAdvisorKarama(initial); + const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']); + for (const difficulty of DIFFICULTIES) { + const v = viewGame(g, 'q'); + v.players.find((p) => p.id === 'q')!.bot = difficulty; + const action = botActions(v)[0]; + assert.ok(action); + if (difficulty === 'Easy' || difficulty === 'Medium') + assert.deepEqual(action, { type: 'passResponse' }); + else assert.deepEqual(action, { type: 'card', card, mode: 'cancel' }); + } +}); diff --git a/tests/nexus-advisor-controls.test.ts b/tests/nexus-advisor-controls.test.ts new file mode 100644 index 00000000..58c1e41a --- /dev/null +++ b/tests/nexus-advisor-controls.test.ts @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame, type GameView } from '../game/engine'; +import { NexusAdvisors } from '../components/nexus-advisors'; +import { + nexusAdvisorAction, + nexusAdvisorCanAct, +} from '../game/nexus-advisor-options'; +import { + nexusAdvisorFixture, + beginNexusAdvisorFlip, + holdAdvisorKarama, +} from './fixture-nexus-advisors'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next( + specifier === 'next/image' + ? 'vinext/shims/image' + : specifier === 'next/link' + ? 'vinext/shims/link' + : specifier, + context, + ); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries( + [...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [ + match[1], + match[1], + ]), + ); + return { + format: 'module', + source: `export default ${JSON.stringify(classes)}`, + shortCircuit: true, + }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +const html = (game: GameView, busy = false) => + renderToStaticMarkup(createElement(NexusAdvisors, { game, busy, act() {} })); + +void test('advisor controls name whole territories and counts with an explicit empty initial selection', () => { + const v = viewGame(nexusAdvisorFixture(), 'p'); + const offer = v.nexusAdvisors!.offer!; + const before = structuredClone(v); + const text = html(v); + assert.match(text, /Pasty Mesa: 3 advisors/); + assert.match(text, /Arrakeen: 2 advisors/); + assert.equal((text.match(/class="decision-checkbox min-h-11"/g) ?? []).length, offer.territories.length); + assert.match(text, /Select all available/); + assert.match(text, /0 territories selected/); + assert.match(text, /across all sectors/); + assert.match( + text, + /costs no spice and uses no shipment or movement allowance/, + ); + assert.doesNotMatch(text, /checked=""/); + assert.match(text, /disabled=""[^>]*>Convert selected advisors/); + const selected = offer.territories + .filter((choice) => !choice.blocked) + .map((choice) => choice.territory); + assert.ok(selected.length >= 2); + assert.deepEqual(nexusAdvisorAction(v, offer.event, selected), { + type: 'nexusAdvisors', + event: offer.event, + territories: selected, + }); + assert.deepEqual(v, before); + assert.match(html(v, true), /disabled=""/); +}); + +void test('shared advisor selection binds event, unique whole groups and projected source boundary reasons', () => { + const v = viewGame(nexusAdvisorFixture(), 'p'); + const offer = v.nexusAdvisors!.offer!; + const place = offer.territories.find( + (choice) => choice.territory === 'arrakeen', + )!; + assert.ok(nexusAdvisorAction(v, offer.event, ['arrakeen'])); + for (const choices of [ + [], + ['arrakeen', 'arrakeen'], + ['not-a-territory'], + ['arrakeen:10'], + ]) + assert.equal(nexusAdvisorAction(v, offer.event, choices), null); + assert.equal(nexusAdvisorAction(v, 'stale', ['arrakeen']), null); + // Consumer-only projections prove reasons are displayed rather than guessed + // from a source rule or the current territory's board graphics. + place.blocked = + 'Newly accompanied advisors await the same-turn conversion ruling.'; + offer.territories.find( + (choice) => choice.territory === 'pasty_mesa', + )!.blocked = 'Conversion in storm awaits its timing ruling.'; + assert.equal(nexusAdvisorAction(v, offer.event, ['arrakeen']), null); + assert.match(html(v), /Newly accompanied advisors await/); + assert.match(html(v), /Conversion in storm awaits/); + offer.blocked = 'Finish the current movement response first.'; + assert.equal(nexusAdvisorCanAct(v), false); + assert.match(html(v), /Finish the current movement response first/); +}); + +void test('advisor selection rejects other actors, Basic, wrong phase and all interrupted windows', () => { + const original = viewGame(nexusAdvisorFixture(), 'p'); + for (const mutate of [ + (v: GameView) => { + v.me = 'q'; + }, + (v: GameView) => { + v.advanced = false; + }, + (v: GameView) => { + v.phase = 4; + }, + (v: GameView) => { + v.active = 'q'; + }, + (v: GameView) => { + v.players[0].ally = 'q'; + }, + (v: GameView) => { + v.nexusCards!.card = null; + }, + (v: GameView) => { + v.automaticContinuationPending = true; + }, + (v: GameView) => { + v.decision = { kind: 'nullentropy', player: 'p' }; + }, + (v: GameView) => { + v.truthtrance = { + stage: 'priority', + queue: [], + passed: [], + question: null, + }; + }, + (v: GameView) => { + v.nexusCards!.waiting = ['q']; + }, + (v: GameView) => { + v.nexusTraitors = { + offer: null, + pending: { + event: 'other', + owner: 'q', + mode: 'cunning', + count: 1, + choices: [], + }, + }; + }, + ]) { + const v = structuredClone(original); + mutate(v); + assert.equal( + nexusAdvisorAction(v, v.nexusAdvisors!.offer!.event, ['arrakeen']), + null, + ); + } +}); + +void test('public pending territory set explains one Karama cancellation without exposing the private offer', () => { + const initial = nexusAdvisorFixture(); + holdAdvisorKarama(initial); + const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']); + assert.equal(g.response?.kind, 'nexusAdvisorFlip'); + for (const id of ['p', 'q', 'r']) { + const v = viewGame(g, id); + assert.ok(v.nexusAdvisors!.pending); + Object.defineProperty(v.nexusAdvisors!, 'offer', { + get() { + throw new Error('Read offer during public pending response'); + }, + }); + for (const player of v.players) + Object.defineProperty(player, 'hand', { + get() { + throw new Error('Read private hand'); + }, + }); + const text = html(v); + assert.match(text, /Arrakeen, Pasty Mesa/); + assert.match(text, /Karama can cancel the entire selection/); + assert.doesNotMatch(text, /checkbox|Convert selected advisors/); + } +}); + +void test('actual response panel names Nexus conversion and offers Karama without printing its internal event', () => { + const initial = nexusAdvisorFixture(); + holdAdvisorKarama(initial); + const g = beginNexusAdvisorFlip(initial, ['arrakeen', 'pasty_mesa']); + const v = viewGame(g, 'q'); + // Real component rendering with CSS selector names preserved above; this is + // structural response verification, not a browser or visual acceptance claim. + const text = renderToStaticMarkup( + createElement(GameTable, { + game: v, + send: async () => {}, + onExit() {}, + busy: false, + }), + ); + assert.match(text, /Bene Gesserit Nexus advisor conversion/); + assert.match(text, /Cancel with Karama/); + assert.match(text, /Allow this power/); + assert.doesNotMatch(text, /nexusAdvisors/); + const circleTitles = [ + ...text.matchAll(/(Player circle [^<]+)<\/title>/g), + ].map((match) => match[1]); + assert.equal(circleTitles.length, 6); + assert.ok( + circleTitles.every((title) => + /^Player circle [1-6], sector \d+: .+/.test(title), + ), + ); + assert.ok(circleTitles.some((title) => title.includes('Bene Gesserit'))); +}); diff --git a/tests/nexus-advisor-interruptions.test.ts b/tests/nexus-advisor-interruptions.test.ts new file mode 100644 index 00000000..a3a00cea --- /dev/null +++ b/tests/nexus-advisor-interruptions.test.ts @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Game, +} from '../game/engine'; +import { + nexusAdvisorFixture, + nexusAdvisorPlayer, + holdAdvisorKarama, + beginNexusAdvisorFlip, + allowNexusAdvisorFlip, +} from './fixture-nexus-advisors'; +import { nexusReload } from './fixture-nexus-cards'; +import { treacheryDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; + +function inventory(g: Game) { + assert.deepEqual( + [ + ...g.deck, + ...g.discard, + ...g.players.flatMap((p) => p.hand), + ...g.richeseCache!, + ] + .map((card) => card.id) + .sort(), + [...treacheryDeck(g.expansions), ...richeseCards()] + .map((card) => card.id) + .sort(), + ); + for (const p of g.players) + assert.equal( + p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), + 20, + ); +} + +function paidSearch() { + let g = nexusAdvisorFixture({ opponentFaction: 'richese' }); + const boxIndex = g.richeseCache!.findIndex( + (card) => card.effect === 'nullentropyBox', + ); + assert.ok(boxIndex >= 0); + const box = g.richeseCache!.splice(boxIndex, 1)[0]; + g.players[1].hand.push(box); + const karama = holdAdvisorKarama(g); + for (const kind of ['shield', 'projectile']) { + const index = g.deck.findIndex((card) => card.kind === kind); + assert.ok(index >= 0); + g.discard.push(g.deck.splice(index, 1)[0]); + } + g.players[1].spice = 10; + const original = nexusReload(g); + g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']); + const response = structuredClone(g.response); + g = applyAction(g, 'q', { type: 'card', card: box.id }); + assert.equal(g.decision?.kind, 'nullentropy'); + assert.equal(g.pendingNullentropy?.resume.response?.kind, 'nexusAdvisorFlip'); + assert.deepEqual(g.pendingNullentropy!.resume.response, response); + assert.equal(g.players[1].spice, 8); + inventory(g); + return { g, original, response, karama, box: box.id }; +} + +function finishSearch(g: Game) { + return applyAction(g, 'q', { + type: 'decision', + event: g.pendingNullentropy!.event, + card: g.discard[0].id, + }); +} + +void test('paid Nullentropy suspends the advisor response and restores its exact batch once after a private JSON recovery', () => { + const f = paidSearch(); + let g = nexusReload(f.g); + const before = nexusReload(g); + assert.deepEqual(normalizeAutomaticGame(g), before); + for (const player of g.players) { + assert.deepEqual( + viewGame(nexusReload(g), player.id), + viewGame(g, player.id), + ); + if (player.id !== 'q') + assert.equal(viewGame(g, player.id).nullentropy, null); + } + assert.deepEqual( + nexusAdvisorPlayer(g).advisors, + nexusAdvisorPlayer(f.original).advisors, + ); + const selected = g.discard[0].id; + g = finishSearch(g); + assert.deepEqual(g.response, f.response); + assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'pending'); + assert.equal(g.players[1].spice, 8); + assert.ok(g.players[1].hand.some((card) => card.id === selected)); + assert.equal(g.discard.filter((card) => card.id === f.box).length, 1); + g = allowNexusAdvisorFlip(nexusReload(g)); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} }); + assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'completed'); + assert.equal(g.nexusAdvisorHistory!.length, 1); + assert.deepEqual( + g.players.map((player) => player.forces), + f.original.players.map((player) => player.forces), + ); + assert.equal(g.players[1].spice, 8); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g)); + inventory(g); +}); + +void test('the restored advisor response still accepts one Karama after Box recovery without undoing the paid search', () => { + const f = paidSearch(); + let g = finishSearch(nexusReload(f.g)); + const selected = f.g.discard[0].id; + g = applyAction(g, 'q', { type: 'card', card: f.karama, mode: 'cancel' }); + g = allowNexusAdvisorFlip(nexusReload(g)); + assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'canceled'); + assert.deepEqual( + nexusAdvisorPlayer(g).advisors, + nexusAdvisorPlayer(f.original).advisors, + ); + assert.equal(g.players[1].spice, 8); + assert.ok(g.players[1].hand.some((card) => card.id === selected)); + assert.equal(g.discard.filter((card) => card.id === f.box).length, 1); + assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1); + assert.equal( + g.nexusCards!.cards!.discard.filter((card) => card === 'beneGesserit') + .length, + 1, + ); + inventory(g); +}); + +void test('damaged advisor parents inside a paid search reject reads, automatic progress and card selection immutably', () => { + const { g: paid } = paidSearch(); + const mutations: ((g: Game) => void)[] = [ + (g) => { + g.pendingNullentropy!.resume.response = null; + }, + (g) => { + g.pendingNullentropy!.resume.response!.intent = 'stale'; + }, + (g) => { + g.pendingNullentropy!.resume.response!.owner = 'q'; + }, + (g) => { + delete g.nexusAdvisorLast; + }, + (g) => { + delete g.nexusAdvisorHistory; + }, + (g) => { + g.nexusAdvisorHistory![0].stage = 'completed'; + }, + (g) => { + g.players[0].moved++; + }, + (g) => { + g.players[0].forces['arrakeen:10']++; + g.players[0].reserves--; + }, + ]; + for (const mutate of mutations) { + const g = nexusReload(paid); + mutate(g); + const before = nexusReload(g); + for (const player of g.players) assert.throws(() => viewGame(g, player.id)); + assert.throws(() => normalizeAutomaticGame(g)); + assert.throws(() => finishSearch(g)); + assert.deepEqual(g, before); + } +}); diff --git a/tests/nexus-advisors-quote.test.ts b/tests/nexus-advisors-quote.test.ts new file mode 100644 index 00000000..5c3ec226 --- /dev/null +++ b/tests/nexus-advisors-quote.test.ts @@ -0,0 +1,289 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + quoteNexusAdvisors, + createNexusAdvisors, + validateNexusAdvisors, + type NexusAdvisorContext, + type NexusAdvisorReceipt, +} from '../game/nexus-advisors'; + +const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; +function context(): NexusAdvisorContext { + return { + advanced: true, + turn: 3, + storm: 18, + order: ['b', 'g', 'r'], + players: [ + { + id: 'b', + faction: 'beneGesserit', + ally: null, + forces: { 'hagga_basin:12': 2, 'hagga_basin:13': 1, 'arrakeen:10': 2 }, + advisors: { hagga_basin: { lockedTurn: 2 }, arrakeen: {} }, + }, + { + id: 'g', + faction: 'guild', + ally: null, + forces: { 'hagga_basin:12': 1, 'arrakeen:10': 1 }, + }, + { id: 'r', faction: 'richese', ally: null, forces: {} }, + ], + }; +} +void test('one territory selection includes all physical advisor sectors and permits several territory groups as one immutable receipt', () => { + const c = context(), + before = reload(c), + quote = quoteNexusAdvisors(c, 'b'); + assert.deepEqual( + quote.territories + .map((t) => [t.territory, t.count, t.blocked]) + .sort((one, two) => String(one[0]).localeCompare(String(two[0]))), + [ + ['arrakeen', 2, null], + ['hagga_basin', 3, null], + ], + ); + const receipt = createNexusAdvisors(c, 'b', 'advisor-1', [ + 'hagga_basin', + 'arrakeen', + ]); + assert.deepEqual(receipt.selections[0], { + territory: 'hagga_basin', + count: 3, + locations: [ + { location: 'hagga_basin:12', count: 2 }, + { location: 'hagga_basin:13', count: 1 }, + ], + noField: null, + stance: { lockedTurn: 2 }, + }); + assert.deepEqual(c, before); + validateNexusAdvisors(reload(c), reload(receipt), true); + receipt.selections[0].locations[0].count = 0; + assert.deepEqual(c, before); +}); +void test('Basic and automatically released lone advisors yield no choice; Homeworld stances are not selected', () => { + const c = context(); + assert.deepEqual(quoteNexusAdvisors({ ...c, advanced: false }, 'b'), { + territories: [], + }); + c.players[1].forces = {}; + c.players[0].advisors!['homeworld:beneGesserit'] = {}; + assert.deepEqual(quoteNexusAdvisors(c, 'b'), { territories: [] }); + assert.throws(() => + createNexusAdvisors(c, 'b', 'x', ['homeworld:beneGesserit']), + ); + assert.throws(() => quoteNexusAdvisors(c, 'g')); +}); +void test('fresh advisor lock and storm interference expose explicit unresolved reasons while clear sectors remain usable', () => { + const fresh = context(); + fresh.players[0].advisors!.hagga_basin.lockedTurn = 3; + assert.match( + quoteNexusAdvisors(fresh, 'b').territories.find( + (t) => t.territory === 'hagga_basin', + )!.blocked!, + /same-turn.*unresolved/, + ); + assert.throws( + () => createNexusAdvisors(fresh, 'b', 'x', ['arrakeen', 'hagga_basin']), + /unresolved/, + ); + const storm = context(); + storm.storm = 10; + assert.match( + quoteNexusAdvisors(storm, 'b').territories.find( + (t) => t.territory === 'arrakeen', + )!.blocked!, + /storm.*ruling/, + ); + assert.equal( + quoteNexusAdvisors(storm, 'b').territories.find( + (t) => t.territory === 'hagga_basin', + )!.blocked, + null, + ); +}); +void test('a storm separating hostile sectors blocks only the affected conversion rather than requiring battle contact everywhere', () => { + const c = context(); + c.players[0].forces = { 'pasty_mesa:5': 2 }; + c.players[0].advisors = { pasty_mesa: {} }; + c.players[1].forces = { 'pasty_mesa:8': 1 }; + c.storm = 7; + assert.match(quoteNexusAdvisors(c, 'b').territories[0].blocked!, /storm/); + c.storm = 18; + assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null); + c.players[0].forces = { 'polar_sink:0': 2 }; + c.players[0].advisors = { polar_sink: {} }; + c.players[1].forces = { 'polar_sink:0': 1 }; + assert.equal(quoteNexusAdvisors(c, 'b').territories[0].blocked, null); +}); +void test('ordinary allied occupation and a third stronghold faction block conversion using public No-Field presence', () => { + const allied = context(); + allied.players[0].ally = 'g'; + allied.players[1].ally = 'b'; + assert.ok( + quoteNexusAdvisors(allied, 'b').territories.every((t) => + /ally/.test(t.blocked!), + ), + ); + const full = context(); + full.players[2].noField = { + deployed: { location: { territory: 'arrakeen', sector: 10 } }, + }; + assert.match( + quoteNexusAdvisors(full, 'b').territories.find( + (t) => t.territory === 'arrakeen', + )!.blocked!, + /three occupying factions/, + ); + assert.equal( + quoteNexusAdvisors(full, 'b').territories.find( + (t) => t.territory === 'hagga_basin', + )!.blocked, + null, + ); +}); +void test('the established Ecaz shared occupancy identity is preserved for capacity and allied entry', () => { + const c = context(); + c.players[1].faction = 'ecaz'; + c.players[0].ally = 'g'; + c.players[1].ally = 'b'; + c.players[2].forces = { 'arrakeen:10': 1 }; + assert.equal( + quoteNexusAdvisors(c, 'b').territories.find( + (t) => t.territory === 'arrakeen', + )!.blocked, + null, + ); +}); +void test('public marker counts and locations are recorded without reading hidden No-Field denomination or other secrets', () => { + const c = context(); + c.players[0].noField = { + deployed: { location: { territory: 'hagga_basin', sector: 12 } }, + }; + for (const player of c.players) + for (const key of ['hand', 'spice', 'traitors', 'leaders']) + Object.defineProperty(player, key, { + get() { + throw Error('Private field accessed'); + }, + }); + const marker = c.players[0].noField!; + for (const key of ['tokens', 'value', 'tokenId', 'lastShipped']) + Object.defineProperty(marker, key, { + get() { + throw Error('Hidden marker accessed'); + }, + }); + Object.defineProperty(marker.deployed!, 'tokenId', { + get() { + throw Error('Hidden identity accessed'); + }, + }); + const receipt = createNexusAdvisors(c, 'b', 'private', ['hagga_basin']); + assert.equal(receipt.selections[0].count, 4); + assert.equal(receipt.selections[0].noField, 'hagga_basin:12'); + assert.equal( + receipt.selections[0].locations.reduce((sum, g) => sum + g.count, 0), + 3, + ); + validateNexusAdvisors(c, receipt, true); + const changed = reload(c); + changed.players[0].noField = null; + changed.players[0].forces = { + 'hagga_basin:12': 3, + 'hagga_basin:13': 1, + 'arrakeen:10': 2, + }; + assert.equal( + quoteNexusAdvisors(changed, 'b').territories.find( + (t) => t.territory === 'hagga_basin', + )!.count, + 4, + ); + assert.throws(() => validateNexusAdvisors(changed, receipt, true)); +}); +void test('empty, duplicate, foreign and malformed selections reject immutably and current settlement rechecks exact counts and legality', () => { + const c = context(), + before = reload(c); + for (const selected of [ + [], + ['arrakeen', 'arrakeen'], + ['unknown'], + ['arrakeen:10'], + ]) + assert.throws(() => createNexusAdvisors(c, 'b', 'x', selected)); + assert.deepEqual(c, before); + const receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen']); + for (const change of [ + (g: NexusAdvisorContext) => { + g.players[0].forces = { 'arrakeen:10': 1 }; + }, + (g: NexusAdvisorContext) => { + delete g.players[0].advisors!.arrakeen; + }, + (g: NexusAdvisorContext) => { + g.players[0].advisors!.arrakeen.lockedTurn = 3; + }, + (g: NexusAdvisorContext) => { + g.players[2].forces = { 'arrakeen:10': 1 }; + }, + (g: NexusAdvisorContext) => { + g.storm = 10; + }, + ]) { + const altered = reload(c); + change(altered); + assert.throws(() => validateNexusAdvisors(altered, receipt, true)); + } +}); +void test('signed receipt detects altered counts, stances and ordering while completed history survives later movement and turns', () => { + const c = context(), + receipt = createNexusAdvisors(c, 'b', 'x', ['arrakeen', 'hagga_basin']); + for (const change of [ + (r: NexusAdvisorReceipt) => { + r.selections.reverse(); + }, + (r: NexusAdvisorReceipt) => { + r.selections[0].count++; + }, + (r: NexusAdvisorReceipt) => { + r.selections[0].stance.lockedTurn = 1; + }, + (r: NexusAdvisorReceipt) => { + r.selections[1].locations.reverse(); + }, + (r: NexusAdvisorReceipt) => { + r.owner = 'g'; + }, + (r: NexusAdvisorReceipt) => { + r.turn++; + }, + ]) { + const bad = reload(receipt); + change(bad); + assert.throws(() => validateNexusAdvisors(c, bad, false)); + } + const later = reload(c); + later.turn++; + later.players[0].forces = {}; + later.players[0].advisors = {}; + validateNexusAdvisors(later, receipt, false); + assert.throws(() => validateNexusAdvisors(later, receipt, true)); +}); + +void test('ordinary in-memory undefined lock metadata normalizes into the same saved stance without mutating its source', () => { + const c = context(); + c.players[0].advisors!.arrakeen = { lockedTurn: undefined }; + const receipt = createNexusAdvisors(c, 'b', 'undefined-lock', ['arrakeen']); + assert.deepEqual(receipt.selections[0].stance, {}); + assert.equal( + Object.hasOwn(c.players[0].advisors!.arrakeen, 'lockedTurn'), + true, + ); + validateNexusAdvisors(c, receipt, true); + validateNexusAdvisors(reload(c), reload(receipt), true); +}); diff --git a/tests/nexus-advisors-recovery.test.ts b/tests/nexus-advisors-recovery.test.ts new file mode 100644 index 00000000..37b9082a --- /dev/null +++ b/tests/nexus-advisors-recovery.test.ts @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import { unitStore } from './fixture-nexus-room-store'; +import { nexusAdvisorFixture, holdAdvisorKarama } from './fixture-nexus-advisors'; +import { nexusTraitorInventory } from './fixture-nexus-traitors'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +async function fixture(initial: Game) { + const store = unitStore(); + const made = await store.rooms.createRoom( + 'Cunning SQL', + 'harkonnen', + false, + [], + ); + const code = made.view.code, + tokens = [made.token]; + for (const faction of ['guild', 'fremen'] as const) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + const ids = ['p', 'q', 'r']; + // Only disposable SQL seat bindings move. Existing game identities and their + // signed battle/component histories remain untouched. + for (const [i, token] of tokens.entries()) { + const auth = await store.rooms.authenticate(code, token); + store.sqlite + .prepare( + 'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?', + ) + .run(ids[i], code, auth.playerId); + } + const auths = await Promise.all( + tokens.map((token) => store.rooms.authenticate(code, token)), + ); + initial.code = code; + initial.version = (await store.rooms.readRoom(code)).version; + store.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(initial), initial.version, code); + store.writes.length = 0; + return { ...store, code, tokens, auths, ids, initial }; +} +type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync }; +const row = (f: Fixture) => + f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(f.code); +const advisorAction = (g: Game): Action => ({ + type: 'nexusAdvisors', event: viewGame(g, 'p').nexusAdvisors!.offer!.event, + territories: ['arrakeen', 'pasty_mesa'], +}); +async function restored(f: Fixture, g: Game) { + const before = row(f), + rooms = f.restart(); + for (const [i, token] of f.tokens.entries()) { + const auth = await rooms.authenticate(f.code, token); + const actual = await rooms.readSeatView(f.code, auth); + assert.deepEqual(actual, viewGame(g, f.ids[i])); + assert.equal(Object.hasOwn(actual, 'nexusAdvisorHistory'), false); + } + await rooms.continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before); + nexusTraitorInventory(g); +} +async function compete(f: Fixture, action: Action) { + let arrivals = 0; + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + const results = await Promise.allSettled( + [f.restart(), f.restart()].map((rooms) => + rooms.act(f.code, f.auths[0], f.initial.version, action, clock), + ), + ); + delete f.hooks.beforeWrite; + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + assert.deepEqual( + f.writes.map((w) => w.changes).sort((a, b) => a - b), + [0, 1], + ); + const g = await f.restart().readRoom(f.code); + assert.equal(g.version, f.initial.version + 1); + return g; +} + +void test('SQLite competing advisor declarations save exactly one pending batch and private seats restore it', async () => { + const initial = nexusAdvisorFixture(); + holdAdvisorKarama(initial); + const f = await fixture(initial); + try { + const action = advisorAction(f.initial); + const g = await compete(f, action); + assert.equal(g.response?.kind, 'nexusAdvisorFlip'); + assert.equal(g.nexusAdvisorHistory!.length, 1); + assert.equal(g.nexusAdvisorLast!.stage, 'pending'); + assert.deepEqual(g.players[0].advisors, initial.players[0].advisors); + await restored(f, g); + const before = row(f); + await assert.rejects(f.rooms.act(f.code, f.auths[0], initial.version, action, clock)); + await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, action, clock)); + assert.deepEqual(row(f), before); + await f.restart().act(f.code, f.auths[1], g.version, {type:'passResponse'}, clock); + const settled = await f.restart().readRoom(f.code); + assert.equal(settled.nexusAdvisorLast!.stage, 'completed'); + assert.deepEqual(settled.players[0].advisors, {carthag:{}}); + assert.deepEqual(settled.players[0].forces, initial.players[0].forces); + await restored(f, settled); + } finally { f.sqlite.close(); } +}); + +void test('SQLite reconnect then Karama cancels the entire saved advisor batch without spending movement', async () => { + const initial = nexusAdvisorFixture(); + const karama = holdAdvisorKarama(initial); + const f = await fixture(initial); + try { + await f.rooms.act(f.code, f.auths[0], initial.version, advisorAction(initial), clock); + const pending = await f.restart().readRoom(f.code); + await restored(f, pending); + await f.restart().act(f.code, f.auths[1], pending.version, {type:'card',card:karama,mode:'cancel'}, clock); + const g = await f.restart().readRoom(f.code); + assert.equal(g.nexusAdvisorLast!.stage, 'canceled'); + assert.deepEqual(g.players[0].advisors, initial.players[0].advisors); + assert.deepEqual(g.players[0].forces, initial.players[0].forces); + assert.equal(g.players[0].moved, 0); + assert.equal(g.players[0].shipped, false); + assert.equal(g.discard.filter(c => c.id === karama).length, 1); + await restored(f, g); + const before = row(f); + await assert.rejects(f.rooms.act(f.code, f.auths[1], g.version, {type:'card',card:karama,mode:'cancel'}, clock)); + assert.deepEqual(row(f), before); + } finally { f.sqlite.close(); } +}); + +void test('SQLite rejects missing or corrupted advisor continuations without changing their saved rows', async () => { + const initial = nexusAdvisorFixture(); holdAdvisorKarama(initial); + const f = await fixture(initial); + try { + await f.rooms.act(f.code, f.auths[0], initial.version, advisorAction(initial), clock); + const good = await f.rooms.readRoom(f.code); + const changes: ((g:Game)=>void)[] = [ + g => { delete g.nexusAdvisorHistory; }, + g => { delete g.nexusAdvisorLast; }, + g => { g.nexusAdvisorHistory![0].receipt.selections.pop(); }, + g => { g.response!.intent += '-stale'; }, + g => { g.response = null; }, + g => { g.players[0].moved++; }, + g => { g.players[0].forces['pasty_mesa:5']++; g.players[0].reserves--; }, + g => { g.nexusAdvisorHistory![0].stage = 'completed'; }, + ]; + for (const change of changes) { + const bad = structuredClone(good); change(bad); + f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(bad), f.code); + f.writes.length = 0; + const before = row(f); + for (const auth of f.auths) await assert.rejects(f.restart().readSeatView(f.code, auth)); + await assert.rejects(f.restart().act(f.code, f.auths[1], bad.version, {type:'passResponse'}, clock)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + } + } finally { f.sqlite.close(); } +}); diff --git a/tests/nexus-advisors.test.ts b/tests/nexus-advisors.test.ts new file mode 100644 index 00000000..9d712b43 --- /dev/null +++ b/tests/nexus-advisors.test.ts @@ -0,0 +1,340 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Game, +} from '../game/engine'; +import { + nexusAdvisorFixture, + nexusAdvisorPlayer, + nexusAdvisorInventory, + holdAdvisorKarama, + beginNexusAdvisorFlip, + allowNexusAdvisorFlip, +} from './fixture-nexus-advisors'; +import { nexusReload, nexusReject } from './fixture-nexus-cards'; +import { drawNexusCard } from '../game/nexus-cards'; + +function action(g: Game, territories = ['arrakeen', 'pasty_mesa']) { + const owner = nexusAdvisorPlayer(g); + return { + type: 'nexusAdvisors', + event: viewGame(g, owner.id).nexusAdvisors!.offer!.event, + territories, + }; +} +function resources(g: Game) { + return g.players.map((p) => ({ + id: p.id, + forces: p.forces, + reserves: p.reserves, + tanks: p.tanks, + elites: p.elites, + spice: p.spice, + hand: p.hand, + shipped: p.shipped, + moved: p.moved, + })); +} + +void test('Cunning converts every counter in selected multi-sector groups without consuming movement, shipment or spice', () => { + let g = nexusAdvisorFixture(); + holdAdvisorKarama(g); + const original = nexusReload(g); + const offer = viewGame(g, 'p').nexusAdvisors!.offer!; + assert.deepEqual( + [...offer.territories] + .sort((a, b) => a.territory.localeCompare(b.territory)) + .map((t) => [t.territory, t.count]), + [ + ['arrakeen', 2], + ['carthag', 1], + ['pasty_mesa', 3], + ], + ); + g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']); + assert.equal(g.response?.kind, 'nexusAdvisorFlip'); + assert.deepEqual( + nexusAdvisorPlayer(g).advisors, + nexusAdvisorPlayer(original).advisors, + ); + assert.equal(g.nexusCards!.cards!.hands.p, null); + g = allowNexusAdvisorFlip(g); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} }); + assert.deepEqual(resources(g), resources(original)); + assert.equal(g.phase, 5); + assert.equal(g.active, 'p'); + assert.equal(g.movementRemaining, original.movementRemaining); + assert.equal(g.pendingTerrorEntry, original.pendingTerrorEntry); + assert.equal(g.pendingAmbassador, original.pendingAmbassador); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length, + 1, + ); + nexusAdvisorInventory(g); +}); + +void test('one Karama cancels the entire announced advisor batch and spends both physical cards once', () => { + let g = nexusAdvisorFixture(); + const karama = holdAdvisorKarama(g); + const advisors = structuredClone(nexusAdvisorPlayer(g).advisors); + g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa', 'carthag']); + g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' }); + g = allowNexusAdvisorFlip(g); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, advisors); + assert.equal(g.discard.filter((c) => c.id === karama).length, 1); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length, + 1, + ); + assert.equal(viewGame(g, 'p').nexusAdvisors!.pending, null); + nexusAdvisorInventory(g); +}); + +void test('saved live response exposes only its public selected groups and resumes exactly once', () => { + let g = nexusAdvisorFixture({ + seatIds: ['bg-seat', 'guild-seat', 'fremen-seat'], + }); + holdAdvisorKarama(g, 'guild-seat'); + const request = action(g); + g = beginNexusAdvisorFlip(g, request.territories); + const saved = nexusReload(g); + const expected = viewGame(saved, 'bg-seat').nexusAdvisors!.pending; + assert.ok(expected); + for (const id of ['bg-seat', 'guild-seat', 'fremen-seat']) { + const view = viewGame(saved, id); + assert.deepEqual(view.nexusAdvisors!.pending, expected); + for (const p of view.players) + if (p.id !== id) assert.equal(p.hand, undefined); + } + assert.deepEqual(normalizeAutomaticGame(saved), saved); + g = allowNexusAdvisorFlip(nexusReload(saved)); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} }); + const done = nexusReload(g); + assert.deepEqual(normalizeAutomaticGame(done), done); + nexusReject(g, 'bg-seat', request); + nexusAdvisorInventory(g); +}); + +void test('foreign, stale, empty, duplicate, partial and non-advisor selections reject before any spend', () => { + const g = nexusAdvisorFixture(); + const request = action(g); + nexusReject(g, 'q', request); + nexusReject(g, 'p', { ...request, event: 'old' }); + for (const territories of [ + [], + ['arrakeen', 'arrakeen'], + ['polar_sink'], + ['pasty_mesa:5'], + ['homeworld:beneGesserit'], + ]) + nexusReject( + g, + 'p', + { ...request, territories }, + /advisor|territor|Homeworld/i, + ); + nexusReject(g, 'p', { ...request, amount: 1 }); + assert.equal(g.nexusCards!.cards!.hands.p, 'beneGesserit'); +}); + +void test('Cunning belongs to the current native Advanced shipment and movement action', () => { + for (const mutate of [ + (g: Game) => { + g.active = 'q'; + }, + (g: Game) => { + g.phase = 6; + }, + (g: Game) => { + g.phase = 8; + }, + ]) { + const g = nexusAdvisorFixture(); + mutate(g); + assert.ok(viewGame(g, 'p').nexusAdvisors!.offer!.blocked); + nexusReject(g, 'p', action(g), /own Shipment and Movement/); + } + const basic = nexusAdvisorFixture({ advanced: false }); + const offer = viewGame(basic, 'p').nexusAdvisors!.offer; + assert.ok(!offer || offer.blocked || !offer.territories.length); + nexusReject( + basic, + 'p', + { + type: 'nexusAdvisors', + event: offer?.event ?? 'none', + territories: ['arrakeen'], + }, + /Advanced/, + ); +}); + +void test('fresh advisor locks and storm-locked strongholds remain guarded without partially converting an allowed group', () => { + for (const scenario of ['lock', 'storm']) { + const g = nexusAdvisorFixture(); + if (scenario === 'lock') + nexusAdvisorPlayer(g).advisors!.arrakeen.lockedTurn = g.turn; + else g.storm = 10; + const offer = viewGame(g, 'p').nexusAdvisors!.offer!; + assert.ok( + offer.territories.find((t) => t.territory === 'arrakeen')!.blocked, + ); + assert.equal( + offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked, + null, + ); + nexusReject(g, 'p', action(g)); + assert.ok(nexusAdvisorPlayer(g).advisors!.pasty_mesa); + } +}); + +void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => { + const g = nexusAdvisorFixture(); + const observer = g.players.find((p) => p.id === 'r')!; + observer.forces['arrakeen:10'] = 1; + observer.reserves--; + const offer = viewGame(g, 'p').nexusAdvisors!.offer!; + assert.ok(offer.territories.find((t) => t.territory === 'arrakeen')!.blocked); + assert.equal( + offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked, + null, + ); + nexusReject(g, 'p', action(g), /three occupying factions/); + nexusAdvisorInventory(g); +}); + +void test('already completed shipping and moving remain completed after a legal conversion before ending the action', () => { + let g = nexusAdvisorFixture(); + nexusAdvisorPlayer(g).shipped = true; + nexusAdvisorPlayer(g).moved = 1; + const before = nexusReload(g); + g = allowNexusAdvisorFlip(beginNexusAdvisorFlip(g, ['pasty_mesa'])); + assert.deepEqual(resources(g), resources(before)); + assert.equal(g.active, 'p'); + assert.equal(g.phase, 5); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { + arrakeen: {}, + carthag: {}, + }); +}); + +void test('missing response, edited original groups and altered movement-frame evidence reject reads and actions unchanged', () => { + let g = nexusAdvisorFixture(); + holdAdvisorKarama(g); + g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']); + for (const mutate of [ + (bad: Game) => { + bad.response = null; + }, + (bad: Game) => { + bad.response!.owner = 'q'; + }, + (bad: Game) => { + bad.response!.intent = 'other-event'; + }, + (bad: Game) => { + delete bad.nexusAdvisorHistory; + }, + (bad: Game) => { + delete bad.nexusAdvisorLast; + }, + (bad: Game) => { + bad.nexusAdvisorHistory![0].receipt.selections[0].count++; + }, + (bad: Game) => { + bad.nexusAdvisorHistory![0].stage = 'completed'; + }, + (bad: Game) => { + nexusAdvisorPlayer(bad).moved++; + }, + (bad: Game) => { + nexusAdvisorPlayer(bad).forces['arrakeen:10']++; + nexusAdvisorPlayer(bad).reserves--; + }, + ]) { + const bad = nexusReload(g); + mutate(bad); + const saved = JSON.stringify(bad); + for (const p of bad.players) assert.throws(() => viewGame(bad, p.id)); + assert.throws(() => normalizeAutomaticGame(bad)); + assert.throws(() => applyAction(bad, 'q', { type: 'passResponse' })); + assert.equal(JSON.stringify(bad), saved); + } +}); + +void test('a real Truthtrance interruption preserves the pending advisor batch and its original cancellation response', () => { + let g = nexusAdvisorFixture(); + holdAdvisorKarama(g); + const index = g.deck.findIndex((card) => card.effect === 'truthtrance'); + assert.ok(index >= 0); + const truth = g.deck.splice(index, 1)[0]; + g.players.find((p) => p.id === 'r')!.hand.push(truth); + g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']); + const response = structuredClone(g.response); + const record = structuredClone(g.nexusAdvisorHistory); + g = applyAction(g, 'r', { type: 'card', card: truth.id }); + while (g.truthtrance?.stage === 'priority') + g = applyAction( + g, + g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id, + { type: 'truthPass' }, + ); + g = applyAction(g, 'r', { + type: 'truthAsk', + question: { + kind: 'fact', + target: 'p', + fact: { kind: 'hand', name: 'Shield' }, + }, + }); + g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'no' }); + assert.equal(g.truthtrance, null); + assert.deepEqual(g.response, response); + assert.deepEqual(g.nexusAdvisorHistory, record); + g = allowNexusAdvisorFlip(g); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} }); + assert.equal(g.discard.filter((card) => card.id === truth.id).length, 1); + nexusAdvisorInventory(g); +}); + +void test('Harkonnen private Nexus exchange cannot consume or alter a pending advisor conversion', () => { + let g = nexusAdvisorFixture({ opponentFaction: 'harkonnen' }); + const cards = g.nexusCards!.cards!; + cards.deck = [ + 'harkonnen', + ...cards.deck.filter((card) => card !== 'harkonnen'), + ]; + g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0); + holdAdvisorKarama(g, 'r'); + g = beginNexusAdvisorFlip(g, ['pasty_mesa']); + const response = structuredClone(g.response); + const record = structuredClone(g.nexusAdvisorHistory); + const offer = viewGame(g, 'q').nexusTraitors!.offer!; + assert.equal(offer.blocked, null); + g = applyAction(g, 'q', { + type: 'nexusTraitorDraw', + event: offer.event, + mode: offer.mode, + }); + assert.deepEqual(g.nexusAdvisorHistory, record); + nexusReject(g, 'r', { type: 'passResponse' }, /private Nexus card return/); + const pending = g.nexusTraitorExchanges!.at(-1)!; + g = applyAction(normalizeAutomaticGame(nexusReload(g)), 'q', { + type: 'nexusTraitorReturn', + event: pending.event, + cards: [...pending.drawn], + }); + assert.deepEqual(g.response, response); + assert.deepEqual(g.nexusAdvisorHistory, record); + g = allowNexusAdvisorFlip(g); + assert.deepEqual(nexusAdvisorPlayer(g).advisors, { + arrakeen: {}, + carthag: {}, + }); + assert.equal(g.nexusTraitorExchanges!.length, 1); + assert.equal(g.nexusAdvisorHistory!.length, 1); + nexusAdvisorInventory(g); +}); diff --git a/tests/nexus-card-controls.test.ts b/tests/nexus-card-controls.test.ts index 4e74ff04..03ed9691 100644 --- a/tests/nexus-card-controls.test.ts +++ b/tests/nexus-card-controls.test.ts @@ -123,10 +123,12 @@ void test('the owned face highlights its mode from the public roster and all thr void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => { const view = fixture(); const html = markup(view, true); + assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning/); + assert.match(html, /other effects remain unavailable/); + assert.doesNotMatch(html, /other card effects are not playable yet/); assert.match(html, /10 in deck · 0 discarded · 2 held/); assert.match(html, /Waiting for Nexus choices: Harkonnen/); assert.match(html, /You do not hold a Nexus card/); - assert.match(html, /effects are not playable yet/); assert.match(html, /<button[^>]*disabled=""[^>]*>Draw a Nexus card/); assert.match(html, /<button[^>]*disabled=""[^>]*>Skip this draw/); assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);