From 40912498b1bfcff5c618d72acbda853d2869ac89 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:49:18 +1000 Subject: [PATCH] Implement Tleilaxu and Ixian Nexus Cunning with durable private recovery --- components/battle-promises.tsx | 12 +- components/game-table.tsx | 11 +- components/nexus-suboids.tsx | 49 +++ components/nexus-tleilaxu.tsx | 60 +++ docs/COMPONENT_INVENTORY.md | 2 +- docs/IMPLEMENTATION_STATUS.md | 2 + docs/NEXUS_CUNNING_RUNTIME.md | 43 ++ docs/NEXUS_IXIAN_CUNNING_RULES.md | 36 ++ docs/NEXUS_TLEILAXU_RULES.md | 59 +++ game/battle-resolution-quote.ts | 2 + game/bots.ts | 6 + game/combat.ts | 26 +- game/engine.ts | 128 +++++- game/nexus-face-dancers.ts | 217 +++++++++++ game/nexus-suboid-options.ts | 39 ++ game/nexus-suboids.ts | 48 +++ game/nexus-tleilaxu-options.ts | 37 ++ game/reference.ts | 15 +- game/stone-burner.ts | 6 +- tests/fixture-nexus-cunning.ts | 29 ++ tests/fixture-nexus-face-dancers.ts | 22 ++ tests/nexus-cunning-recovery.test.ts | 281 +++++++++++++ tests/nexus-face-dancer-engine.test.ts | 368 ++++++++++++++++++ tests/nexus-face-dancers.test.ts | 269 +++++++++++++ tests/nexus-suboid-bots.test.ts | 132 +++++++ tests/nexus-suboid-controls.test.ts | 163 ++++++++ ...nexus-suboid-reachability-controls.test.ts | 49 +++ tests/nexus-suboid-reachability.test.ts | 76 ++++ tests/nexus-suboids-engine.test.ts | 120 ++++++ tests/nexus-tleilaxu-bots.test.ts | 106 +++++ tests/nexus-tleilaxu-controls.test.ts | 133 +++++++ tests/normal-free-support.test.ts | 239 ++++++++++++ 32 files changed, 2761 insertions(+), 24 deletions(-) create mode 100644 components/nexus-suboids.tsx create mode 100644 components/nexus-tleilaxu.tsx create mode 100644 docs/NEXUS_CUNNING_RUNTIME.md create mode 100644 docs/NEXUS_IXIAN_CUNNING_RULES.md create mode 100644 docs/NEXUS_TLEILAXU_RULES.md create mode 100644 game/nexus-face-dancers.ts create mode 100644 game/nexus-suboid-options.ts create mode 100644 game/nexus-suboids.ts create mode 100644 game/nexus-tleilaxu-options.ts create mode 100644 tests/fixture-nexus-cunning.ts create mode 100644 tests/fixture-nexus-face-dancers.ts create mode 100644 tests/nexus-cunning-recovery.test.ts create mode 100644 tests/nexus-face-dancer-engine.test.ts create mode 100644 tests/nexus-face-dancers.test.ts create mode 100644 tests/nexus-suboid-bots.test.ts create mode 100644 tests/nexus-suboid-controls.test.ts create mode 100644 tests/nexus-suboid-reachability-controls.test.ts create mode 100644 tests/nexus-suboid-reachability.test.ts create mode 100644 tests/nexus-suboids-engine.test.ts create mode 100644 tests/nexus-tleilaxu-bots.test.ts create mode 100644 tests/nexus-tleilaxu-controls.test.ts create mode 100644 tests/normal-free-support.test.ts diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx index 39126b56..d7891d3f 100644 --- a/components/battle-promises.tsx +++ b/components/battle-promises.tsx @@ -179,11 +179,13 @@ export function BattlePromises({ {b.compliantPreparation.actions.map((action, index) => (

{index + 1}.{' '} - {action.mode === 'special' - ? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice` - : typeof action.leader === 'string' - ? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}` - : `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`} + {action.type === 'nexusSuboids' + ? 'Use Ixian Cunning to give Suboids full strength without spice support' + : action.mode === 'special' + ? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice` + : typeof action.leader === 'string' + ? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}` + : `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`} .

))} diff --git a/components/game-table.tsx b/components/game-table.tsx index 11ae574c..a18a01c8 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -11,6 +11,8 @@ import { GrummanCollection } from './grumman-collection'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; +import { NexusTleilaxu } from './nexus-tleilaxu'; +import { NexusSuboids } from './nexus-suboids'; import { TerrorBoardMarkers } from './terror-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; @@ -346,7 +348,7 @@ export function GameTable({ ownBattleForces ? ownBattleForces.freeSupport ? 0 - : (ownBattleForces.normalFixedHalf ? 0 : ownBattleForces.normal) + + : (ownBattleForces.normalFixedHalf || ownBattleForces.normalFreeSupport ? 0 : ownBattleForces.normal) + (ownBattleForces.eliteFreeSupport ? 0 : ownBattleForces.elite) : Number.POSITIVE_INFINITY, ); @@ -1416,6 +1418,8 @@ export function GameTable({ + + {!g.nexusCards?.waiting.length && } {g.biddingEnd && } {g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && ( @@ -4147,6 +4151,11 @@ export function GameTable({ ? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.` : `You have ${battleForces} fighting forces here.`}

+ {ownBattleForces?.normalFreeSupport && ( +

+ Your Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs. +

+ )} {ownBattleForces?.eliteFreeSupport && (

Salusa Secundus is at high population. Your diff --git a/components/nexus-suboids.tsx b/components/nexus-suboids.tsx new file mode 100644 index 00000000..b0373898 --- /dev/null +++ b/components/nexus-suboids.tsx @@ -0,0 +1,49 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { nexusSuboidsAction } from '@/game/nexus-suboid-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +export function NexusSuboids({ game, act, busy }: Props) { + const state = game.nexusSuboids; + if (!state?.offer && !state?.active) return null; + if (state.active) + return ( +

+

+ Ixian Suboids fight at full strength without spice support for the + rest of this turn. Cyborgs keep their usual strength and support + rules. +

+
+ ); + const offer = state.offer!; + const action = nexusSuboidsAction(game, offer.event); + return ( +
+

Ixian Nexus · Cunning

+

+ For the rest of this turn, your Suboids fight at full strength without + spice support. Cyborgs keep their usual strength and support rules. +

+

+ Use this before submitting your battle plan. This spends your Ixian + Nexus card. +

+ {offer.blocked &&

{offer.blocked}

} + +
+ ); +} diff --git a/components/nexus-tleilaxu.tsx b/components/nexus-tleilaxu.tsx new file mode 100644 index 00000000..4af22463 --- /dev/null +++ b/components/nexus-tleilaxu.tsx @@ -0,0 +1,60 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { nexusFaceDancersAction } from '@/game/nexus-tleilaxu-options'; +import { nexusCardMode } from '@/game/nexus-cards'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +export function NexusTleilaxu({ game, act, busy }: Props) { + const offer = game.nexusTleilaxu?.cunning; + if (!offer) { + const owner = game.players.find((player) => player.id === game.me); + if (!owner || game.nexusCards?.card !== 'tleilaxu') return null; + const mode = nexusCardMode( + 'tleilaxu', + owner.faction, + game.players.map((player) => player.faction), + ); + if (mode === 'cunning') return null; + return ( +
+

+ {mode === 'secretAlly' + ? 'Secret Ally revival is unavailable while its revival-limit ruling is pending.' + : 'Betrayal is unavailable while its private response timing is pending.'} +

+
+ ); + } + const action = nexusFaceDancersAction(game, offer.event); + return ( +
+

Tleilaxu Nexus · Cunning

+

+ Set aside all your revealed Face Dancers and secretly draw their + replacements. Then shuffle the set-aside cards into the Traitor Deck. + Your unrevealed Face Dancers stay in your hand. +

+

+ This uses your Tleilaxu Nexus card. All {offer.count} revealed{' '} + {offer.count === 1 ? 'card is' : 'cards are'} replaced together. +

+ {offer.blocked &&

{offer.blocked}

} + +
+ ); +} diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index 84d64c4f..84bc029d 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -1,6 +1,6 @@ # Component inventory and verification ledger -**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. This adds no duplicate physical deck or new artwork. 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 current verification and limits; final Harkonnen checkpoint 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 3d4015a6..59084ef7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**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. **Atreides Nexus inspection checkpoint, 10 September 2026:** [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md) records Cunning's distinct second commitment and absent-faction Secret Ally, physical card disposal, No-Field distinctions, private history, all four editor locks and bot handling. Independent review fixed lost-response bypass, first-answer drift and a sealed plan contradicting its signed observation; legitimate Residual reanswers preserve the first historical disclosure. **Types, lint, all 3,839 offline tests, production build and 40 HTTP/session tests pass.** Forty-four new cases include concurrent SQLite recovery and four independent corruption regressions. Three-seat keyboard play, answer, protected plan submission and desktop/phone refresh passed. All **3,234** preexisting room versions/hashes survived the scheduled backup/restart and final comparison. Betrayal response timing, Residual after two answered Cunning fields, other card families and complete module games remain explicitly unfinished; no release gate opened. Automation stays removed; verified checkpoints are authorized for push without another confirmation. diff --git a/docs/NEXUS_CUNNING_RUNTIME.md b/docs/NEXUS_CUNNING_RUNTIME.md new file mode 100644 index 00000000..6935a269 --- /dev/null +++ b/docs/NEXUS_CUNNING_RUNTIME.md @@ -0,0 +1,43 @@ +# Tleilaxu and Ixian Nexus Cunning checkpoint + +10 September 2026. Two native Cunning effects extend the existing [Nexus lifecycle](NEXUS_CARD_RUNTIME.md), [Atreides inspections](NEXUS_ATREIDES_RUNTIME.md) and [Harkonnen exchanges](NEXUS_HARKONNEN_RUNTIME.md). The [Tleilaxu source audit](NEXUS_TLEILAXU_RULES.md) and [Ixian source audit](NEXUS_IXIAN_CUNNING_RULES.md) distinguish printed effects from their digital timing composition. Neither this checkpoint nor its focused tests open incomplete mode or expansion starts. + +## Tleilaxu replacement + +Native Tleilaxu holding its own Nexus card can replace the entire revealed Face Dancer group. The engine sets those identities aside, draws the same number from the existing reserve, then shuffles the set-aside cards into the remaining Traitor Deck. The old revealed identities cannot be among this operation's new draws. Existing unrevealed dancers remain held, and replacement dancers begin unrevealed. + +The operation spends the physical Nexus card and records one completed receipt atomically. There is no subset selection, post-draw choice or extra acknowledgement. A nonempty revealed group and sufficient original reserve are required; a missing reserve is not supplied by recycling another player's cards. Canonical source/result evidence binds the physical operation, and reads or retries do not repeat it. These signatures check saved-state consistency, not cryptographic authenticity against a deliberately rewritten save. + +The card has no phase restriction. Its engine path preserves the existing response or owned decision, including paid Nullentropy, while replacing the Face Dancers. Live Truthtrance, automatic card disposal, closing common Nexus draws and a pending Harkonnen Traitor Deck return retain priority. This does not authorize rewriting a completed Face Dance or interrupting its mandatory native cycle at an invented point. + +Ordinary Mentat replacement and the native all-three-revealed reset remain different operations. Both shuffle returned cards before their draws; Cunning draws before returning its set-aside group. Cunning does not spend or refresh the ordinary Mentat usage counter. A stable third native reveal already performs its mandatory reset, so Cunning normally has one or two revealed cards to replace. No extra native reset follows Cunning. + +Only the owner receives new Face Dancer identities. Other seats receive the public effect and count, without the reserve order or historical private replacement snapshot. The owner has one explicit button to play the effect; all four bot profiles use the same event-bound private offer without inspecting another hand. + +## Ixian Suboid strength + +Native Ixians can play Cunning in their own current battle before submitting their plan. The effect spends the Nexus card and records its native owner, activation battle and turn. Every Ixian Suboid then counts as **one strength instead of one half**, in Basic and Advanced, throughout that turn. Later battles use the same grant; the actual next turn removes the benefit while keeping its history. It does not replay or amend an earlier battle. + +The force profile separates free Suboid support from Cyborg support. Suboids cannot be charged support spice while the effect is active; Cyborgs retain their usual strength, support and substitution rules. Counter counts, physical losses, movement and revival remain unchanged. The shared combat solver, Stone Burner comparison and battle resolution consume this typed profile. The table bounds paid support to the remaining eligible Cyborg count and explains the distinction; bot plans use the server's projected forces rather than guessing an effect from the discarded card. + +Prior Prescience and Truthtrance commitments remain binding. The engine checks whether the boosted profile still permits a reachable compliant plan before offering activation. A previously promised half-strength dial can make activation impossible; the card is then retained and no promised field is silently released. Compatible commitments do not require the opposing player to unseal a plan. The public active notice identifies Ixian Suboids and never implies that another faction's forces gain the bonus. + +Independent review found that the reachability search omitted an available Cunning activation. The corrected search can accept a future full-strength dial commitment while leaving the real Nexus card unspent. It supplies Cunning as the owner's explicit preparation step; the private hint names that effect rather than incorrectly describing an undefined Ghola force count. Actual Truthtrance and Prescience regressions exercise the future-strength commitment, and a separate server-rendered control regression checks its private instruction and subsequent real activation. This follow-up selection passed **3/3**, with focused lint passing. See [reachability tests](../tests/nexus-suboid-reachability.test.ts) and [preparation controls](../tests/nexus-suboid-reachability-controls.test.ts). + +The genuine Nexus audit setup now accepts Ixians' initialized seven elite reserves. This fixes setup validation for the existing physical seven-Cyborg/thirteen-Suboid inventory; it does not grant public access to gated modes or migrate existing force custody. + +## Verified checkpoint and unfinished work + +The latest observed consumer selection passed **17/17** cases: thirteen new Tleilaxu/Ixian controls and bot regressions plus four existing Harkonnen bot cases. It verifies all four profiles, private getter traps, pending-control priorities, zero-spice boosted plans and the actual table's Cyborg-only support bounds. Type-aware lint passed for the owned consumer files. Full-table SSR still emits the preexisting React SVG title-array warning; this is not browser acceptance. + +**Types, lint, all 3,936 offline tests, production build and 40 HTTP/session tests pass.** This checkpoint adds 51 cases: eight pure Face Dancer, ten Face Dancer engine, six support solver, seven Suboid engine, thirteen consumer, four production SQLite recovery, two future-plan reachability and one private preparation-control regression. + +The SQLite races accept one card spend, draw/shuffle or turn grant. Lost responses restore the same private state, subsequent boosted plans persist, and damaged receipts or a missing last-play marker reject without writes. Engine coverage includes a real completed Face Dance before Cunning, paid Nullentropy and Prescience interruptions, native Mentat replacement in both orders, compatible and impossible prior commitments, a second battle in the same turn and turn expiry. + +Two isolated three-seat browser rooms verified desktop/phone play, Tleilaxu's atomic replacement and six private seat refreshes. The Ixian editor allowed seven strength from three Suboids and two Cyborgs, capped support at two, and accepted that sealed plan with an available leader. Opponents saw the public bonus and no sealed plan. The ordinary no-leader rejection remained enforced. Reviewed phone renders show readable controls and no horizontal page overflow; no page errors occurred. + +The controlled hourly restart preserved all **3,359** then-existing room versions and hashes, restored both Cunning rooms and the earlier three-seat Atreides inspection checkpoint. Final comparison preserves all **3,327** rooms from this checkpoint's opening baseline. Two browser rooms and sixty isolated HTTP rooms were added across the pre/post-review test runs. No recurring automation was recreated. + +Implementation: [Face Dancer operation](../game/nexus-face-dancers.ts), [Suboid grant](../game/nexus-suboids.ts), [combat solver](../game/combat.ts), [Tleilaxu controls](../components/nexus-tleilaxu.tsx), [Ixian controls](../components/nexus-suboids.tsx). Evidence: [Face Dancer engine](../tests/nexus-face-dancer-engine.test.ts), [Suboid engine](../tests/nexus-suboids-engine.test.ts), [support solver](../tests/normal-free-support.test.ts), [Tleilaxu controls](../tests/nexus-tleilaxu-controls.test.ts), [Tleilaxu bots](../tests/nexus-tleilaxu-bots.test.ts), [Ixian controls](../tests/nexus-suboid-controls.test.ts), [Ixian bots](../tests/nexus-suboid-bots.test.ts), [SQLite recovery](../tests/nexus-cunning-recovery.test.ts). + +Tleilaxu Secret Ally remains wholly gated pending the question about normal revival accounting, leader prerequisites and whether the combined leader benefit is optional. Its fixed price does not settle those separate permissions. Betrayal retains the existing private response timing gate. Other Ixian modes, unfinished Nexus families and complete module/expansion games remain separate release requirements. diff --git a/docs/NEXUS_IXIAN_CUNNING_RULES.md b/docs/NEXUS_IXIAN_CUNNING_RULES.md new file mode 100644 index 00000000..4d66fdc7 --- /dev/null +++ b/docs/NEXUS_IXIAN_CUNNING_RULES.md @@ -0,0 +1,36 @@ +# Ixian Nexus Cunning: full-strength Suboids + +Primary-source audit, 10 September 2026. This is a source contract, not an implementation or expansion-release claim. Other Ixian Nexus modes are outside this checkpoint. + +## Authority and result + +The original Ixian face was freshly inspected in `/tmp/dune-nexus-cards.jpg`, the [photograph of GF9's twelve printed cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). Its Cunning panel applies before the native Ixian player's Battle Plan is formulated. All that player's Suboids count at full strength in all battles during that turn, without spending spice to support them. The physical Nexus card is then discarded under the [E3 common rules, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11). + +[E1 p.9](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=9) makes Suboids half-strength in Basic. Its Advanced paragraph keeps them at half strength and prohibits increasing that value by paying spice. The new card explicitly overrides that limitation. It is useful in **both Basic and Advanced**, rather than an Advanced-only advantage. + +| Force profile | Ordinary rules | With Cunning active this turn | +| --- | --- | --- | +| Basic Suboid | 0.5 per physical counter | 1 per physical counter | +| Advanced Suboid | 0.5, unaffected by spice support | 1, without a support payment | +| Cyborg | Its existing Basic/Advanced values and support rules | Unchanged | + +The Advanced support distinction comes from [base p.13](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=13), combined with E1's Suboid exception. Cunning does not double a Cyborg, create extra counters, or change shipment, revival, spice collection or movement. The [November FAQ p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) expressly excludes Suboid strength from Karama cancellation. Applying that named exception to enhanced Suboid strength is a source composition; it does not establish immunity for every Nexus card or suppress separately cancelable Cyborg substitution. + +Fresh official-domain indexed excerpts were retrieved for E1 p.9, base p.13 and the November FAQ. The card is the primary source for its duration and timing; no tournament or fan rewrite was used. + +## Executable composition + +- Activation belongs to native Ixians holding its own Nexus card, before its plan in a current battle. Preserve the common unallied requirement. Playing it spends the single physical card and records one public activation; no subsequent confirmation is needed. +- Bind the grant to `{event, owner, faction: ixians, turn, activationBattle}`. Eligibility is not re-created from whether the discarded card remains visible in the current discard pile: recycling or a later draw must not end or duplicate this turn's grant. +- The effect covers the activation battle and later battles that turn. It cannot change an already resolved earlier battle. Native ownership of affected counters is the relevant identity; do not turn another faction's forces into Suboids. +- Every strength consumer must use the same active-turn force profile: feasible dials, support choices and costs, Truthtrance/Prescience commitments, bot plans, previews, resolution and the winner's legal casualty allocations. A Suboid still represents one physical token when lost. Preserve ordinary survivor-for-Cyborg substitution and its separate cancellation conditions. +- In Advanced, support selection/payment must not charge the active Suboids; Cyborg support remains chargeable. A blanket free-support flag for the whole Ixian army would be wrong. +- Keep the receipt after card disposal and through JSON restoration. Compare its recorded turn at each relevant calculation, and expire the benefit on the actual turn transition without replaying an earlier activation. + +“Before formulating” is the printed timing. Treating the server's first committed plan as the submission boundary is an implementation convention, not additional publisher wording. A prior inspected element or Truthtrance commitment must remain binding if activation is allowed afterward; the effect is not permission to change an already promised dial. No source permits reopening a sealed plan to play the card. Early battle preparation before any own commitment is the unambiguous activation window; later partial-plan timing needs to be represented consistently with the game's established plan workflow. + +## Boundaries and verification + +The face does not restrict the bonus to one territory, the first battle, or the counters present at activation. Later revived/arriving Suboids are still that player's Suboids in a battle during the same turn. This follows its turn-wide class description rather than a new per-counter grant. Homeworld combat and any Ecaz combined-force adapter must retain their existing physical-owner and printed battle-size rules while applying the applicable token profile; Cunning does not enlarge a Homeworld's allowed defensive army. + +Test Basic and Advanced with mixed Cyborg/Suboid armies, zero spice, a later same-turn battle, expiration next turn, physical losses and substitution, unchanged protected commitments, duplicate/stale activation, owner/phase integrity, and JSON/CAS recovery. Preserve all existing Homeworld, Advanced and expansion gates; passing this force modifier does not certify the other two Ixian Nexus effects. diff --git a/docs/NEXUS_TLEILAXU_RULES.md b/docs/NEXUS_TLEILAXU_RULES.md new file mode 100644 index 00000000..b3f24c92 --- /dev/null +++ b/docs/NEXUS_TLEILAXU_RULES.md @@ -0,0 +1,59 @@ +# Tleilaxu Nexus: primary-source contract + +Source audit, 10 September 2026. This document does not certify runtime support or open expansion release gates. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. Betrayal's existing unanswered reaction-privacy question remains separate. + +## Sources + +- The Tleilaxu face in the [original printed-component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), freshly inspected at `/tmp/dune-nexus-cards.jpg`. The authority is the photographed GF9 card text. +- [GF9 Ecaz & Moritani, pp.9,11,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11): common mode and disposal rules, Duke restrictions, Homeworld and Nexus FAQ. The previously acquired publisher-authored PDF mirror at `/tmp/dune-e3-nexus-rules.pdf` and its extracted text were reread; fresh publisher-indexed excerpts were retrieved. Direct official PDF access remains unavailable. +- [GF9 Ixians & Tleilaxu, pp.6–7,9 and Karama table](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7): ordinary Face Dancer cycle, native revival powers, typed Ixian forces and cancellation boundaries. +- [GF9 base rulebook, pp.9,13,16–19,22](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), and [November 2020 FAQ, pp.2,5–9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2): ordinary revival, repeated deaths, special forces, Kwisatz, Ghola and special revival permissions. Fresh official indexed excerpts were inspected. Existing Ghola implementation is not substitute authority for this Nexus effect. + +## Cunning: complete physical operation + +Native Tleilaxu sets aside every revealed Face Dancer, privately draws the same number of replacements, and only then shuffles the set-aside identities into the Traitor Deck. Unrevealed dancers remain held. The panel has no phase restriction, no subset choice and no second choice after drawing. [Printed Tleilaxu face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) + +Implementation composition: + +1. Bind the native owner, currently held Tleilaxu Nexus card, event, turn/phase, complete original Face Dancer group and original reserve order. +2. Take the entire revealed group out of held custody. Draw exactly its size from the original reserve. New dancers are unrevealed; retained dancers keep their identities/statuses. +3. Shuffle the removed identities into the remaining reserve, spend the physical Nexus card and commit one receipt. The removed identities cannot be among this action's replacement draws. No player acknowledgement is needed after this automatic operation. +4. Return only public owner/count/effect information to other seats. New identities, reserve order and historical private snapshots stay server/owner-only. Reads and retries cannot repeat the draw, shuffle or reset. +5. Use the shared physical census from [Harkonnen Nexus](NEXUS_HARKONNEN_RULES.md); do not recreate cards from current leaders or borrow held identities when a malformed reserve is short. + +E1's native third-reveal cycle is different: after all three reveal, return all three, shuffle, then draw three. Its ordinary Mentat action replaces one unrevealed dancer, also shuffling before drawing. Preserve both operations and their original usage counters. Cunning does not consume ordinary Mentat replacement or grant a second such replacement. In a stable ordinary game the third reveal already completes native cycling, so Cunning normally sees one or two revealed dancers. Do not manufacture an interruption before that mandatory reset or trigger another reset after Cunning. [E1 pp.6–7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7) + +With zero revealed dancers there is no replacement group. No retrieved source grants a free zero-card reshuffle. A nonempty-group runtime prerequisite is the supported useful action boundary; this audit does not establish a general rule permitting voluntary no-effect Nexus disposal. The native Karama table stops ordinary Mentat replacement but excludes other Face Dancer effects, supporting treatment of this distinct refresh without that ordinary cancellation window. This is source composition, not blanket Nexus immunity. + +Recommended pure API: `refreshRevealedFaceDancers(snapshot, universe, {event, owner, turn, phase}, random) -> {state, receipt}`. It should require no selected IDs and return a completed operation. Engine timing must preserve any pending native reveal/cycle transaction instead of replacing that transaction's reserved dancer underneath it. + +## Secret Ally: established facts + +Only an unallied holder in a game without Tleilaxu can use this mode. During Revival the panel grants one own leader for free **and** up to five own forces at one spice each. The five forces are not free; zero forces is permitted by the quantity wording. This is neither the Ghola card's alternative choice nor an acquisition of foreign leaders. [Printed Tleilaxu face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) + +The amount and price override normal values for the granted return. A native faction's free rate does not automatically make these specified paid counters free. Recruits doubles ordinary free rates; it does not say to double this fixed five. Preserve exact normal/starred counters and actual bank payment, never battle strength as a counter count. Returned forces ordinarily enter reserves. [Base p.9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), [E3 p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) + +Important compositions and exclusions: + +- Tleilaxu is absent, so no native Tleilaxu revival income, negotiated service or special Karama can apply. Secret Ally does not import the faction's entire ability sheet. At the time of playing it, the holder also has no formal Emperor ally; do not offer Emperor-funded extras as part of this transaction. A later alliance/ordinary action has its own rules. +- A Cheap Hero is a Treachery Card, not a dead leader disc, and is not revivable. Neither a living captured leader nor an opponent's dead leader becomes eligible through this grant. +- Advanced Fedaykin/Sardaukar share the one-per-turn revival limit across producers. The November FAQ preserves that limit through Ghola, Emperor extras and Tleilaxu. Basic stars retain physical identity without importing the Advanced cap. Ixian Cyborgs have no matching one-per-turn restriction. [November FAQ p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2), [E1 p.9](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=9) +- The one-spice force price expressly applies to this card's force group, including permitted typed counters. Do not call ordinary Fremen paid-revival validation and silently reject the explicit card grant, or charge Ixian Cyborgs their ordinary three-spice price. +- Kwisatz is revived like a leader, supporting its own Atreides identity as a candidate once accounting is resolved. Auditor is CHOAM's own leader, with existing special revival/slot rules. Duke may only be revived by Ecaz, including through Ghola; its destination/cycle questions remain pending. These exceptional identities cannot resolve the general leader-accounting question. [Base p.17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17), [CHOAM & Richese p.8](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=8), [Ecaz revival audit](ECAZ_REVIVAL_RULES.md) +- Southern Hemisphere applies to newly revived starred Fedaykin and therefore needs this producer's exact earned group, subject to the existing threshold/splitting/arrival boundaries. Tleilax's own Homeworld cannot confer a native benefit when Tleilaxu is absent. High Ix's paid-Cyborg trigger may apply to an actual positive payment here; its existing bonus quota/shortage questions remain unresolved. Preserve both [deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md) and [Ix benefit](HOMEWORLD_BENEFITS_RULES.md) gates. + +## Secret Ally questions that still affect implementation + +The inspected E3 FAQ does not answer these. Keep Secret Ally gated rather than implementing a smaller interpretation disguised as the complete effect. + +| Issue | Concrete interpretations requiring a ruling | +| --- | --- | +| Ordinary force accounting | Is this an independent five-counter return in addition to normal revival, or a replacement/expanded normal allowance? For example, may a player take normal three and then five with this card, and does the card spend subsequent free allowance? | +| Leader slot and eligibility | Does the free leader use the ordinary one-leader slot, or grant an additional revival? Does its unqualified permission waive the all-leaders-unavailable condition and the distinct face-down/repeated-death restriction? | +| Partial combined benefit | Can a holder play only the paid-force portion when no eligible dead leader exists, or decline an available leader while taking forces? The panel joins one leader and the force allowance with “and,” unlike an explicitly optional leader clause. | + +Normal leader revival has an unavailable-roster trigger and a separate repeated-death cycle. E1 expressly permits face-down leaders for its negotiated service, and the FAQ separately discusses native foreign-ghola revival. Neither automatically grants those permissions to an absent-faction Nexus effect. Similarly, the FAQ's Emperor extras are explicitly additional, while this panel does not settle its ordinary counter debit. [Base p.9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), [E1 p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7), [November FAQ p.6](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=6) + +Coordinator question recommendation: ask explicitly about independent versus ordinary force/leader allowance, waived leader prerequisites, and whether the leader portion is optional. The related [Ambassador accounting questions](TLEILAXU_AMBASSADOR_RULES.md) are already pending; do not duplicate those or assume an answer covers differently worded Nexus text. No user question was sent by this audit. + +Future revival quote inputs should retain source `nexusTleilaxuSecretAlly`, chosen leader/Kwisatz identity, exact normal/starred force counts, payment and existing usage histories. Require a resolved accounting policy before mutating ordinary counters. A receipt must preserve original revival, subsequent Homeworld deployment and phase continuation once; no card/force/spice effect should be inferred afresh from a restored hand or current Tanks. diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts index e83ec61b..6afc0c22 100644 --- a/game/battle-resolution-quote.ts +++ b/game/battle-resolution-quote.ts @@ -167,6 +167,8 @@ function validateCombatant( typeof f.freeSupport === 'boolean' && (f.eliteFreeSupport === undefined || typeof f.eliteFreeSupport === 'boolean') && + (f.normalFreeSupport === undefined || + typeof f.normalFreeSupport === 'boolean') && (f.normalFixedHalf === undefined || typeof f.normalFixedHalf === 'boolean') && Number.isFinite(p.dial) && diff --git a/game/bots.ts b/game/bots.ts index 6d4efcbd..239bf9a8 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,5 +1,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 { tupileIntelligenceActions } from './tupile-intelligence-options'; import { biddingEndActions, choamMarketPolicy } from './bidding-end-options'; import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options'; @@ -3321,6 +3323,10 @@ export function botActions(g: GameView): Action[] { : nexusTraitorBotActions(g); if (g.automaticContinuationPending) return []; if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); + const suboids = nexusSuboidBotActions(g); + if (suboids.length) return suboids; + const faceDancers = nexusTleilaxuBotActions(g); + if (faceDancers.length) return faceDancers; const traitorExchange = nexusTraitorBotActions(g); if (traitorExchange.length) return traitorExchange; const nexus = g.nexusAtreides; diff --git a/game/combat.ts b/game/combat.ts index 2e2bf5e1..b5a96d1b 100644 --- a/game/combat.ts +++ b/game/combat.ts @@ -2,6 +2,9 @@ export type CombatForces = { normal: number; normalFixedHalf?: boolean; + /** Normal counters alone retain full strength without support, overriding + * fixed half strength. Special counters keep their separate support rules. */ + normalFreeSupport?: boolean; elite: number; eliteStrength: 1 | 2; freeSupport: boolean; @@ -31,6 +34,8 @@ export function validCombatForces(forces: CombatForces): boolean { typeof forces.freeSupport === 'boolean' && (forces.eliteFreeSupport === undefined || typeof forces.eliteFreeSupport === 'boolean') && + (forces.normalFreeSupport === undefined || + typeof forces.normalFreeSupport === 'boolean') && (forces.normalFixedHalf === undefined || typeof forces.normalFixedHalf === 'boolean') ); @@ -49,7 +54,9 @@ export function casualtyOptions( !Number.isInteger(dial * 2) || !Number.isSafeInteger(support) || support < 0 || - support > forces.normal + (forces.eliteFreeSupport ? 0 : forces.elite) || + support > + (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) + + (forces.eliteFreeSupport ? 0 : forces.elite) || (forces.freeSupport && support !== 0) ) return []; @@ -58,7 +65,8 @@ export function casualtyOptions( for (let normal = 0; normal <= forces.normal; normal++) { if (forces.freeSupport) { if ( - normal * (forces.normalFixedHalf ? 0.5 : 1) + + normal * + (forces.normalFixedHalf && !forces.normalFreeSupport ? 0.5 : 1) + elite * forces.eliteStrength === dial ) @@ -66,14 +74,22 @@ export function casualtyOptions( continue; } for ( - let paidElite = Math.max(0, support - normal); + let paidElite = Math.max( + 0, + support - + (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : normal), + ); paidElite <= (forces.eliteFreeSupport ? 0 : Math.min(elite, support)); paidElite++ ) { const paidNormal = support - paidElite; - if (forces.normalFixedHalf && paidNormal !== 0) continue; + if ( + (forces.normalFixedHalf || forces.normalFreeSupport) && + paidNormal !== 0 + ) + continue; const doubledStrength = - normal + + normal * (forces.normalFreeSupport ? 2 : 1) + paidNormal + (forces.eliteFreeSupport ? elite * 2 : elite + paidElite) * forces.eliteStrength; diff --git a/game/engine.ts b/game/engine.ts index 0d8cdfce..460c9b10 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,5 +1,7 @@ import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections'; 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 { 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'; @@ -1258,6 +1260,9 @@ export type Game = { /** Optional independent module; complete effect coverage is still release-gated. */ nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null; nexusTraitorExchanges?: NexusTraitorExchange[]; + nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; + nexusSuboidHistory?: NexusSuboidReceipt[]; + nexusSuboidLast?: { event: string; owner: string; turn: number }; nexusTraitorPending?: string | null; nexusTraitorParent?: { event: string; signature: string } | null; /** Null/absent disables the module; custody is installed at force placement. */ @@ -1641,6 +1646,106 @@ function projectedNexusTraitors(g: Game, id: string) { } : null }; } +function nexusFaceDancerIntegrity(g: Game) { + if (g.nexusFaceDancerHistory === undefined) return; + requireRule(g.nexusCards?.cards && Array.isArray(g.nexusFaceDancerHistory), + 'Face Dancer Nexus history requires its original module.'); + const universe = nexusTraitorUniverse(g), events = new Set(); + nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe)); + for (const receipt of g.nexusFaceDancerHistory) { + requireRule(!events.has(receipt.event) && receipt.turn <= g.turn && + g.players.some(p => p.id === receipt.owner && p.faction === 'tleilaxu'), + 'The Face Dancer replacement has lost its original owner or turn.'); + events.add(receipt.event); + nexusRule(() => validateNexusFaceDancerHistory(universe, receipt)); + } +} +function projectedNexusTleilaxu(g: Game, id: string) { + if (!g.nexusCards?.cards) return null; + if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null }; + const p = getPlayer(g, id); + if (p.faction !== 'tleilaxu') return { cunning: null }; + const count = p.faceDancers?.filter(card => card.revealed).length ?? 0; + let blocked: string | null = null; + if (g.status !== 'playing') blocked = 'Use Cunning during an active game.'; + else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; + else if (g.truthtrance) blocked = 'Finish Truthtrance before replacing Face Dancers.'; + else if (g.pendingTreacheryDiscard) blocked = 'Finish the automatic card disposal first.'; + else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; + else if (pendingNexusTraitors(g)) blocked = 'Finish the current private Traitor Deck return first.'; + else if (!count) blocked = 'No revealed Face Dancers are available to replace.'; + else if ((g.traitorReserve?.length ?? 0) < count) blocked = 'The Traitor Deck cannot supply the full replacement draw.'; + return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked } }; +} +function playNexusFaceDancers(g: Game, p: Player, action: Action) { + requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)), + 'Cunning replaces every revealed Face Dancer; do not select a subset.'); + const offer = projectedNexusTleilaxu(g, p.id)?.cunning; + requireRule(offer && action.event === offer.event && !offer.blocked, + offer?.blocked ?? 'Choose the current owned Face Dancer replacement.'); + const result = nexusRule(() => replaceNexusFaceDancers(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), + { event: offer.event, owner: p.id, turn: g.turn, phase: g.phase }, random)); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + applyNexusTraitorSnapshot(g, result.state); + (g.nexusFaceDancerHistory ??= []).push(result.receipt); + log(g, `${p.name} set aside ${offer.count} revealed Face Dancer ${offer.count === 1 ? 'Card' : 'Cards'}, secretly drew replacements, then shuffled the set-aside cards into the Traitor Deck. Unrevealed Face Dancers and the normal Mentat replacement allowance are unchanged.`, + { faction: p.faction, name: 'Nexus Face Dancer replacement' }); +} + +function nexusSuboidIntegrity(g: Game) { + if (g.nexusSuboidHistory === undefined) { + requireRule(!g.nexusSuboidLast, 'The Suboid Nexus effect has lost its saved history.'); + return; + } + requireRule(g.nexusCards?.cards, 'Suboid Nexus history requires its original module.'); + nexusRule(() => validateNexusSuboids(g, g.nexusSuboidHistory!)); + const last = g.nexusSuboidHistory.at(-1); + requireRule(last && JSON.stringify(g.nexusSuboidLast) === JSON.stringify({ event: last.event, owner: last.owner, turn: last.turn }), + 'The Suboid Nexus effect has lost its latest committed play.'); + if (g.nexusSuboidHistory.some(receipt => receipt.turn === g.turn)) + requireRule(g.nexusCards.cards.discard.includes('ixians'), 'The active Suboid effect has lost its spent Nexus card.'); +} +function projectedNexusSuboids(g: Game, id: string) { + if (!g.nexusCards?.cards) return null; + const ixians = byFaction(g, 'ixians'); + const active = !!ixians && nexusSuboidsActive(g, g.nexusSuboidHistory, ixians.id); + if (g.nexusCards.cards.hands[id] !== 'ixians' || ixians?.id !== id) return { offer: null, active }; + const b = g.battle; + let blocked: string | null = null; + if (g.status !== 'playing' || g.phase !== 6 || !b || ![b.attacker, b.defender].includes(id)) + blocked = 'Use Cunning in your battle before submitting your Battle Plan.'; + else if (ixians.ally) blocked = 'Allied players cannot use a Nexus card.'; + else if (b.revealed || b.plans[id]) blocked = 'Your Battle Plan is already submitted.'; + else if (active) blocked = 'Your Suboids already have full strength this turn.'; + else if (g.truthtrance || g.pendingTreacheryDiscard || pendingNexusTraitors(g)) blocked = 'Finish the current private question or card return first.'; + else if (g.pendingNullentropy) blocked = 'Finish the paid Nullentropy search first.'; + else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; + const event = b?.event ? JSON.stringify(['nexusSuboids', g.turn, b.event, id]) : ''; + if (!blocked) { + const trial = structuredClone(g); + (trial.nexusSuboidHistory ??= []).push(nexusRule(() => createNexusSuboids(trial, id, b!.event!))); + trial.nexusSuboidLast = { event, owner: id, turn: g.turn }; + trial.nexusCards!.cards = nexusRule(() => discardNexusCard(trial.nexusCards!.cards!, id, trial.players)); + if (!findReachableBattlePlan(trial, getPlayer(trial, id))) + blocked = 'Full-strength Suboids would prevent you from honoring an existing Battle Plan commitment.'; + } + return { offer: { event, blocked }, active }; +} +function playNexusSuboids(g: Game, p: Player, action: Action) { + const offer = projectedNexusSuboids(g, p.id)?.offer; + requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)) && offer && + action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Ixian Cunning opportunity.'); + commitNexusSuboids(g, p); +} +function commitNexusSuboids(g: Game, p: Player) { + const receipt = nexusRule(() => createNexusSuboids(g, p.id, g.battle!.event!)); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusSuboidHistory ??= []).push(receipt); + g.nexusSuboidLast = { event: receipt.event, owner: receipt.owner, turn: receipt.turn }; + log(g, `${p.name} used Ixian Cunning. Every Suboid has full strength without spice support in all battles for the rest of this turn. Cyborg strength, support and physical force counts are unchanged.`, + { faction: p.faction, name: 'Nexus Suboid strength' }); +} + function battleInspectionContext(g: Game): BattleInspectionContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, @@ -4249,7 +4354,8 @@ function combatForces( ...(homeworldSardaukarFreeSupport(g, p.id) ? { eliteFreeSupport: true } : {}), - normalFixedHalf: p.faction === 'ixians', + normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id), + ...(nexusSuboidsActive(g, g.nexusSuboidHistory, p.id) ? { normalFreeSupport: true } : {}), elite, eliteStrength: (!g.advanced && p.faction !== 'ixians') || @@ -4548,7 +4654,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f !p.prediction && !p.advisorSetup && (!p.elites || - (homeworlds && + ((homeworlds || nexus) && p.faction === 'ixians' && p.elites.reserves === 7 && p.elites.tanks === 0 && @@ -11652,6 +11758,8 @@ function gholaOptions(g: Game, p: Player) { function marketGholaIntegrity(g: Game) { nexusCardsIntegrity(g); nexusTraitorIntegrity(g); + nexusFaceDancerIntegrity(g); + nexusSuboidIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); @@ -11941,13 +12049,21 @@ function findReachableBattlePlan( return { plan, actions: node.actions, waitingForIncome: pending > 0 }; } for (const action of [ + ...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' && + !actor.ally && node.state.nexusCards?.cards?.hands[actor.id] === 'ixians' && + node.state.nexusCards.phase?.stage !== 'drawing' && !node.state.pendingNullentropy && + !node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) && + !nexusSuboidsActive(node.state, node.state.nexusSuboidHistory, actor.id) + ? [{ type: 'nexusSuboids', event: JSON.stringify(['nexusSuboids', node.state.turn, node.state.battle!.event, actor.id]) } as Action] + : []), ...gholaPreparationActions(node.state, actor), ...cashInPreparationActions(node.state, actor), ]) { const trial = structuredClone(node.state), target = getPlayer(trial, p.id); try { - if (action.mode === 'special') specialKarama(trial, target, action); + if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target); + else if (action.mode === 'special') specialKarama(trial, target, action); else { applyGholaEffect(trial, target, action); discard(trial, target, action.card as string); @@ -16794,7 +16910,7 @@ function applyActionInner( stoneBurnerIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); - if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn'].includes(action?.type)) { + if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) { if (action?.type === 'advanceBots') return g; requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.'); requireRule( @@ -16852,6 +16968,8 @@ function applyActionInner( if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; } requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.'); 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 === 'nexusTraitorReturn') { requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; @@ -20319,6 +20437,8 @@ export function viewGame(state: Game, id: string) { strongholdCards: g.strongholdCards ?? null, nexusCards: projectedNexusCards(g, id), nexusTraitors: projectedNexusTraitors(g, id), + nexusTleilaxu: projectedNexusTleilaxu(g, id), + nexusSuboids: projectedNexusSuboids(g, id), nexusAtreides: nexusAtreidesOffer(g, id), homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id), caladanReinforcement: projectedHomeworldVictoryReturn(g, id), diff --git a/game/nexus-face-dancers.ts b/game/nexus-face-dancers.ts new file mode 100644 index 00000000..e900f041 --- /dev/null +++ b/game/nexus-face-dancers.ts @@ -0,0 +1,217 @@ +import { + validateNexusTraitorSnapshot, + type NexusTraitorSnapshot, +} from './nexus-traitor-exchange'; + +export type NexusFaceDancerReceipt = { + version: 1; + event: string; + owner: string; + turn: number; + phase: number; + stage: 'complete'; + source: NexusTraitorSnapshot; + replaced: string[]; + drawn: string[]; + settledReserve: string[]; + signature: string; +}; +export type NexusFaceDancerResult = { + state: NexusTraitorSnapshot; + receipt: NexusFaceDancerReceipt; +}; +function requireDancers( + condition: unknown, + message: string, +): asserts condition { + if (!condition) throw new Error(message); +} +function identifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + !['__proto__', 'constructor', 'prototype'].includes(value) + ); +} +function exact(value: unknown, keys: readonly string[]): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.keys(value).sort().join(',') === [...keys].sort().join(',') + ); +} +function identities(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every(identifier) && + new Set(value).size === value.length + ); +} +function same(one: readonly string[], two: readonly string[]): boolean { + return one.length === two.length && one.every((value, i) => value === two[i]); +} +function validateInput( + input: Pick, +): void { + requireDancers( + exact(input, ['event', 'owner', 'turn', 'phase']) && + identifier(input.event) && + identifier(input.owner) && + Number.isSafeInteger(input.turn) && + input.turn > 0 && + Number.isSafeInteger(input.phase) && + input.phase >= 0 && + input.phase <= 8, + 'Invalid Tleilaxu Nexus replacement event.', + ); +} +function replacementGroup( + state: NexusTraitorSnapshot, + owner: string, +): string[] { + const player = state.players.find((p) => p.id === owner); + requireDancers( + player?.faction === 'tleilaxu' && player.faceDancers, + 'Tleilaxu Cunning requires its native Face Dancer owner.', + ); + const revealed = player.faceDancers + .filter((card) => card.revealed) + .map((card) => card.leader); + requireDancers( + revealed.length > 0, + 'Tleilaxu Cunning requires a revealed Face Dancer.', + ); + requireDancers( + state.reserve.length >= revealed.length, + 'Draw every replacement before returning the revealed Face Dancers.', + ); + return revealed; +} +function signature(receipt: Omit): string { + return JSON.stringify([ + 'nexusFaceDancers', + receipt.version, + receipt.event, + receipt.owner, + receipt.turn, + receipt.phase, + receipt.stage, + receipt.source.reserve, + receipt.source.players.map((p) => [ + p.id, + p.faction, + p.traitors, + p.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null, + ]), + receipt.replaced, + receipt.drawn, + receipt.settledReserve, + ]); +} +function resultingState(receipt: NexusFaceDancerReceipt): NexusTraitorSnapshot { + const state = structuredClone(receipt.source); + const owner = state.players.find((p) => p.id === receipt.owner)!; + owner.faceDancers = [ + ...owner.faceDancers!.filter((card) => !card.revealed), + ...receipt.drawn.map((leader) => ({ leader, revealed: false })), + ]; + state.reserve = [...receipt.settledReserve]; + return state; +} +/** Intrinsic original-event evidence, independent of later live custody. */ +export function validateNexusFaceDancerHistory( + universe: readonly string[], + receipt: NexusFaceDancerReceipt, +): void { + requireDancers( + exact(receipt, [ + 'version', + 'event', + 'owner', + 'turn', + 'phase', + 'stage', + 'source', + 'replaced', + 'drawn', + 'settledReserve', + 'signature', + ]) && + receipt.version === 1 && + receipt.stage === 'complete' && + identities(receipt.replaced) && + identities(receipt.drawn) && + identities(receipt.settledReserve), + 'Invalid saved Tleilaxu Nexus replacement.', + ); + validateInput({ + event: receipt.event, + owner: receipt.owner, + turn: receipt.turn, + phase: receipt.phase, + }); + validateNexusTraitorSnapshot(receipt.source, universe); + const group = replacementGroup(receipt.source, receipt.owner); + requireDancers( + same(receipt.replaced, group) && + same(receipt.drawn, receipt.source.reserve.slice(0, group.length)), + 'The Nexus replacement must preserve the entire original revealed batch and its prior draw.', + ); + requireDancers( + same( + [...receipt.settledReserve].sort(), + [...receipt.source.reserve.slice(group.length), ...group].sort(), + ), + 'The revealed Face Dancers return only after their replacements leave the deck.', + ); + requireDancers( + receipt.signature === signature(receipt), + 'The Tleilaxu Nexus replacement has lost its original history.', + ); + validateNexusTraitorSnapshot(resultingState(receipt), universe); +} +/** Replace the full revealed batch atomically. No optional subset and no draw + * from the just-set-aside cards. Timing and Nexus-card use belong to the engine. */ +export function replaceNexusFaceDancers( + state: NexusTraitorSnapshot, + universe: readonly string[], + input: Pick, + rng: () => number, +): NexusFaceDancerResult { + validateInput(input); + validateNexusTraitorSnapshot(state, universe); + const replaced = replacementGroup(state, input.owner); + requireDancers( + typeof rng === 'function', + 'A Nexus replacement needs valid shuffle randomness.', + ); + const drawn = state.reserve.slice(0, replaced.length); + const reserve = [...state.reserve.slice(replaced.length), ...replaced]; + for (let i = reserve.length - 1; i > 0; i--) { + const value = rng(); + requireDancers( + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value < 1, + 'Invalid Tleilaxu Nexus shuffle randomness.', + ); + const j = Math.floor(value * (i + 1)); + [reserve[i], reserve[j]] = [reserve[j], reserve[i]]; + } + const receipt: NexusFaceDancerReceipt = { + version: 1, + ...input, + stage: 'complete', + source: structuredClone(state), + replaced, + drawn, + settledReserve: reserve, + signature: '', + }; + receipt.signature = signature(receipt); + validateNexusFaceDancerHistory(universe, receipt); + return { state: resultingState(receipt), receipt }; +} diff --git a/game/nexus-suboid-options.ts b/game/nexus-suboid-options.ts new file mode 100644 index 00000000..e994d3fb --- /dev/null +++ b/game/nexus-suboid-options.ts @@ -0,0 +1,39 @@ +import type { Action, GameView } from './engine'; + +/** Use only the private current battle offer; the server checks existing + * commitments before offering changed Suboid strength/support. */ +export function nexusSuboidsAction( + game: GameView, + event: string, +): Action | null { + const offer = game.nexusSuboids?.offer; + const owner = game.players.find((player) => player.id === game.me); + const battle = game.battle; + if ( + game.status !== 'playing' || + game.phase !== 6 || + !owner || + owner.faction !== 'ixians' || + owner.ally || + game.nexusCards?.card !== 'ixians' || + game.truthtrance || + game.nexusCards.waiting.length || + game.nexusTraitors?.pending || + !battle || + ![battle.attacker, battle.defender].includes(owner.id) || + battle.submitted.includes(owner.id) || + game.nexusSuboids?.active || + !offer || + !event || + offer.event !== event || + offer.blocked + ) + return null; + return { type: 'nexusSuboids', event }; +} + +export function nexusSuboidBotActions(game: GameView): Action[] { + const offer = game.nexusSuboids?.offer; + const action = offer ? nexusSuboidsAction(game, offer.event) : null; + return action ? [action] : []; +} diff --git a/game/nexus-suboids.ts b/game/nexus-suboids.ts new file mode 100644 index 00000000..c85e71da --- /dev/null +++ b/game/nexus-suboids.ts @@ -0,0 +1,48 @@ +import type { FactionId } from './catalog'; +export type NexusSuboidContext = { + turn: number; + players: readonly { id: string; faction: FactionId }[]; +}; +export type NexusSuboidReceipt = { + version: 1; event: string; owner: string; turn: number; battle: string; + roster: { id: string; faction: FactionId }[]; signature: string; +}; +function requireSuboids(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +function signature(receipt: NexusSuboidReceipt) { + return JSON.stringify([receipt.version, receipt.event, receipt.owner, receipt.turn, receipt.battle, + receipt.roster.map(p => [p.id, p.faction])]); +} +function roster(context: NexusSuboidContext) { + return context.players.map(({id, faction}) => ({id, faction})).sort((a, b) => a.id.localeCompare(b.id)); +} +export function validateNexusSuboids(context: NexusSuboidContext, history: readonly NexusSuboidReceipt[]) { + requireSuboids(Number.isSafeInteger(context.turn) && context.turn > 0 && Array.isArray(history), + 'Invalid Suboid Nexus turn or history.'); + const turns = new Set(); + for (const receipt of history) { + requireSuboids(receipt && typeof receipt === 'object' && !Array.isArray(receipt) && + Object.keys(receipt).sort().join(',') === 'battle,event,owner,roster,signature,turn,version' && + receipt.version === 1 && typeof receipt.battle === 'string' && receipt.battle.length > 0 && + Number.isSafeInteger(receipt.turn) && receipt.turn > 0 && receipt.turn <= context.turn && + !turns.has(receipt.turn) && + context.players.some(p => p.id === receipt.owner && p.faction === 'ixians') && + receipt.event === JSON.stringify(['nexusSuboids', receipt.turn, receipt.battle, receipt.owner]) && + JSON.stringify(receipt.roster) === JSON.stringify(roster(context)) && + receipt.signature === signature(receipt), + 'The Suboid Nexus effect has lost its original battle, owner or turn.'); + turns.add(receipt.turn); + } +} +export function createNexusSuboids(context: NexusSuboidContext, owner: string, battle: string): NexusSuboidReceipt { + const receipt: NexusSuboidReceipt = { version: 1, owner, turn: context.turn, battle, + event: JSON.stringify(['nexusSuboids', context.turn, battle, owner]), roster: roster(context), signature: '' }; + receipt.signature = signature(receipt); + validateNexusSuboids(context, [receipt]); + return receipt; +} +/** A completed play applies to every battle in its own turn only. */ +export function nexusSuboidsActive(context: NexusSuboidContext, history: readonly NexusSuboidReceipt[] | undefined, owner: string) { + return history?.some(receipt => receipt.owner === owner && receipt.turn === context.turn) ?? false; +} diff --git a/game/nexus-tleilaxu-options.ts b/game/nexus-tleilaxu-options.ts new file mode 100644 index 00000000..23c2f4c4 --- /dev/null +++ b/game/nexus-tleilaxu-options.ts @@ -0,0 +1,37 @@ +import type { Action, GameView } from './engine'; + +/** The server's private offer binds the complete revealed replacement group. + * Do not reconstruct eligibility from private Face Dancer hands. */ +export function nexusFaceDancersAction( + game: GameView, + event: string, +): Action | null { + const offer = game.nexusTleilaxu?.cunning; + const owner = game.players.find((player) => player.id === game.me); + if ( + game.status !== 'playing' || + !owner || + owner.faction !== 'tleilaxu' || + owner.ally || + game.nexusCards?.card !== 'tleilaxu' || + game.truthtrance || + game.nexusCards.waiting.length || + game.nexusTraitors?.pending || + !offer || + !event || + offer.event !== event || + offer.blocked || + !Number.isSafeInteger(offer.count) || + offer.count < 1 + ) + return null; + return { type: 'nexusFaceDancers', event }; +} + +/** All profiles share the legal replacement policy. There is no post-draw + * choice and no access to another player's hand or the reserve order. */ +export function nexusTleilaxuBotActions(game: GameView): Action[] { + const offer = game.nexusTleilaxu?.cunning; + const action = offer ? nexusFaceDancersAction(game, offer.event) : null; + return action ? [action] : []; +} diff --git a/game/reference.ts b/game/reference.ts index 0c2e79e5..d04ac30e 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 and Harkonnen traitor exchanges 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 Tleilaxu/Ixian 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.', @@ -3356,15 +3356,18 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.', 'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.', 'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.', + '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.', '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 draw-first exchanges and preserved traitor declarations. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, Atreides answers/history/plan locks, and exact Harkonnen returns with Traitor or Face Dancer inspection. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, Atreides commitments and Harkonnen exchanges using authorized choices and public leaders. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces, Atreides inspections and Harkonnen exchanges are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_HARKONNEN_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, exchange controls, bot and concurrent recovery regressions; final Harkonnen acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-controls.test.ts', 'tests/nexus-traitor-bots.test.ts', 'tests/nexus-traitor-recovery.test.ts'] }, + { 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'] }, ], }, ...[ diff --git a/game/stone-burner.ts b/game/stone-burner.ts index 74e028c4..5fc68622 100644 --- a/game/stone-burner.ts +++ b/game/stone-burner.ts @@ -89,6 +89,7 @@ function allOpposingTotals( forces.elite, forces.eliteStrength, !!forces.normalFixedHalf, + !!forces.normalFreeSupport, forces.freeSupport, !!forces.eliteFreeSupport, ].join(':'); @@ -96,11 +97,12 @@ function allOpposingTotals( if (cached) return cached; const result: number[][] = []; const maximumHalfDial = - forces.normal * (forces.normalFixedHalf ? 1 : 2) + + forces.normal * + (forces.normalFixedHalf && !forces.normalFreeSupport ? 1 : 2) + forces.elite * forces.eliteStrength * 2; const maximumSupport = forces.freeSupport ? 0 - : (forces.normalFixedHalf ? 0 : forces.normal) + + : (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) + (forces.eliteFreeSupport ? 0 : forces.elite); // Use the same legality and typed-support arithmetic as sealed plans. Cache // public pool results so repeated preflights do not repeat this enumeration. diff --git a/tests/fixture-nexus-cunning.ts b/tests/fixture-nexus-cunning.ts new file mode 100644 index 00000000..8a45d235 --- /dev/null +++ b/tests/fixture-nexus-cunning.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { nexusTraitorFixture } from './fixture-nexus-traitors'; +import { validateNexusCards } from '../game/nexus-cards'; +export function suboidFixture(advanced = true, atreides = false) { + let g = nexusTraitorFixture({ ownerFaction: 'ixians', opponentFaction: atreides ? 'atreides' : 'guild', ix: true, advanced }); + const cards = g.nexusCards!.cards!; + const index = cards.deck.indexOf('ixians'); assert.ok(index >= 0); + cards.deck[index] = cards.hands.p!; cards.hands.p = 'ixians'; + Object.assign(g, { phase: 6, active: 'p', order: ['p', 'q', 'r'], ready: [], response: null, decision: null, phaseOpening: null }); + for (const p of g.players) { + p.forces = {}; p.reserves = 20; p.tanks = 0; + if (p.elites) { p.elites.reserves = p.faction === 'ixians' ? 7 : 3; p.elites.tanks = 0; p.elites.forces = {}; } + } + for (const p of g.players.slice(0, 2)) { + p.forces = { 'pasty_mesa:5': 5, 'hagga_basin:12': 3 }; p.reserves = 12; + } + g.players[0].elites!.forces = { 'pasty_mesa:5': 2 }; g.players[0].elites!.reserves = 5; + g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'pasty_mesa', target: 'q' }); + for (const id of ['p', 'q']) if (g.battle!.preLeader && !g.battle!.preLeader.closed) + g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle!.event }); + validateNexusCards(g.nexusCards!.cards!, g.players); + return g; +} +export function boostSuboids(g: Game) { + const offer = viewGame(g, 'p').nexusSuboids!.offer!; + assert.equal(offer.blocked, null); + return applyAction(g, 'p', { type: 'nexusSuboids', event: offer.event }); +} diff --git a/tests/fixture-nexus-face-dancers.ts b/tests/fixture-nexus-face-dancers.ts new file mode 100644 index 00000000..684ec02a --- /dev/null +++ b/tests/fixture-nexus-face-dancers.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { + nexusTraitorFixture, + nexusTraitorInventory, +} from './fixture-nexus-traitors'; + +/** The final Tleilaxu roster and physical components come from genuine setup. + * Only the conserved held Nexus card and revealed-card checkpoint are staged. */ +export function nexusFaceDancerFixture(revealed = 2) { + const g = nexusTraitorFixture({ ownerFaction: 'tleilaxu' }); + const cards = g.nexusCards!.cards!; + const index = cards.deck.indexOf('tleilaxu'); + assert.ok(index >= 0); + assert.equal(cards.hands.p, 'harkonnen'); + cards.deck[index] = 'harkonnen'; + cards.hands.p = 'tleilaxu'; + g.players[0].faceDancers!.forEach((card, i) => { + card.revealed = i < revealed; + }); + nexusTraitorInventory(g); + return g; +} diff --git a/tests/nexus-cunning-recovery.test.ts b/tests/nexus-cunning-recovery.test.ts new file mode 100644 index 00000000..24387d91 --- /dev/null +++ b/tests/nexus-cunning-recovery.test.ts @@ -0,0 +1,281 @@ +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 { suboidFixture } from './fixture-nexus-cunning'; +import { nexusFaceDancerFixture } from './fixture-nexus-face-dancers'; +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> & { sqlite: DatabaseSync }; +const row = (f: Fixture) => + f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(f.code); +const suboidAction = (g: Game): Action => ({ + type: 'nexusSuboids', + event: viewGame(g, 'p').nexusSuboids!.offer!.event, +}); +const dancerAction = (g: Game): Action => ({ + type: 'nexusFaceDancers', + event: viewGame(g, 'p').nexusTleilaxu!.cunning!.event, +}); +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, 'nexusFaceDancerHistory'), false); + assert.equal(Object.hasOwn(actual, 'nexusSuboidHistory'), 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((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 Ixian Cunning plays persist one turn effect and spend one card in Basic and Advanced', async () => { + for (const advanced of [false, true]) { + const f = await fixture(suboidFixture(advanced)); + try { + const action = suboidAction(f.initial), + g = await compete(f, action); + assert.equal(g.nexusSuboidHistory!.length, 1); + assert.equal(g.nexusSuboidLast!.event, g.nexusSuboidHistory![0].event); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'ixians').length, + 1, + ); + assert.equal(g.nexusCards!.cards!.hands.p, null); + assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true); + assert.equal(viewGame(g, 'p').battle!.ownForces!.freeSupport, !advanced); + await restored(f, g); + const before = row(f); + await assert.rejects( + f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock), + ); + await assert.rejects( + f.rooms.act(f.code, f.auths[0], g.version, action, clock), + ); + assert.deepEqual(row(f), before); + } finally { + f.sqlite.close(); + } + } +}); + +void test('SQLite lost Ixian response restores private hands and the same boosted plan in a later request', async () => { + const initial = suboidFixture(true); + for (const [i, kind] of [ + [0, 'projectile'], + [1, 'shield'], + ] as const) { + const index = initial.deck.findIndex((c) => c.kind === kind); + initial.players[i].hand.push(initial.deck.splice(index, 1)[0]); + } + const f = await fixture(initial); + try { + await f.rooms.act( + f.code, + f.auths[0], + f.initial.version, + suboidAction(f.initial), + clock, + ); + let g = await f.restart().readRoom(f.code); + await restored(f, g); + const own = viewGame(g, 'p'), + other = viewGame(g, 'q'); + assert.equal(own.players[0].hand![0].id, g.players[0].hand[0].id); + assert.equal(other.players[0].hand, undefined); + await f + .restart() + .act( + f.code, + f.auths[0], + g.version, + { + type: 'battlePlan', + dial: 3, + support: 0, + leader: g.players[0].leaders[0].id, + }, + clock, + ); + g = await f.restart().readRoom(f.code); + assert.equal(g.battle!.plans.p.dial, 3); + assert.equal(g.battle!.plans.p.support, 0); + assert.equal(g.nexusSuboidHistory!.length, 1); + assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true); + assert.deepEqual(viewGame(g, 'q').battle!.plans, {}); + await restored(f, g); + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite competing Face Dancer replacements commit one original draw and one shuffle, and lost responses cannot replay', async () => { + const f = await fixture(nexusFaceDancerFixture()); + try { + const action = dancerAction(f.initial), + original = f.initial.players[0].faceDancers!, + drawn = f.initial.traitorReserve!.slice(0, 2); + const unrelated = await f.rooms.createRoom( + 'Unrelated', + 'atreides', + false, + [], + ); + const otherRow = f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(unrelated.view.code); + f.writes.length = 0; + const g = await compete(f, action); + assert.equal(g.nexusFaceDancerHistory!.length, 1); + assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, drawn); + assert.deepEqual(g.players[0].faceDancers, [ + original[2], + ...drawn.map((leader) => ({ leader, revealed: false })), + ]); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'tleilaxu').length, + 1, + ); + assert.equal(g.nexusCards!.cards!.hands.p, null); + for (const id of ['q', 'r']) + assert.equal(viewGame(g, id).players[0].faceDancers, undefined); + await restored(f, g); + const before = row(f); + await assert.rejects( + f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock), + ); + await assert.rejects( + f.rooms.act(f.code, f.auths[0], g.version, action, clock), + ); + assert.deepEqual(row(f), before); + assert.deepEqual( + f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(unrelated.view.code), + otherRow, + ); + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite rejects corrupted Cunning history and missing Suboid last marker without repairing or writing saves', async () => { + for (const kind of ['suboid', 'dancer'] as const) { + const f = await fixture( + kind === 'suboid' ? suboidFixture() : nexusFaceDancerFixture(), + ); + try { + const action = + kind === 'suboid' ? suboidAction(f.initial) : dancerAction(f.initial); + await f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock); + const good = await f.rooms.readRoom(f.code); + const mutations: Array<(g: Game) => void> = + kind === 'suboid' + ? [ + (g) => { + delete g.nexusSuboidHistory; + }, + (g) => { + delete g.nexusSuboidLast; + }, + (g) => { + g.nexusSuboidHistory![0].turn++; + }, + ] + : [ + (g) => { + g.nexusFaceDancerHistory![0].drawn.reverse(); + }, + (g) => { + g.nexusFaceDancerHistory![0].source.players[0].faceDancers![0].revealed = false; + }, + ]; + for (const mutate of mutations) { + const bad = structuredClone(good); + mutate(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[0], bad.version, action, clock), + ); + await f.restart().continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + } + } finally { + f.sqlite.close(); + } + } +}); diff --git a/tests/nexus-face-dancer-engine.test.ts b/tests/nexus-face-dancer-engine.test.ts new file mode 100644 index 00000000..cccc0756 --- /dev/null +++ b/tests/nexus-face-dancer-engine.test.ts @@ -0,0 +1,368 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + nexusTraitorFixture, + nexusTraitorInventory, + nexusTraitorBattle, +} from './fixture-nexus-traitors'; +import { nexusAllow, nexusReload } from './fixture-nexus-cards'; + +function fixture( + options: { + revealed?: number; + phase?: number; + opponentFaction?: 'atreides' | 'richese'; + } = {}, +): Game { + const g = nexusTraitorFixture({ + ownerFaction: 'tleilaxu', + opponentFaction: options.opponentFaction, + phase: options.phase, + }); + const cards = g.nexusCards!.cards!; + const index = cards.deck.indexOf('tleilaxu'); + assert.ok(index >= 0); + assert.equal(cards.hands.p, 'harkonnen'); + cards.deck[index] = 'harkonnen'; + cards.hands.p = 'tleilaxu'; + g.players[0].faceDancers!.forEach((card, i) => { + card.revealed = i < (options.revealed ?? 2); + }); + nexusTraitorInventory(g); + return g; +} +function action(g: Game): Action { + const offer = viewGame(g, 'p').nexusTleilaxu!.cunning!; + assert.equal(offer.blocked, null); + return { type: 'nexusFaceDancers', event: offer.event }; +} +function refresh(g: Game): Game { + return applyAction(g, 'p', action(g)); +} +function hold(g: Game, owner: string, effect: string): string { + const source = g.richeseCache?.some((card) => card.effect === effect) + ? g.richeseCache + : g.deck; + const index = source.findIndex((card) => card.effect === effect); + assert.ok(index >= 0); + const card = source.splice(index, 1)[0]; + g.players.find((p) => p.id === owner)!.hand.push(card); + return card.id; +} +function reject(g: Game, id: string, attempted: Action): void { + const before = JSON.stringify(g); + assert.throws(() => applyAction(g, id, attempted)); + assert.equal(JSON.stringify(g), before); +} +function inventory(g: Game): string[] { + return [ + ...g.deck, + ...g.discard, + ...(g.richeseCache ?? []), + ...g.players.flatMap((p) => p.hand), + ] + .map((c) => c.id) + .sort(); +} + +/** The leader identity moves between existing physical zones before the battle; + * actual battle resolution and the ordinary reveal create the revealed dancer. */ +function actuallyReveal(): Game { + let g = fixture({ revealed: 0, phase: 6 }); + const owner = g.players[0], + winner = g.players[1], + loser = g.players[2]; + const leader = winner.leaders.reduce((best, next) => + next.strength > best.strength ? next : best, + ); + const opposing = loser.leaders.reduce((best, next) => + next.strength < best.strength ? next : best, + ); + assert.ok(leader.strength > opposing.strength); + if (!owner.faceDancers!.some((c) => c.leader === leader.id)) { + const displaced = owner.faceDancers![0].leader; + const index = g.traitorReserve!.indexOf(leader.id); + if (index >= 0) g.traitorReserve![index] = displaced; + else { + const holder = g.players.find((p) => p.traitors.includes(leader.id)); + assert.ok(holder); + holder.traitors[holder.traitors.indexOf(leader.id)] = displaced; + } + owner.faceDancers![0].leader = leader.id; + } + for (const p of g.players) { + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + } + for (const p of [winner, loser]) { + p.forces = { 'pasty_mesa:5': 3 }; + p.reserves = 17; + } + g.active = 'q'; + g.order = ['q', 'r', 'p']; + g = applyAction(g, 'q', { + type: 'chooseBattle', + territory: 'pasty_mesa', + target: 'r', + }); + for (const id of ['q', 'r']) + if (g.battle!.preLeader && !g.battle!.preLeader.closed) + g = applyAction(g, id, { + type: 'battlePreparationReady', + event: g.battle!.event, + }); + for (const [id, disc] of [ + ['q', leader.id], + ['r', opposing.id], + ]) + g = applyAction(g, id, { type: 'battlePlan', leader: disc, dial: 0 }); + for (const id of ['q', 'r']) + g = applyAction(g, id, { type: 'traitorCall', call: false }); + assert.equal(g.decision?.kind, 'faceDance'); + g = applyAction(g, 'p', { + type: 'decision', + reveal: true, + sources: { reserves: 1 }, + sector: 5, + }); + assert.equal(g.players[0].faceDancers!.filter((c) => c.revealed).length, 1); + nexusTraitorInventory(g); + return g; +} + +void test('a real winning-leader Face Dance supplies the revealed card for an atomic Nexus refresh', () => { + const original = actuallyReveal(); + const replaced = original.players[0].faceDancers!.find( + (c) => c.revealed, + )!.leader; + const next = original.traitorReserve![0]; + const g = refresh(original); + assert.deepEqual(g.nexusFaceDancerHistory![0].replaced, [replaced]); + assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, [next]); + assert.ok(g.traitorReserve!.includes(replaced)); + assert.ok( + g.players[0].faceDancers!.some((c) => c.leader === next && !c.revealed), + ); + assert.equal( + g.players[0].faceDancers!.some((c) => c.leader === replaced), + false, + ); + assert.equal(g.nexusCards!.cards!.hands.p, null); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'tleilaxu').length, + 1, + ); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g)); + nexusTraitorInventory(g); +}); + +void test('all revealed dancers refresh together off turn in multiple phases without a choice or acknowledgement', () => { + for (const phase of [2, 4, 5, 6, 8]) { + const original = fixture({ phase }); + original.active = 'q'; + const before = structuredClone(original.players[0].faceDancers!); + const drawn = original.traitorReserve!.slice(0, 2); + const g = refresh(original); + assert.deepEqual(g.nexusFaceDancerHistory![0].drawn, drawn); + assert.deepEqual(g.players[0].faceDancers, [ + before[2], + ...drawn.map((leader) => ({ leader, revealed: false })), + ]); + assert.equal(g.decision, null); + assert.equal(g.response, null); + assert.equal(g.active, 'q'); + assert.equal(g.phase, phase); + nexusTraitorInventory(g); + } +}); + +void test('the shared-deck refresh preserves an actual native Prescience cancellation window', () => { + let g = fixture({ opponentFaction: 'atreides' }); + hold(g, 'r', 'karama'); + g = nexusTraitorBattle(g, false); + g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); + const before = nexusReload(g); + g = refresh(g); + assert.deepEqual(g.response, before.response); + assert.deepEqual(g.battle, before.battle); + g = nexusAllow(nexusReload(g)); + g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 }); + assert.equal(g.battle!.prescience!.value, 0); + nexusTraitorInventory(g); +}); + +void test('refreshing during an actual paid Nullentropy choice preserves its fee, search and original discard pile', () => { + let g = fixture({ opponentFaction: 'richese' }); + const box = hold(g, 'q', 'nullentropyBox'); + for (const kind of ['shield', 'projectile']) { + const index = g.deck.findIndex((c) => c.kind === kind); + assert.ok(index >= 0); + g.discard.push(g.deck.splice(index, 1)[0]); + } + g.players[1].spice = 10; + g = applyAction(g, 'q', { type: 'card', card: box }); + assert.equal(g.decision?.kind, 'nullentropy'); + const before = nexusReload(g), + allCards = inventory(g); + g = refresh(nexusReload(g)); + assert.deepEqual(g.pendingNullentropy, before.pendingNullentropy); + assert.deepEqual(g.decision, before.decision); + assert.deepEqual(g.discard, before.discard); + assert.equal(g.players[1].spice, 8); + const selected = g.discard[0].id; + g = applyAction(nexusReload(g), 'q', { + type: 'decision', + event: g.pendingNullentropy!.event, + card: selected, + }); + assert.equal(g.players[1].spice, 8); + assert.ok(g.players[1].hand.some((c) => c.id === selected)); + assert.deepEqual(inventory(g), allCards); +}); + +void test('active Truthtrance blocks refresh without spending the card or exposing a new dancer', () => { + let g = fixture(); + const request = action(g), + truth = hold(g, 'r', 'truthtrance'); + g = applyAction(g, 'r', { type: 'card', card: truth }); + assert.match( + viewGame(g, 'p').nexusTleilaxu!.cunning!.blocked!, + /Truthtrance/, + ); + reject(g, 'p', request); + assert.equal(g.nexusFaceDancerHistory, undefined); + assert.equal(g.nexusCards!.cards!.hands.p, 'tleilaxu'); +}); + +void test('ordinary Mentat replacement and Nexus refresh keep their independent usage in either order', () => { + for (const normalFirst of [false, true]) { + let g = fixture({ revealed: 1 }); + const normal = (state: Game) => + nexusAllow( + applyAction(state, 'p', { + type: 'replaceFaceDancer', + leader: state.players[0].faceDancers!.find((c) => !c.revealed)! + .leader, + }), + ); + if (normalFirst) g = normal(g); + const used = g.players[0].faceDancerReplacedTurn; + g = refresh(g); + assert.equal(g.players[0].faceDancerReplacedTurn, used); + if (!normalFirst) g = normal(g); + assert.equal(g.players[0].faceDancerReplacedTurn, g.turn); + reject(g, 'p', { + type: 'replaceFaceDancer', + leader: g.players[0].faceDancers![0].leader, + }); + nexusTraitorInventory(g); + } +}); + +void test('private replacement identities and history never enter another seat projection', () => { + const initial = fixture(), + changed = nexusReload(initial); + [changed.traitorReserve![0], changed.traitorReserve![2]] = [ + changed.traitorReserve![2], + changed.traitorReserve![0], + ]; + const one = refresh(initial), + two = refresh(changed); + assert.notDeepEqual( + viewGame(one, 'p').players[0].faceDancers, + viewGame(two, 'p').players[0].faceDancers, + ); + for (const id of ['q', 'r']) { + const a = viewGame(one, id), + b = viewGame(two, id); + assert.deepEqual(a.players, b.players); + assert.deepEqual(a.nexusTleilaxu, b.nexusTleilaxu); + assert.deepEqual( + a.log.map((entry) => entry.text), + b.log.map((entry) => entry.text), + ); + assert.equal(a.players[0].faceDancers, undefined); + assert.equal(Object.hasOwn(a, 'nexusFaceDancerHistory'), false); + assert.equal(Object.hasOwn(a, 'traitorReserve'), false); + } +}); + +void test('stale, foreign, subset, duplicate and zero-revealed requests reject without mutation', () => { + const g = fixture(), + request = action(g); + reject(g, 'p', { ...request, event: 'old-event' }); + reject(g, 'q', request); + reject(g, 'p', { ...request, cards: [g.players[0].faceDancers![0].leader] }); + const done = refresh(g); + reject(done, 'p', request); + const empty = fixture({ revealed: 0 }); + assert.match( + viewGame(empty, 'p').nexusTleilaxu!.cunning!.blocked!, + /No revealed/, + ); + reject(empty, 'p', { + type: 'nexusFaceDancers', + event: viewGame(empty, 'p').nexusTleilaxu!.cunning!.event, + }); +}); + +void test('saved replacement receipts reject corrupted identity, source, ownership and duplicate history', () => { + const done = refresh(fixture()); + for (const mutate of [ + (g: Game) => { + g.nexusFaceDancerHistory![0].drawn[0] = 'invented'; + }, + (g: Game) => { + g.nexusFaceDancerHistory![0].source.reserve.reverse(); + }, + (g: Game) => { + g.nexusFaceDancerHistory![0].owner = 'q'; + }, + (g: Game) => { + g.nexusFaceDancerHistory![0].turn++; + }, + (g: Game) => { + g.nexusFaceDancerHistory!.push( + structuredClone(g.nexusFaceDancerHistory![0]), + ); + }, + ]) { + const changed = nexusReload(done); + mutate(changed); + const before = JSON.stringify(changed); + for (const p of changed.players) + assert.throws(() => viewGame(changed, p.id)); + assert.throws(() => normalizeAutomaticGame(changed)); + assert.throws(() => applyAction(changed, 'p', { type: 'advanceBots' })); + assert.equal(JSON.stringify(changed), before); + } +}); + +void test('all four profiles perform the real atomic refresh using only their projected offer', () => { + const initial = fixture(); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(initial, 'p'); + view.players[0].bot = difficulty; + const before = structuredClone(view), + actions = botActions(view); + assert.deepEqual(view, before); + assert.equal(actions[0]?.type, 'nexusFaceDancers'); + const g = applyAction(initial, 'p', actions[0]); + assert.equal(g.nexusFaceDancerHistory!.length, 1); + assert.equal( + g.players[0].faceDancers!.every((c) => !c.revealed), + true, + ); + nexusTraitorInventory(g); + } +}); diff --git a/tests/nexus-face-dancers.test.ts b/tests/nexus-face-dancers.test.ts new file mode 100644 index 00000000..af872da4 --- /dev/null +++ b/tests/nexus-face-dancers.test.ts @@ -0,0 +1,269 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { leaders } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { + validateNexusTraitorSnapshot, + type NexusTraitorSnapshot, +} from '../game/nexus-traitor-exchange'; +import { + replaceNexusFaceDancers, + validateNexusFaceDancerHistory, + type NexusFaceDancerReceipt, +} from '../game/nexus-face-dancers'; + +const universe = traitorDeck( + ['tleilaxu', 'harkonnen', 'guild'].map((f) => ({ + leaders: leaders(f as 'tleilaxu' | 'harkonnen' | 'guild'), + })), + true, +); +const event = { event: 'tleilaxu-nexus-1', owner: 't', turn: 3, phase: 6 }; +const reload = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +function fixture(revealed = [true, false, true]): NexusTraitorSnapshot { + return { + reserve: universe.slice(5), + players: [ + { + id: 't', + faction: 'tleilaxu', + traitors: [], + faceDancers: universe + .slice(0, 3) + .map((leader, i) => ({ leader, revealed: revealed[i] })), + }, + { id: 'h', faction: 'harkonnen', traitors: [universe[3]] }, + { id: 'g', faction: 'guild', traitors: [universe[4]] }, + ], + }; +} +void test('one, two or three revealed Face Dancers are replaced as a whole batch before their cards return to the reserve', () => { + for (const count of [1, 2, 3]) { + const state = fixture([0, 1, 2].map((i) => i < count)), + before = reload(state); + const result = replaceNexusFaceDancers(state, universe, event, () => 0.999); + assert.deepEqual(state, before); + assert.deepEqual(result.receipt.replaced, universe.slice(0, count)); + assert.deepEqual(result.receipt.drawn, state.reserve.slice(0, count)); + assert.deepEqual(result.state.reserve, [ + ...state.reserve.slice(count), + ...universe.slice(0, count), + ]); + assert.deepEqual(result.state.players[0].faceDancers, [ + ...state.players[0].faceDancers!.filter((c) => !c.revealed), + ...state.reserve + .slice(0, count) + .map((leader) => ({ leader, revealed: false })), + ]); + assert.deepEqual(result.state.players.slice(1), state.players.slice(1)); + assert.equal( + result.state.players[0].faceDancers!.some((c) => + result.receipt.replaced.includes(c.leader), + ), + false, + ); + validateNexusTraitorSnapshot(result.state, universe); + } +}); +void test('replacement order preserves unrevealed originals and never offers a subset or returning new cards', () => { + const state = fixture([true, false, true]); + const result = replaceNexusFaceDancers(state, universe, event, () => 0); + assert.deepEqual( + result.state.players[0].faceDancers![0], + state.players[0].faceDancers![1], + ); + assert.equal(result.state.players[0].faceDancers!.length, 3); + assert.equal( + result.state.players[0].faceDancers!.every((c) => c.revealed === false), + true, + ); + assert.equal(result.receipt.stage, 'complete'); + assert.throws(() => + replaceNexusFaceDancers( + state, + universe, + { ...event, cards: [universe[0]] } as typeof event, + () => 0, + ), + ); + assert.throws(() => + replaceNexusFaceDancers( + result.state, + universe, + { ...event, event: 'new-event' }, + () => { + throw Error('No revealed cards must not shuffle'); + }, + ), + ); +}); +void test('zero revealed, undersized reserve and wrong faction reject before randomness or mutation', () => { + const zero = fixture([false, false, false]); + const short = fixture(); + short.players[1].traitors.push(...short.reserve.splice(1)); + for (const [state, input] of [ + [zero, event], + [short, event], + [fixture(), { ...event, owner: 'h' }], + [fixture(), { ...event, owner: 'missing' }], + ] as Array<[NexusTraitorSnapshot, typeof event]>) { + const before = reload(state); + let calls = 0; + assert.throws(() => + replaceNexusFaceDancers(state, universe, input, () => { + calls++; + return 0; + }), + ); + assert.equal(calls, 0); + assert.deepEqual(state, before); + } +}); +void test('even an exactly sized reserve draws entirely before set-aside cards are shuffled', () => { + const state = fixture(); + state.players[1].traitors.push(...state.reserve.splice(2)); + const drawn = [...state.reserve]; + const result = replaceNexusFaceDancers(state, universe, event, () => 0); + assert.deepEqual(result.receipt.drawn, drawn); + assert.deepEqual( + [...result.state.reserve].sort(), + [universe[0], universe[2]].sort(), + ); + assert.deepEqual( + result.state.players[0].faceDancers!.slice(1).map((c) => c.leader), + drawn, + ); + validateNexusFaceDancerHistory(universe, result.receipt); +}); +void test('validated injected randomness shuffles only the remaining reserve and old revealed identities', () => { + const state = fixture(), + before = reload(state); + let calls = 0; + const result = replaceNexusFaceDancers(state, universe, event, () => { + calls++; + return 0; + }); + assert.equal(calls, state.reserve.length - 1); + assert.deepEqual( + [...result.state.reserve].sort(), + [...state.reserve.slice(2), universe[0], universe[2]].sort(), + ); + assert.deepEqual( + result, + replaceNexusFaceDancers(state, universe, event, () => 0), + ); + for (const invalid of [-1, 1, NaN, Infinity]) + assert.throws(() => + replaceNexusFaceDancers(state, universe, event, () => invalid), + ); + assert.deepEqual(state, before); +}); +void test('history validates JSON independently of later legitimate live custody and output mutations do not alias the receipt', () => { + const result = replaceNexusFaceDancers( + fixture(), + universe, + event, + () => 0.999, + ); + const original = reload(result.receipt); + validateNexusFaceDancerHistory(universe, reload(result.receipt)); + result.state.players[0].faceDancers![0].revealed = true; + [result.state.players[1].traitors[0], result.state.reserve[0]] = [ + result.state.reserve[0], + result.state.players[1].traitors[0], + ]; + validateNexusTraitorSnapshot(result.state, universe); + validateNexusFaceDancerHistory(universe, result.receipt); + assert.deepEqual(result.receipt, original); +}); +void test('changed whole-batch history, source statuses, original draw order and settled reserve are detected', () => { + const receipt = replaceNexusFaceDancers( + fixture(), + universe, + event, + () => 0.999, + ).receipt; + for (const mutate of [ + (r: NexusFaceDancerReceipt) => { + r.replaced.pop(); + }, + (r: NexusFaceDancerReceipt) => { + r.replaced.reverse(); + }, + (r: NexusFaceDancerReceipt) => { + r.drawn.reverse(); + }, + (r: NexusFaceDancerReceipt) => { + r.source.players[0].faceDancers![0].revealed = false; + }, + (r: NexusFaceDancerReceipt) => { + r.source.reserve.reverse(); + }, + (r: NexusFaceDancerReceipt) => { + r.settledReserve.reverse(); + }, + (r: NexusFaceDancerReceipt) => { + r.event = 'other'; + }, + (r: NexusFaceDancerReceipt) => { + r.turn++; + }, + (r: NexusFaceDancerReceipt) => { + r.phase++; + }, + (r: NexusFaceDancerReceipt) => { + delete (r as Partial).source; + }, + (r: NexusFaceDancerReceipt) => { + Object.assign(r, { selected: [] }); + }, + ]) { + const corrupt = reload(receipt); + mutate(corrupt); + const before = reload(corrupt); + assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt)); + assert.deepEqual(corrupt, before); + } +}); +void test('corrupt physical census and invalid event metadata reject without invoking shuffle', () => { + for (const mutate of [ + (s: NexusTraitorSnapshot) => { + s.reserve[0] = s.players[0].faceDancers![0].leader; + }, + (s: NexusTraitorSnapshot) => { + s.players[0].traitors = [universe[0]]; + }, + (s: NexusTraitorSnapshot) => { + s.players[0].faceDancers!.pop(); + }, + (s: NexusTraitorSnapshot) => { + s.players[1].id = '__proto__'; + }, + ]) { + const state = fixture(); + mutate(state); + assert.throws(() => + replaceNexusFaceDancers(state, universe, event, () => { + throw Error('Corrupt census must not shuffle'); + }), + ); + } + for (const change of [ + { event: '' }, + { turn: 0 }, + { turn: Infinity }, + { phase: -1 }, + { phase: 9 }, + { phase: 1.5 }, + ]) + assert.throws(() => + replaceNexusFaceDancers( + fixture(), + universe, + { ...event, ...change }, + () => { + throw Error('Invalid event must not shuffle'); + }, + ), + ); +}); diff --git a/tests/nexus-suboid-bots.test.ts b/tests/nexus-suboid-bots.test.ts new file mode 100644 index 00000000..2a2435f8 --- /dev/null +++ b/tests/nexus-suboid-bots.test.ts @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nexusSuboidBotActions } from '../game/nexus-suboid-options'; +import { casualtyOptions } from '../game/combat'; + +/** Authoritative projections isolate bot consumers; engine tests prove actual + * card spending and changes to physical battle strength. */ +function fixture(): GameView { + const g = createGame('SUBOIDBOTS', newPlayer('i', 'Ixians', 'ixians'), true); + g.players.push( + newPlayer('h', 'Harkonnen', 'harkonnen'), + newPlayer('a', 'Atreides', 'atreides'), + ); + Object.assign(g, { + status: 'playing', + phase: 6, + turn: 2, + active: 'i', + storm: 18, + order: ['i', 'h', 'a'], + }); + for (const player of g.players.slice(0, 2)) { + player.forces = { 'arrakeen:10': 5 }; + player.reserves = 15; + player.spice = 10; + } + g.players[0].elites!.forces = { 'arrakeen:10': 2 }; + g.players[0].elites!.reserves = 5; + g.battle = { + event: 'suboid-battle', + territory: 'arrakeen', + attacker: 'i', + defender: 'h', + prepared: true, + plans: {}, + revealed: false, + traitorCalls: {}, + }; + const v = viewGame(g, 'i'); + v.nexusCards = { + card: 'ixians', + deckCount: 11, + discardCount: 0, + held: { i: true, h: false, a: false }, + turn: null, + choices: [], + waiting: [], + }; + v.nexusSuboids = { + offer: { event: 'suboid-event', blocked: null }, + active: false, + }; + return v; +} + +void test('all profiles use only the offered native Cunning before their battle plan', () => { + for (const difficulty of DIFFICULTIES) { + const v = fixture(); + v.players[0].bot = difficulty; + const before = structuredClone(v); + assert.deepEqual(botActions(v), [ + { type: 'nexusSuboids', event: 'suboid-event' }, + ]); + assert.deepEqual(v, before); + v.nexusSuboids!.offer!.blocked = + 'An earlier commitment would become impossible.'; + assert.deepEqual(nexusSuboidBotActions(v), []); + v.nexusSuboids!.offer!.blocked = null; + v.battle!.submitted = ['i']; + assert.deepEqual(nexusSuboidBotActions(v), []); + v.battle!.submitted = []; + v.truthtrance = { + stage: 'priority', + queue: [], + passed: [], + question: null, + }; + assert.deepEqual(botActions(v), [{ type: 'truthPass' }]); + } +}); + +void test('all profiles produce feasible boosted Suboid plans using projected support, including zero spice', () => { + for (const difficulty of DIFFICULTIES) + for (const spice of [0, 2]) { + const v = fixture(); + v.players[0].bot = difficulty; + v.players[0].spice = spice; + v.nexusCards!.card = null; + v.nexusSuboids = { offer: null, active: true }; + const forces = v.battle!.ownForces!; + forces.normalFixedHalf = false; + forces.normalFreeSupport = true; + const before = structuredClone(v); + const actions = botActions(v); + assert.ok(actions.length); + for (const action of actions) { + assert.equal(action.type, 'battlePlan'); + assert.ok(Number(action.support) <= spice); + assert.ok(Number(action.support) <= forces.elite); + assert.ok( + casualtyOptions(forces, Number(action.dial), Number(action.support)) + .length, + ); + } + assert.deepEqual(v, before); + } +}); + +void test('native Cunning policy reads no unseen hands or opposing sealed plans', () => { + const v = fixture(); + v.players[0].bot = 'Brutal'; + for (const player of v.players) + for (const field of ['hand', 'traitors', 'faceDancers', 'spice']) + Object.defineProperty(player, field, { + get() { + throw new Error(`Private ${field} read`); + }, + }); + Object.defineProperty(v.battle!, 'plans', { + get() { + throw new Error('Read sealed plans'); + }, + }); + assert.deepEqual(botActions(v), [ + { type: 'nexusSuboids', event: 'suboid-event' }, + ]); + v.me = 'h'; + assert.deepEqual(nexusSuboidBotActions(v), []); +}); diff --git a/tests/nexus-suboid-controls.test.ts b/tests/nexus-suboid-controls.test.ts new file mode 100644 index 00000000..465452db --- /dev/null +++ b/tests/nexus-suboid-controls.test.ts @@ -0,0 +1,163 @@ +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 { createGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { NexusSuboids } from '../components/nexus-suboids'; +import { nexusSuboidsAction } from '../game/nexus-suboid-options'; + +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((m) => [m[1], m[1]]), + ); + return { + format: 'module', + source: `export default ${JSON.stringify(classes)}`, + shortCircuit: true, + }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +/** Synthetic private offer over an ordinary projected battle; source/custody + * execution belongs to the engine regression suite. CSS aliases preserve real + * component structure, without claiming browser or visual acceptance. */ +function fixture(): GameView { + const g = createGame( + 'SUBOIDCONTROLS', + newPlayer('i', 'Ixians', 'ixians'), + true, + ); + g.players.push( + newPlayer('h', 'Harkonnen', 'harkonnen'), + newPlayer('a', 'Atreides', 'atreides'), + ); + Object.assign(g, { + status: 'playing', + phase: 6, + turn: 2, + active: 'i', + storm: 18, + order: ['i', 'h', 'a'], + }); + for (const player of g.players.slice(0, 2)) { + player.forces = { 'arrakeen:10': 5 }; + player.reserves = 15; + player.spice = 10; + } + g.players[0].elites!.forces = { 'arrakeen:10': 2 }; + g.players[0].elites!.reserves = 5; + g.battle = { + event: 'suboid-battle', + territory: 'arrakeen', + attacker: 'i', + defender: 'h', + prepared: true, + plans: {}, + revealed: false, + traitorCalls: {}, + }; + const v = viewGame(g, 'i'); + v.nexusCards = { + card: 'ixians', + deckCount: 11, + discardCount: 0, + held: { i: true, h: false, a: false }, + turn: null, + choices: [], + waiting: [], + }; + v.nexusSuboids = { + offer: { event: 'suboid-event', blocked: null }, + active: false, + }; + return v; +} +const html = (game: GameView, busy = false) => + renderToStaticMarkup(createElement(NexusSuboids, { game, busy, act() {} })); + +void test('Ixian Cunning binds an owned unsealed battle and authoritative commitment block', () => { + const v = fixture(); + assert.deepEqual(nexusSuboidsAction(v, 'suboid-event'), { + type: 'nexusSuboids', + event: 'suboid-event', + }); + assert.match(html(v), /rest of this turn/); + assert.match(html(v), /Cyborgs keep their usual strength and support rules/); + assert.doesNotMatch(html(v), /disabled=""/); + assert.match(html(v, true), /disabled=""/); + assert.equal(nexusSuboidsAction(v, 'stale'), null); + v.nexusSuboids!.offer!.blocked = + 'The existing plan commitment must remain feasible.'; + assert.equal(nexusSuboidsAction(v, 'suboid-event'), null); + assert.match(html(v), /existing plan commitment/); + v.nexusSuboids!.offer!.blocked = null; + v.battle!.submitted = ['i']; + assert.equal(nexusSuboidsAction(v, 'suboid-event'), null); + v.battle!.submitted = []; + v.phase = 5; + assert.equal(nexusSuboidsAction(v, 'suboid-event'), null); +}); + +void test('public active notice identifies Ixian Suboids and never reads an observer’s hidden cards', () => { + const v = fixture(); + v.nexusSuboids = { offer: null, active: true }; + v.me = 'h'; + v.nexusCards = null; + for (const player of v.players) + for (const key of ['hand', 'traitors', 'faceDancers']) + Object.defineProperty(player, key, { + get() { + throw new Error('Private card read'); + }, + }); + const text = html(v); + assert.match(text, /Ixian Suboids fight at full strength/); + assert.doesNotMatch(text, / { + const v = fixture(); + v.nexusSuboids = { offer: null, active: true }; + const forces = v.battle!.ownForces!; + forces.normalFixedHalf = false; + forces.normalFreeSupport = true; + const render = () => + renderToStaticMarkup( + createElement(GameTable, { + game: v, + send: async () => {}, + onExit() {}, + busy: false, + }), + ); + const text = render(); + const input = text.match(/]*id="battle-spice"[^>]*>/)?.[0]; + assert.ok(input); + assert.match(input, /max="2"/); + assert.match(text, /Spice support here applies only to Cyborgs/); + forces.elite = 0; + assert.match( + render().match(/]*id="battle-spice"[^>]*>/)![0], + /max="0"/, + ); +}); diff --git a/tests/nexus-suboid-reachability-controls.test.ts b/tests/nexus-suboid-reachability-controls.test.ts new file mode 100644 index 00000000..34a3f7c0 --- /dev/null +++ b/tests/nexus-suboid-reachability-controls.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame } from '../game/engine'; +import { BattlePromises } from '../components/battle-promises'; +import { suboidFixture } from './fixture-nexus-cunning'; +import { nexusAllow, nexusReload } from './fixture-nexus-cards'; + +void test('a real future-strength Prescience commitment names Ixian Cunning in its private reachable preparation', () => { + let g = suboidFixture(true, true); + g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); + g = nexusAllow(g); + g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 6 }); + while (g.battle!.preparation) + g = applyAction(g, g.battle!.preparation.owner, { + type: 'declineBattlePower', + }); + if (g.decision?.kind === 'fullPlanOffer') + g = applyAction(g, g.decision.player, { type: 'decision', decline: true }); + g = nexusReload(g); + const before = JSON.stringify(g); + const v = viewGame(g, 'p'); + const actions = v.battle!.compliantPreparation!.actions; + assert.equal(actions[0].type, 'nexusSuboids'); + assert.equal(g.nexusSuboidHistory, undefined); + assert.equal(g.nexusCards!.cards!.hands.p, 'ixians'); + const text = renderToStaticMarkup( + createElement(BattlePromises, { + game: v, + fill() {}, + act() {}, + busy: false, + }), + ); + assert.match( + text, + /Use Ixian Cunning to give Suboids full strength without spice support/, + ); + assert.match(text, /Complete next preparation/); + assert.doesNotMatch(text, /Ghola|NaN/); + assert.equal(JSON.stringify(g), before); + for (const id of ['q', 'r']) + assert.equal(viewGame(g, id).battle!.compliantPreparation, null); + const boosted = applyAction(g, 'p', actions[0]); + assert.equal(boosted.battle!.prescience!.value, 6); + assert.equal(viewGame(boosted, 'p').nexusSuboids!.active, true); + assert.equal(viewGame(boosted, 'p').battle!.compliantPreparation, null); +}); diff --git a/tests/nexus-suboid-reachability.test.ts b/tests/nexus-suboid-reachability.test.ts new file mode 100644 index 00000000..9b37f3bb --- /dev/null +++ b/tests/nexus-suboid-reachability.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { boostSuboids, suboidFixture } from './fixture-nexus-cunning'; +import { nexusAllow, nexusReload } from './fixture-nexus-cards'; + +function finishPreparation(state: Game): Game { + let g = state; + while (g.battle!.preparation) + g = applyAction(g, g.battle!.preparation.owner, { + type: 'declineBattlePower', + }); + if (g.decision?.kind === 'fullPlanOffer') + g = applyAction(g, g.decision.player, { type: 'decision', decline: true }); + return g; +} + +function submitPromisedDial(state: Game): Game { + let g = finishPreparation(boostSuboids(nexusReload(state))); + g = applyAction(g, 'p', { + type: 'battlePlan', + dial: 6, + support: 2, + leader: g.players.find((p) => p.id === 'p')!.leaders[0].id, + }); + assert.equal(g.battle!.plans.p.dial, 6); + assert.equal(g.nexusSuboidHistory!.length, 1); + return g; +} + +void test('Truthtrance can promise a dial reachable through the held Ixian Cunning after the answer', () => { + let g = suboidFixture(); + const index = g.deck.findIndex((card) => card.effect === 'truthtrance'); + assert.ok(index >= 0); + const card = g.deck.splice(index, 1)[0]; + g.players.find((p) => p.id === 'q')!.hand.push(card); + g = applyAction(g, 'q', { type: 'card', card: card.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, 'q', { + type: 'truthAsk', + question: { + kind: 'battlePlan', + target: 'p', + territory: 'pasty_mesa', + claim: { kind: 'dial', compare: 'gte', value: 6 }, + }, + }); + assert.equal(g.nexusCards!.cards!.hands.p, 'ixians'); + assert.equal(g.nexusSuboidHistory, undefined); + g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' }); + assert.equal( + g.nexusSuboidHistory, + undefined, + 'Reachability must not spend the real card', + ); + const done = submitPromisedDial(g); + assert.equal(done.battle!.truthPromises![0].answer, true); + assert.ok(!done.battle!.truthPromises![0].released); +}); + +void test('Prescience can bind a future full-strength Suboid dial without applying the grant during its search', () => { + let g = suboidFixture(true, true); + g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); + g = nexusAllow(g); + g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 6 }); + assert.equal(g.battle!.prescience!.value, 6); + assert.equal(g.nexusSuboidHistory, undefined); + assert.equal(viewGame(g, 'p').nexusSuboids!.active, false); + const done = submitPromisedDial(g); + assert.equal(done.battle!.prescience!.value, 6); +}); diff --git a/tests/nexus-suboids-engine.test.ts b/tests/nexus-suboids-engine.test.ts new file mode 100644 index 00000000..a30681a2 --- /dev/null +++ b/tests/nexus-suboids-engine.test.ts @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { casualtyOptions } from '../game/combat'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { suboidFixture, boostSuboids } from './fixture-nexus-cunning'; +import { nexusAllow, nexusReload, nexusReject } from './fixture-nexus-cards'; +function prepare(g: Game) { + while (g.battle!.preparation) g = applyAction(g, g.battle!.preparation.owner, { type: 'declineBattlePower' }); + if (g.decision?.kind === 'fullPlanOffer') g = applyAction(g, g.decision.player, { type: 'decision', decline: true }); + return g; +} +void test('Ixian Cunning supplies full free Suboid strength in Basic and Advanced without altering Cyborgs or custody', () => { + for (const advanced of [false, true]) { + let g = suboidFixture(advanced); + const before = structuredClone(g.players); + const ordinary = viewGame(g, 'p').battle!.ownForces!; + assert.equal(ordinary.normalFixedHalf, true); + g = boostSuboids(g); + const boosted = viewGame(g, 'p').battle!.ownForces!; + assert.equal(boosted.normalFixedHalf, false); + assert.equal(boosted.normalFreeSupport, true); + assert.equal(boosted.eliteStrength, ordinary.eliteStrength); + assert.equal(boosted.freeSupport, ordinary.freeSupport); + assert.deepEqual(g.players, before); + assert.ok(casualtyOptions(boosted, 3, 0).some(c => c.normal === 3 && c.elite === 0)); + if (advanced) { + assert.equal(casualtyOptions(boosted, 7, 0).length, 0); + assert.ok(casualtyOptions(boosted, 7, 2).length); + assert.equal(casualtyOptions(boosted, 7, 3).length, 0); + } + for (const p of g.players) assert.equal(viewGame(g, p.id).nexusSuboids!.active, true); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g)); + } +}); +void test('Ixian Cunning keeps disclosed half-dial commitments binding and rejects impossible activation without spending the card', () => { + let g = suboidFixture(true, true); + g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); + g = nexusAllow(g); + g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0.5 }); + const offer = viewGame(g, 'p').nexusSuboids!.offer!; + assert.match(offer.blocked!, /commitment/); + nexusReject(g, 'p', { type: 'nexusSuboids', event: offer.event }, /commitment/); + assert.equal(g.nexusCards!.cards!.hands.p, 'ixians'); +}); +void test('Cunning is allowed after compatible Prescience and before own plan even when the opposing plan is sealed', () => { + let g = suboidFixture(true, true); + g = applyAction(g, 'q', { type: 'prescience', field: 'dial' }); g = nexusAllow(g); + g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 1 }); + g = prepare(g); + g = applyAction(g, 'q', { type: 'battlePlan', dial: 0, leader: g.players[1].leaders[0].id }); + const sealed = structuredClone(g.battle!.plans.q); + g = boostSuboids(g); + assert.deepEqual(g.battle!.plans.q, sealed); + assert.equal(g.battle!.prescience!.value, 1); + g = applyAction(g, 'p', { type: 'battlePlan', dial: 1, support: 0, leader: g.players[0].leaders[0].id }); + assert.equal(g.battle!.revealed, true); +}); +void test('Suboid effect survives a second battle in the same turn and expires without discarding history next turn', () => { + let g = prepare(boostSuboids(suboidFixture())); + const first = g.battle!.event; + for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'battlePlan', dial: 0, leader: g.players.find(p => p.id === id)!.leaders[0].id }); + for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'traitorCall', call: false }); + for (let i = 0; g.battle && i < 25; i++) { + let acted = false; + for (const p of g.players) { + const view = viewGame(g, p.id); view.players.find(x => x.id === p.id)!.bot = 'Easy'; + const action = botActions(view)[0]; if (action) { g = applyAction(g, p.id, action); acted = true; break; } + } + assert.ok(acted, `Battle stalled at ${g.decision?.kind}/${g.response?.kind}`); + } + assert.equal(g.battle, null); + g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'hagga_basin', target: 'q' }); + assert.notEqual(g.battle!.event, first); + assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true); + // Advancing only the public turn tests the receipt's lifetime independently + // of this staged second battle; no result or previous casualty is recomputed. + g.turn++; + assert.equal(viewGame(nexusReload(g), 'p').nexusSuboids!.active, false); + assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFixedHalf, true); + assert.equal(g.nexusSuboidHistory!.length, 1); +}); +void test('stale, foreign, repeated and post-submission Cunning attempts reject unchanged', () => { + const initial = suboidFixture(), offer = viewGame(initial, 'p').nexusSuboids!.offer!; + nexusReject(initial, 'q', { type: 'nexusSuboids', event: offer.event }, /Cunning/); + nexusReject(initial, 'p', { type: 'nexusSuboids', event: 'old' }, /Cunning/); + nexusReject(initial, 'p', { type: 'nexusSuboids', event: offer.event, support: 0 }, /Cunning/); + const played = boostSuboids(initial); + nexusReject(played, 'p', { type: 'nexusSuboids', event: offer.event }, /Cunning/); + let sealed = prepare(initial); + sealed = applyAction(sealed, 'p', { type: 'battlePlan', dial: 0, leader: sealed.players[0].leaders[0].id }); + nexusReject(sealed, 'p', { type: 'nexusSuboids', event: offer.event }, /submitted/); +}); +void test('edited Suboid receipts reject reads, normalization and actions without changing the saved game', () => { + const original = boostSuboids(suboidFixture()); + for (const mutate of [ + (g: Game) => { delete g.nexusSuboidHistory; }, + (g: Game) => { delete g.nexusSuboidLast; }, + (g: Game) => { g.nexusSuboidHistory![0].turn++; }, + (g: Game) => { g.nexusSuboidHistory![0].owner = 'q'; }, + (g: Game) => { g.nexusSuboidHistory![0].battle = 'other'; }, + (g: Game) => { g.nexusSuboidHistory!.push(structuredClone(g.nexusSuboidHistory![0])); }, + ]) { + const bad = nexusReload(original); mutate(bad); const before = JSON.stringify(bad); + assert.throws(() => viewGame(bad, 'p')); assert.throws(() => normalizeAutomaticGame(bad)); + assert.throws(() => applyAction(bad, 'p', { type: 'advanceBots' })); assert.equal(JSON.stringify(bad), before); + } +}); +void test('all four bot profiles use the actual Cunning offer and submit legal boosted plans', () => { + for (const level of DIFFICULTIES) { + let g = prepare(suboidFixture()); + for (let i = 0; !g.battle!.plans.p && i < 8; i++) { + const v = viewGame(g, 'p'); v.players[0].bot = level; + const action = botActions(v)[0]; assert.ok(action); g = applyAction(g, 'p', action); + } + assert.equal(g.nexusSuboidHistory!.length, 1); + assert.ok(g.battle!.plans.p); + } +}); diff --git a/tests/nexus-tleilaxu-bots.test.ts b/tests/nexus-tleilaxu-bots.test.ts new file mode 100644 index 00000000..12061811 --- /dev/null +++ b/tests/nexus-tleilaxu-bots.test.ts @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nexusTleilaxuBotActions } from '../game/nexus-tleilaxu-options'; + +/** Synthetic authorized offer: this tests shared bot policy, not acquisition + * or source-effect execution, which belong to the engine suite. */ +function fixture(): GameView { + const g = createGame('DANCERBOTS', newPlayer('t', 'Tleilaxu', 'tleilaxu')); + g.players.push( + newPlayer('a', 'Atreides', 'atreides'), + newPlayer('h', 'Harkonnen', 'harkonnen'), + ); + Object.assign(g, { + status: 'playing', + phase: 6, + turn: 2, + active: 'a', + order: ['t', 'a', 'h'], + }); + const v = viewGame(g, 't'); + v.nexusCards = { + card: 'tleilaxu', + deckCount: 11, + discardCount: 0, + held: { t: true, a: false, h: false }, + turn: null, + choices: [], + waiting: [], + }; + v.nexusTleilaxu = { + cunning: { event: 'dancer-event', count: 1, blocked: null }, + }; + return v; +} + +void test('every bot profile takes the complete offered replacement off turn without a choice handshake', () => { + for (const difficulty of DIFFICULTIES) + for (const count of [1, 2, 3]) { + const v = fixture(); + v.players[0].bot = difficulty; + v.nexusTleilaxu!.cunning!.count = count; + v.decision = { kind: 'nullentropy', player: 'a' }; + const before = structuredClone(v); + assert.deepEqual(botActions(v), [ + { type: 'nexusFaceDancers', event: 'dancer-event' }, + ]); + assert.deepEqual(v, before); + } +}); + +void test('all bot profiles preserve Truth, closing draw, automatic and preceding exchange priorities', () => { + for (const difficulty of DIFFICULTIES) { + const v = fixture(); + v.players[0].bot = difficulty; + v.truthtrance = { + stage: 'priority', + queue: [], + passed: [], + question: null, + }; + assert.deepEqual(botActions(v), [{ type: 'truthPass' }]); + v.truthtrance = null; + v.automaticContinuationPending = true; + assert.deepEqual(botActions(v), []); + v.automaticContinuationPending = false; + v.nexusCards!.waiting = ['a']; + assert.deepEqual(botActions(v), []); + v.nexusCards!.waiting = []; + v.nexusTraitors = { + offer: null, + pending: { + event: 'exchange', + owner: 'h', + mode: 'cunning', + count: 1, + choices: [], + }, + }; + assert.deepEqual(botActions(v), []); + v.nexusTraitors = null; + v.nexusTleilaxu!.cunning!.blocked = 'Cunning is unavailable.'; + assert.deepEqual(nexusTleilaxuBotActions(v), []); + v.nexusTleilaxu = null; + assert.deepEqual(nexusTleilaxuBotActions(v), []); + } +}); + +void test('replacement policy never reads any private hand, traitor, dancer or spice field', () => { + const v = fixture(); + v.players[0].bot = 'Brutal'; + for (const player of v.players) + for (const field of ['hand', 'traitors', 'faceDancers', 'spice']) + Object.defineProperty(player, field, { + get() { + throw new Error(`Read private ${field}`); + }, + }); + assert.deepEqual(botActions(v), [ + { type: 'nexusFaceDancers', event: 'dancer-event' }, + ]); + v.me = 'a'; + assert.deepEqual(nexusTleilaxuBotActions(v), []); +}); diff --git a/tests/nexus-tleilaxu-controls.test.ts b/tests/nexus-tleilaxu-controls.test.ts new file mode 100644 index 00000000..e7e51af3 --- /dev/null +++ b/tests/nexus-tleilaxu-controls.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { NexusTleilaxu } from '../components/nexus-tleilaxu'; +import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options'; + +/** Explicit projected offers isolate presentation/consumer validation. These + * tests do not claim physical acquisition or replacement engine evidence. */ +function fixture(): GameView { + const g = createGame( + 'DANCERCONTROLS', + newPlayer('t', 'Tleilaxu', 'tleilaxu'), + ); + g.players.push( + newPlayer('a', 'Atreides', 'atreides'), + newPlayer('h', 'Harkonnen', 'harkonnen'), + ); + Object.assign(g, { + status: 'playing', + phase: 6, + turn: 2, + active: 'a', + order: ['t', 'a', 'h'], + }); + const v = viewGame(g, 't'); + v.nexusCards = { + card: 'tleilaxu', + deckCount: 11, + discardCount: 0, + held: { t: true, a: false, h: false }, + turn: null, + choices: [], + waiting: [], + }; + v.nexusTleilaxu = { + cunning: { event: 'dancer-event', count: 2, blocked: null }, + }; + return v; +} +function html(game: GameView, busy = false) { + return renderToStaticMarkup( + createElement(NexusTleilaxu, { game, busy, act() {} }), + ); +} + +void test('Cunning explains the ordered complete replacement and submits no selection or extra confirmation', () => { + const v = fixture(); + const before = structuredClone(v); + assert.deepEqual(nexusFaceDancersAction(v, 'dancer-event'), { + type: 'nexusFaceDancers', + event: 'dancer-event', + }); + const text = html(v); + assert.match(text, /secretly draw their replacements/); + assert.match(text, /Then shuffle the set-aside cards/); + assert.match(text, /unrevealed Face Dancers stay in your hand/); + assert.match(text, /Replace 2 revealed Face Dancers/); + assert.doesNotMatch(text, /type="checkbox"| { + const v = fixture(); + v.decision = { kind: 'nullentropy', player: 'a' }; + v.automaticContinuationPending = true; + assert.ok(nexusFaceDancersAction(v, 'dancer-event')); + assert.equal(nexusFaceDancersAction(v, 'stale'), null); + v.nexusTleilaxu!.cunning!.blocked = 'Finish the current Nexus return first.'; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + assert.match(html(v), /Finish the current Nexus return first/); + assert.match(html(v), /disabled=""/); + v.nexusTleilaxu!.cunning!.blocked = null; + v.nexusCards!.card = 'harkonnen'; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + v.nexusCards!.card = 'tleilaxu'; + v.me = 'a'; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + v.me = 't'; + v.players[0].ally = 'a'; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); +}); + +void test('Cunning controls honor Truth and exchange priority and never inspect hidden identities', () => { + const v = fixture(); + for (const p of v.players) + for (const key of ['traitors', 'faceDancers', 'hand']) + Object.defineProperty(p, key, { + get() { + throw new Error('Read a hidden identity'); + }, + }); + assert.ok(nexusFaceDancersAction(v, 'dancer-event')); + assert.match(html(v), /Replace 2/); + v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null }; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + v.truthtrance = null; + v.nexusCards!.waiting = ['a']; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + v.nexusCards!.waiting = []; + v.nexusTraitors = { + offer: null, + pending: { + event: 'exchange', + owner: 'h', + mode: 'cunning', + count: 1, + choices: [], + }, + }; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + v.nexusTraitors = null; + for (const count of [0, -1, 1.5, NaN]) { + v.nexusTleilaxu!.cunning!.count = count; + assert.equal(nexusFaceDancersAction(v, 'dancer-event'), null); + } + v.nexusTleilaxu = null; + assert.equal(html(v), ''); +}); + +void test('unavailable Tleilaxu modes are explained only from the viewer’s own held Nexus card', () => { + const v = fixture(); + v.nexusTleilaxu = { cunning: null }; + v.me = 'a'; + assert.match(html(v), /Betrayal is unavailable while its private response timing is pending/); + v.players = v.players.filter((player) => player.faction !== 'tleilaxu'); + assert.match(html(v), /Secret Ally revival is unavailable while its revival-limit ruling is pending/); + assert.doesNotMatch(html(v), /