From 0d9d0a72d63b29d93161857cee2d1a2cd6acd7e3 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:06:03 +1000 Subject: [PATCH] Integrate CHOAM Nexus Cunning Kull with source-aware costs --- README.md | 8 + components/choam-kull.tsx | 62 +- components/choam-worthless.tsx | 6 +- components/game-table.tsx | 10 +- components/nexus-cards.tsx | 2 +- docs/CHOAM_KULL_DESIGN.md | 28 +- docs/CHOAM_KULL_SOURCE_UPDATE.md | 21 +- docs/CURRENT_STATUS.md | 19 +- docs/IMPLEMENTATION_STATUS.md | 47 ++ docs/NEXUS_CHOAM_RULES.md | 39 +- docs/NEXUS_CHOAM_RUNTIME.md | 51 +- docs/RULE_DECISIONS.md | 2 +- game/bot-choam-kull.ts | 11 +- game/choam-kull.ts | 11 + game/engine.ts | 272 +++++-- game/nexus-choam.ts | 3 +- game/reference.ts | 48 +- tests/bot-choam-kull.test.ts | 8 +- tests/bot-nexus-choam-kull.test.ts | 114 +++ tests/choam-kull-controls.test.tsx | 10 +- tests/choam-kull-engine.test.ts | 10 +- tests/choam-kull-recovery.test.ts | 134 +++- tests/choam-kull.test.ts | 43 ++ tests/fixture-nexus-choam-kull.ts | 203 ++++++ tests/nexus-choam-kull-controls.test.tsx | 106 +++ tests/nexus-choam-kull-engine.test.ts | 469 ++++++++++++ tests/nexus-choam-kull-recovery.test.ts | 873 +++++++++++++++++++++++ tests/nexus-choam.test.ts | 46 +- tests/prototype-room.test.ts | 28 + tests/table-turn.test.ts | 2 +- tools/prototype-room.ts | 7 + tools/start-prototype.ts | 2 +- 32 files changed, 2502 insertions(+), 193 deletions(-) create mode 100644 tests/bot-nexus-choam-kull.test.ts create mode 100644 tests/fixture-nexus-choam-kull.ts create mode 100644 tests/nexus-choam-kull-controls.test.tsx create mode 100644 tests/nexus-choam-kull-engine.test.ts create mode 100644 tests/nexus-choam-kull-recovery.test.ts diff --git a/README.md b/README.md index 04c221a4..1a3f39b2 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,14 @@ counter; BG interception is before conversion. Unpayable overbid recovery remain guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md). This does not enable public expansion starts or certify full CHOAM. +For local **CHOAM Nexus Cunning Kull**, use `node --import tsx tools/start-prototype.ts --profile nexus-kull` +on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` Treachery +and Nexus alone. It omits physical Sandtrout. The same exact-version/private-backup +safeguards apply. Choose printed Kull or a canonical own Treachery fuel: +Nexus spends on accepted declaration, fuel only on success; distinct prevention +retains fuel while leaving Nexus spent. See [verified bounded runtime and limits](docs/NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile). +Older printed-only games do not gain Nexus choices, and public starts stay gated. + For local **Richese Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile richese-betrayal` on a fresh ready Basic/Advanced CHOAM/Richese-plus-classic lobby with the `choam` deck and Nexus only. The same exact-version/private-backup safeguards apply. diff --git a/components/choam-kull.tsx b/components/choam-kull.tsx index ec4ac469..5383d7c8 100644 --- a/components/choam-kull.tsx +++ b/components/choam-kull.tsx @@ -1,7 +1,10 @@ 'use client'; +import { useState } from 'react'; import type { Action, GameView } from '@/game/engine'; -import { CardInspector, CardRules } from './card-inspector'; +import { choamPowerKey } from '@/game/choam-power-options'; +import { ChoamPowerCost } from './choam-power-cost'; +import { NexusCardFace } from './nexus-cards'; import { Button } from './ui/button'; export function ChoamKull({ @@ -13,12 +16,18 @@ export function ChoamKull({ act: (action: Action) => void; busy: boolean; }) { + const [selected, select] = useState(''); const reaction = game.kullReaction; if (!reaction) return null; const reactor = game.players.find((player) => player.id === reaction.player); const target = game.players.find((player) => player.id === reaction.target); const ownsOffer = game.me === reaction.player && reaction.canDecline; const disabled = busy || game.status !== 'playing'; + // Foreign projections never authorize private cost controls or inspectors. + const plays = ownsOffer ? reaction.plays : []; + const play = plays.find((candidate) => choamPowerKey(candidate) === selected) ?? + plays.find((candidate) => !candidate.blocked) ?? plays[0]; + const canUse = !disabled && !reaction.blocked && !!play && !play.blocked; return (
@@ -32,9 +41,11 @@ export function ChoamKull({ ) : ( <>

- Decline to continue the original play, or use a listed physical Kull - Wahad card. Successful Kull retains the attempted card and prevents - that player’s Karama activations for this phase. A different eligible + Decline to continue the original play, or choose a listed physical + cost for Kull Wahad. Printed Kull uses its own Worthless Card; + CHOAM Nexus Cunning can use any eligible Treachery Card. + Successful Kull retains the attempted card and prevents that + player’s Karama activations for this phase. A different eligible Karama can prevent Kull before that restriction takes effect.

{reaction.blocked &&

{reaction.blocked}

} @@ -48,29 +59,26 @@ export function ChoamKull({ > Decline Kull · allow the attempted play - {!reaction.blocked && ( -
- {reaction.cards.map((card) => ( -
-

{card.name}

-

Physical cost: {card.id}

- - - -
- ))} + {!reaction.blocked && play && ( +
+ + {play.source === 'nexus' && game.nexusCards?.card === 'choam' && ( + + )} +

Physical Treachery cost: {play.card.id}

+
)} diff --git a/components/choam-worthless.tsx b/components/choam-worthless.tsx index 727d3c51..5efeab59 100644 --- a/components/choam-worthless.tsx +++ b/components/choam-worthless.tsx @@ -79,8 +79,10 @@ export function ChoamWorthless({ (play) => play.source === 'nexus' && play.effect === 'kull', ) && (

- Kull Wahad’s Karama prevention effect is not implemented. CHOAM - Cunning cannot use it yet. + Kull Wahad reacts only to another player’s Karama attempt.{' '} + {g.nexusKullPreview + ? 'Choose printed or Nexus fuel in that opportunity.' + : 'Nexus Kull is unavailable outside its explicit preview.'}

)} {reactive && ( diff --git a/components/game-table.tsx b/components/game-table.tsx index 227b8eec..1411b11b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1031,7 +1031,15 @@ export function GameTable({
{g.advanced && g.status !== 'lobby' && } - {g.kullPreview && ( + {g.nexusKullPreview ? ( +

+ CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible + Treachery Card can pay for the pending Karama interception; accepted + declarations spend the Nexus even if a distinct Karama prevents Kull. + This is not complete CHOAM or certified Nexus rules.{' '} + Preview rules and limits +

+ ) : g.kullPreview && (

Kull Wahad development preview · provisional interception timing, not complete CHOAM or certified expansion rules.{' '} diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index a68b3998..edfb722e 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -120,7 +120,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

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

-

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM, Moritani and bounded Ecaz Duke Cunning, plus Emperor Secret Ally’s bank-auction purchase and three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

+

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM, Moritani and bounded Ecaz Duke Cunning, plus Emperor Secret Ally’s bank-auction purchase and three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. {game.nexusKullPreview ? 'CHOAM Nexus Kull is reactive: choose its fuel at the Karama opportunity.' : 'CHOAM Nexus Kull is unavailable outside its explicit preview.'} Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

{game.nexusAtreides && offer.card === 'atreides' && (
diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index ef1f7848..d6d5a9f5 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -22,7 +22,9 @@ The wider publisher cancellation scope across other physical copies or Worthless The explicit `kull` development profile admits Basic or Advanced CHOAM with classic factions, the physical CHOAM and Ix Treachery decks, and **no optional modules**. It does not introduce Ixian/Tleilaxu factions merely because their physical deck is present. Basic uses ordinary printed Karama; Advanced additionally admits BG substitutions and the existing legal special powers of that roster. `initializeKullGameForAudit` opts the newly initialized Game into `kullPreview`; older saved games and ordinary public starts are not retrofitted. -The visible preview warning and closed expansion/module gates remain mandatory. Printed Kull's preview is not Nexus Cunning Kull, complete CHOAM, full expansion certification or deployed evidence. Checkpoint verification belongs to the integration owner, not this source/guidance update. +The separate `nexus-kull` profile admits fresh two-to-six-seat Basic or Advanced native CHOAM plus classic factions, exactly the physical CHOAM/Ix Treachery decks and **Nexus alone**, without physical Sandtrout. `initializeNexusKullGameForAudit` sets `nexusKullPreview` and shared `kullPreview`; it does not retrofit new choices into saved games. Unallied native CHOAM with the actual CHOAM Nexus may choose eligible own canonical Treachery fuel at the pending Karama opportunity. Other expansion factions/modules, Semuta and Richese Betrayal remain excluded; other Nexus previews retain the Kull guard. See the [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary) and [runtime evidence](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile). + +Both profiles require the visible preview warning and closed public expansion/module gates. The old `kull` profile remains printed-only without Nexus. This bounded Cunning interaction is verified locally, with broader verification **Partial**, not complete CHOAM, full Nexus/expansion certification or deployment evidence. Prior printed-Kull evidence remains scoped to the older preview. Ordinary cancellation with a live Truthtrance battle or shipment promise must pass the existing pure `assertKaramaPromiseFeasibility` post-cost proof. An unsupported/unprovable suffix is rejected actor-privately before a Kull offer or cost. Proven supported Voice, Prescience, elite, support and aid cancellation paths remain interruptible; no-obligation paths remain unaffected. This is an incomplete preview composition, **not** a publisher prohibition. Do not publicize an unvalidated attempt, release its promise or speculatively execute random effects merely to prove feasibility. @@ -59,27 +61,27 @@ Special branches bypassing `spendKarama()`: ## State and API responsibilities -The pure [Kull module](../game/choam-kull.ts) owns `KullPhaseRestriction`, `activeKullRestrictions`, `kullBlocksKarama`, restriction/attempt-stamp validation, `distinctKullCounter`, `kullNativeCostCards` and `kullCounterCards`. It validates stamped phase policy and conserved canonical physical-card binding without mutating a Game. The engine remains authoritative for live custody, validated ordinary uses, prepared `SpecialKaramaIntent`, response ownership and once-only continuation. A saved attempt must not contain an arbitrary client `Action`, function closure, cyclic state or whole-Game rollback snapshot. +The pure [Kull module](../game/choam-kull.ts) owns `KullPhaseRestriction`, `activeKullRestrictions`, `kullBlocksKarama`, restriction/attempt-stamp validation, `distinctKullCounter`, `kullNativeCostCards`, `kullNexusCostCards` and `kullCounterCards`. It validates stamped phase policy and conserved canonical physical-card binding without mutating a Game. `kullNexusCostCards(held, physicalCards, reservedIds)` returns uniquely held canonical any-Treachery fuel excluding reservations; it infers neither Nexus presence, profile, phase nor promises. The engine remains authoritative for live source-specific promises/custody, actual Nexus and mode authorization, validated ordinary uses, prepared `SpecialKaramaIntent`, response ownership and once-only continuation. A saved attempt must not contain an arbitrary client `Action`, function closure, cyclic state or whole-Game rollback snapshot. The original and counter cards have different obligations. The original stays reserved until Kull settles; ordinary counter eligibility excludes that exact physical ID. A different printed Karama or eligible BG substitution may counter while Kull is pending. A BG counter's own conversion response must not overwrite the original intent or its suspended response. -The consumer interface is `GameView.kullReaction`: `null` or `{ event, player, target, intent, cards, canDecline, blocked }`. It is present during the offer stage; the counter stage uses existing `responseControls`. `player` is the public CHOAM reactor, `target` the attempted activating player, and `intent` the ordinary purpose kind or the generic `special` label, never a special selection. All viewers see the same event/actors/intent; only CHOAM sees actual legal `cards` and `canDecline: true`. The current offer uses `blocked: null`, including no-card offers that CHOAM can decline. Countering reuses ordinary `card/mode: cancel` or `passResponse` actions. Never project raw special choices, saved response secrets, signatures, funding or either hidden hand. +The consumer interface is `GameView.kullReaction`: `null` or `{ event, player, target, intent, plays, canDecline, blocked }`. The old `cards` array is removed. Each private play is `{ source: 'printed' | 'nexus', effect: 'kull', card: Card, event: string, blocked: string | null }`, separating the real selected fuel from the effect and source. It is present during the offer stage; the counter stage uses existing `responseControls`. `player` is the public CHOAM reactor, `target` the attempted activating player, and `intent` the ordinary purpose kind or generic `special` label, never a special selection. All viewers see the same event/actors/intent; only CHOAM receives its plays and `canDecline: true`; others get empty plays. The offer remains declinable with no legal cost. Countering reuses ordinary `card/mode: cancel` or `passResponse`. Never project special choices, saved secrets, signatures, funding or hidden hands, and never derive public opportunity timing from a secret Nexus or cost. -`GameView.kullCounterEvent` is `null` or the same opaque public attempt event while the distinct-counter stage owns control, including a BG `worthlessKarama` overlay. Controls and bots use it only for precedence; legal physical choices still come from native `responseControls`. It reveals no original card, Kull cost or saved response payload. `GameView.karamaBlocked` is the current viewer's stamped activation-block reason or `null`, separate from a pending declaration; `kullPreview` supplies the visible opt-in warning. +`GameView.kullCounterEvent` is `null` or the same opaque public attempt event while the distinct-counter stage owns control, including a BG `worthlessKarama` overlay. Controls and bots use it only for precedence; legal physical choices still come from native `responseControls`. It reveals no original card, Kull cost or saved response payload. `GameView.karamaBlocked` is the current viewer's stamped activation-block reason or `null`, separate from a pending declaration; `kullPreview` supplies the shared interception warning and `nexusKullPreview` identifies the new source capability. -CHOAM submits `{ type: 'kullDecision', event, decline: true }` or `{ type: 'kullDecision', event, card }`. Bind event and actor before costs. The declared Kull is a real held printed card, not a manufactured card or a free power. Nexus Cunning is not admitted by this preview. +CHOAM submits `{ type: 'kullDecision', event, decline: true }` or `{ type: 'kullDecision', event, source: 'printed' | 'nexus', card }`. **Source is mandatory on Use**, including the old printed path; no omitted-source compatibility remains. Bind actor, event, source and physical cost before mutation. Printed source is a held printed Kull; Nexus source is real eligible Treachery fuel plus the actual held CHOAM Nexus under the explicit new profile. No client effect, target, price or Nexus ID is admitted. Generic proactive `choamPowerAction` cannot play Kull. ## Settlement flow and cancellation 1. Validate an actual activation before discard, payment, physical movement, once-use flags or random effects. Reject an already effective stamped phase restriction before costs. Invalid plays create no public offer and reveal no private payload. 2. Save the exact typed intent and its parent ownership. Offer the same neutral CHOAM opportunity whether Kull is held or absent. Mere holding, transfer or nonactivation discard is not an attempted play. -3. Declining resumes the original intent once, without another offer for that same attempt. Declaring Kull opens the ordinary CHOAM-effect response; **pending is not active**. +3. Declining spends nothing and resumes the original intent once, without another offer for that same attempt. Declaring Kull opens the ordinary CHOAM-effect response; **pending is not active**. Accepted Nexus source immediately spends its physical CHOAM Nexus once using the existing Cunning receipt/history, while fuel remains held until success. A prevented declaration does not refund that Nexus. 4. Resolve legal distinct counters using existing response ownership. The reserved original cannot cancel Kull. A valid counter follows its own ordinary consumption/conversion rules; it does not consume the original card as payment for the counter. -5. On successful Kull, discard the exact Kull cost once, retain the original unplayed printed/BG card, leave the original special once-use unspent, and abort its effect. Restrict the **activating player**, not an allied shipment recipient, for the current turn/phase. -6. If Kull is prevented, retain its cost and prevent that physical cost's effect for the phase under existing native CHOAM semantics. Resume the original attempt once. Recheck custody rather than substituting another same-name card. +5. On successful Kull, discard the exact selected printed or Nexus fuel once, retain the original unplayed printed/BG card, leave the original special once-use unspent, and abort its effect and additional costs. Restrict the **activating player**, not an allied shipment recipient, for the current turn/phase. A Nexus fuel's actual canonical identity is unchanged; no Kull card is minted and Karama fuel is not also activated. +6. If Kull is prevented, retain its selected fuel and prevent that physical cost's effect for the phase under existing native CHOAM semantics. An accepted Nexus declaration remains spent and its Cunning history completes canceled. Resume the original attempt once. Recheck exact custody rather than substituting another same-name card or restoring a whole Game. 7. Stamp comparison expires the effective restriction on phase/turn change, not a temporary response, market or worm substep. It blocks Karama activations including substitutes, not ownership, trades or otherwise legal nonactivation discards. -This is the approved development interpretation, not an official nested-priority clarification. JSON continuation must preserve the same ownership and exact physical cards without a second random draw, payment or replayed effect. +This is the approved development interpretation, not an official nested-priority clarification. Accepted-declaration Nexus disposal and retained pending fuel reuse the existing Cunning application convention, not a newly located publisher Kull transaction rule. JSON continuation must bind original attempt, chosen source/fuel and Cunning receipt/history through suspended `pendingKull.resume`/Worthless or `pendingKarama` parents. Deleted/orphaned source, original or receipt ownership must reject before view, normalization, actions or SQL writes, not auto-resume or expose a private cost. Completed history must not require spent cards to remain forever in discard. There is no second random draw, payment, Nexus spend or replayed effect. ## Bene Gesserit conversion @@ -105,10 +107,10 @@ Ordinary conversion behavior remains different: `spendKarama()` discards the BG ## Projection, UI, and AI - Public attempted-play metadata can include actor, purpose category, public recipient, and attempt ID. Do not expose raw continuation frames: they can contain private plans, hidden hand choices, revival terms, or future card transfers. -- The CHOAM opportunity must not depend on its hand containing `ix-kull-wahad`; otherwise the presence of a prompt leaks possession. Its available reaction card and exact pending CHOAM card ID belong only in its private projection. Existing public `g.decision` projection must be reviewed before placing private fields in a new decision variant. +- The CHOAM opportunity must not depend on holding `ix-kull-wahad`, CHOAM Nexus or eligible fuel; otherwise the prompt leaks possession. Its source-aware plays and exact selected fuel belong only in its private projection. Public viewers receive the same attempt metadata for CHOAM Nexus, an irrelevant Nexus, absent Nexus and no-cost hands. Saved private ownership must not enter public `g.decision` output. - Kull's settled target and stamped phase expiry are public. Selecting another player's shipment beneficiary must not change which player is restricted. Do not silently extend CHOAM's powers to an ally. - Bots must handle offer/decline, distinct counters, restored underlying decisions and blocked-activation filters. A card remaining physically in hand is not evidence the bot can play it. Prevent repeated invalid-action generation from proactive special and ordinary cancellation branches. -- Preserve `choamWorthless.pending` during pending Kull and nested responses, just as with Jubba; an AI should not cash in the card it is currently using as protection/prevention unless it deliberately chooses to abandon that effect. +- Preserve `choamWorthless.pending` during pending Kull and nested responses, just as with Jubba; it binds the actual selected printed/Nexus fuel so an AI does not accidentally cash in the card currently committed to prevention. The already spent Nexus is separate from that retained physical fuel. - Human controls describe the attempted purpose and distinguish pending Kull from a successful active restriction. The opt-in preview warning must remain visible; approved timing is product policy, not official completeness. Card inspection must not expose unplayed opponents' hands. ## Minimum validation plan @@ -118,11 +120,13 @@ Ordinary conversion behavior remains different: `spendKarama()` discards the BG 3. Successful block rejects a second activation in the same phase and permits one next phase; distinguish actor/beneficiary. Verify unsupported acquisition/holding restrictions were not added accidentally. 4. Payment with enough spice, alternate usable Karama, blocked held Karama, stale funding, hand-limit changes and losing card custody. Confirm the deferred winning-overbid guard rejects before costs with the same private result whether hidden Kull is held or absent; no auction remedy is certified. 5. Pending CHOAM cash-in, Fremen summon, Harkonnen exchange, revival-income preparation and Truthtrance feasibility; assert no response overwrite, false promise release, clone-search loop, duplicate random draw, or deadlock. -6. Compare two states differing only in CHOAM's private Kull possession: rival/public opportunity shape must match. Check private selected special payloads and suspended contexts never appear in rival or spectator views. +6. Compare printed-held/absent and CHOAM-Nexus/irrelevant-Nexus/absent-Nexus/no-fuel states: rival/public opportunity shape must match. Check private selected special payloads, source choices and suspended contexts never appear in rival or spectator views. 7. All four AI difficulties must complete each new continuation, including no-Kull/blocked-Kull decline and a response resumed after cancellation. Browser-check the new decision and its pending/canceled state after reconnect. The original 6 September pass was a source/engine and document audit, not runtime verification. Its unanswered counter/BG proposals are superseded by the explicit 30 September selections above. The minimum validation plan remains a coverage contract, not a claim that every combination is verified. +The new interaction has its own frozen evidence: types, lint, 6,022 offline tests, build, authenticated source-aware SQLite restart/races, four legal bot policies and actual Basic/Advanced CLI/runtime/phone weapon-fuel success and distinct prevention with refresh. Review repairs protect canonical descriptors, native suspended parents, reserved parent counters and CHOAM auction funding. Verified old printed counter saves migrate source binding without mutating callers or enabling Nexus; corrupt new-profile selections still reject. Local HTTP 49/55 retains four 503s and two timeouts; proxy-free CI and deployed acceptance remain separate. Historical printed-Kull and Richese results are not substituted for this proof. Full combinations and deferred auction remedies remain open. + ## Implemented prerequisite: prepared special intents diff --git a/docs/CHOAM_KULL_SOURCE_UPDATE.md b/docs/CHOAM_KULL_SOURCE_UPDATE.md index f88a291d..ccb4e436 100644 --- a/docs/CHOAM_KULL_SOURCE_UPDATE.md +++ b/docs/CHOAM_KULL_SOURCE_UPDATE.md @@ -1,6 +1,6 @@ # Kull Wahad: official-source update -Checked 2026-09-07 for classic GF9 Dune and CHOAM & Richese; preview policy updated 30 September 2026. This supplements [CHOAM_KULL_DESIGN.md](CHOAM_KULL_DESIGN.md) and the Kull section of [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md). The source audit itself did not change runtime behavior; the later opt-in development implementation follows the explicit selections below. +Checked 2026-09-07 for classic GF9 Dune and CHOAM & Richese; preview policy and source-aware Nexus cost boundary updated 30 September 2026. This supplements [CHOAM_KULL_DESIGN.md](CHOAM_KULL_DESIGN.md), [native CHOAM Cunning](NEXUS_CHOAM_RULES.md) and the Kull section of [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md). The source audit itself did not change runtime behavior; later opt-in development implementations follow the explicit selections below. This update does not claim a newly fetched publisher ruling. ## User-selected preview timing — 30 September 2026 @@ -19,6 +19,20 @@ These are explicit provisional product interpretations, not a publisher clarification. They supersede the older unanswered counter/BG proposals below; full CHOAM starts remain gated. +## Source-aware Cunning composition — 30 September 2026 + +The [CHOAM Nexus face](NEXUS_CHOAM_RULES.md#authority-and-printed-scope) says **“Discard any Treachery Card to obtain a Worthless Card special effect of your choice.”** This is the source for allowing any actual Treachery fuel, not for inventing a nested priority. Kull's [printed CHOAM rule](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7) supplies the attempted-Karama timing and phase restriction; [E3 p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) supplies native/unallied Nexus conditions and disposal of a played Nexus. None is presented as an official resolution of the counter/BG/overbid questions below. + +The older `kull` profile stays printed-only without optional modules or Nexus. Only a fresh explicit `nexus-kull` profile adds this bounded interaction: two to six ready Basic/Advanced seats, native CHOAM and otherwise classic factions, exactly physical CHOAM/Ix Treachery decks and Nexus alone. Older games and other Nexus previews are not retrofitted; other expansion factions/modules, Semuta, Richese Betrayal, complete games and public/deployed release remain outside scope. + +Every Use now names its source: `{ type: 'kullDecision', event, source: 'printed' | 'nexus', card }`. Decline is `{ type: 'kullDecision', event, decline: true }`. The private `kullReaction.plays` replaces `cards` and distinguishes real fuel from the effect. Printed source spends only a real held Kull on success. Nexus source requires actual unallied native CHOAM Nexus custody and uniquely held canonical Treachery fuel compatible with reservations and binding own promises. No omitted-source fallback or proactive generic Nexus Kull is admitted. + +The **application transaction convention** is the existing Cunning one: accepted declaration spends the physical Nexus once immediately, retains fuel while the response is pending, and discards fuel once only if the effect succeeds. Prevention retains fuel but does not refund Nexus. This is distinct from the source-supported requirement to discard a played Nexus and is not attributed to a publisher Kull priority ruling. + +The same **user-selected** distinct-counter, BG-before-conversion and deferred-overbid policies apply to both sources. The original stays reserved and cannot be its own counter; success retains its unplayed card/special once-use and stamps the activating player's turn/phase ban. Decline or prevention resumes the exact original once. The public offer is independent of Nexus identity and fuel eligibility; only CHOAM receives its private source choices. That neutral timing is application privacy policy, not publisher-prescribed acknowledgement. + +New Nexus Kull is **verified locally within its boundary**, with broader verification **Partial**. Frozen types/lint/6,022 offline tests/build, authenticated SQLite ownership/races and actual Basic/Advanced CLI/runtime/phone Use, distinct prevention and refresh are recorded in the [runtime checkpoint](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile). Prior printed-Kull, five-effect Cunning and Richese evidence remains scoped. Local HTTP is 49/55 with four POST 503s and two timeouts; proxy-free CI/live deployment, wider cancellation scope, combined modules, unpayable-auction recovery and full-family acceptance remain separate. + ## Result and provenance @@ -45,6 +59,7 @@ Targeted official-domain and named-designer searches also surfaced a BGG discuss | BG substitution and custody | Ordinary prevention of BG conversion discards the Worthless card. The [base rulebook, printed p. 14](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=14) discards a Karama after play. | **Before conversion** is selected: successful Kull retains the still-unplayed printed Karama or BG Worthless card and leaves an attempted special once-use unspent. If Kull is declined or countered, the original attempt resumes, including the separate ordinary BG conversion/discard rules. | | Blocked overbids | Holding permission and actual Karama play are distinct in the FAQ. | **Defer interaction** is selected for a winning overbid whose only Karama payment would become unplayable. Preflight the unsupported composition before costs or an unfinishable transaction. Do not restart the auction, award a free card, invent funding or add a hidden-Kull-dependent bid cap. | | Countered CHOAM discard | The expansion supplies a phase-scoped prevention provision. | Preserve native prevention-of-discard semantics: retain the selected Kull cost and phase-block its effect. The wider official scope across different physical copies or Worthless names remains unclarified; the preview does not resolve it. | +| Nexus source cost | CHOAM's Cunning allows any Treachery Card for a selected Worthless effect; E3 requires native unallied use and disposal of a played Nexus. | The new explicit profile uses real fuel plus the physical CHOAM Nexus, spent at accepted declaration even if prevented; fuel remains held until success. This transaction boundary reuses existing application convention, not an official nested Kull ruling. The printed-only profile is unchanged in scope. | The counter/BG choices are no longer unanswered user questions. They are approved preview policy with an unresolved **publisher-source** distinction. Unfunded-auction recovery was deliberately not selected. Full CHOAM, combined-module and publication acceptance therefore remain gated; an opt-in Kull preview is not official rules certification. @@ -54,7 +69,7 @@ An additional engineering admission boundary is the existing pure post-cost feas The runtime fence is before bid commitment, not only before payment: non-CHOAM unfunded Karama-dependent bids are privately unavailable in -this explicit preview. The former late guard accepted such a bid and +both explicit Kull profiles. The former late guard accepted such a bid and then could not settle it. The fence is independent of hidden Kull, does not change numeric UI bid limits or non-preview rules, and does not select an auction recovery. CHOAM self-activation stays @@ -63,4 +78,4 @@ Kull costs also receive a cloned post-cost own-promise check; counters are projected only when their same authoritative proof is available. -Validation: reviewed both prior documents, publisher text and the local rulebook extract; repeated focused official searches; verified the designer FAQ redirect. No gameplay tests were run for this documentation-only update. +Historical source validation reviewed publisher text, prior documents and a local rulebook extract, repeated focused official searches and checked the designer FAQ redirect. That documentation-only update ran no gameplay tests. The 1 October runtime integration adds the separately recorded frozen checks, actual CLI/source/phone evidence and independent review repairs; it claims no fresh publisher priority clarification or full-mode/deployed acceptance. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4c119bdc..7b315174 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,11 +1,28 @@ # Current development status -Updated 30 September 2026. **The full twelve-faction goal is unfinished. Basic +Updated 1 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +Five implementation lanes connect [CHOAM Nexus Cunning Kull](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile) +through the new fresh `nexus-kull` profile. Actual canonical Treachery fuel +and physical Nexus are distinct costs: Nexus spends at accepted declaration, +fuel remains held pending response, and only success disposes fuel and bans +the original actor for the phase. Distinct prevention retains fuel, leaves +Nexus spent and resumes the reserved original once; BG is still preconversion. +Review repaired counterfeit descriptor trust, native suspended-parent +ownership, nested reserved counters and CHOAM winning-payment coverage. +Verified old printed counter saves migrate their binding immutably without +enabling new choices or accepting source-omitting Use; new corrupt Nexus +selections remain rejected. The fresh Spice Deck excludes Sandtrout. +Types, lint, **6,022 offline tests**, build and actual Basic/Advanced +CLI/runtime/phone success/prevention/refresh pass with conserved physical +cards and unchanged spice. Local HTTP is **49/55**, four POST 503s plus two +timeouts, not green. Proxy-free CI/live deployment and full-mode acceptance +remain separate; no public release or deployed claim. + Five implementation lanes now connect the bounded [Richese Nexus Betrayal](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) preview: original public self-cache purchase veto without payment, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cb673c06..5e539016 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,52 @@ # Dune implementation status +## 1 October 2026 — source-aware CHOAM Nexus Cunning Kull + +Five implementation lanes connected runtime, canonical fuel/receipt policy, +real source/fuel controls and legal bots, authenticated SQLite recovery and +internal source guidance. The safe fresh `nexus-kull` CLI admits native CHOAM +plus classic 2..6-seat Basic/Advanced rosters with CHOAM/Ix Treachery and Nexus +alone; it supplies no physical Sandtrout or new public/retrofit gate. + +Use now requires source/event/card and private `plays` replaces old `cards`. +Nexus declaration spends actual CHOAM Nexus once; canonical retained fuel +is reserved until success. Distinct prevention keeps fuel but not Nexus and +resumes the original once. Success discards fuel, retains the original and +special once-use, and stamps the actor's activation ban. BG original +interception remains before conversion; decline spends neither source cost. + +Independent reviews found and repaired canonical self-authorization, +native Nexus response discoverability through Kull parents, reserved parent +fuel advertised as a nested counter, and loss of CHOAM winning-payment +coverage. A retained Karama's canceled Cunning effect is not ordinary payment +ineligibility. Verified old printed counter saves receive a bounded immutable +source-binding migration; malformed signatures and new Nexus missing-selection +frames still reject. There is no source-omitting action shim. Fresh setup's +Ix Treachery requirement no longer accidentally admits Sandtrout. + +Final `npm run check`: types, lint and **6,022 offline tests** pass. +`npm run build` passes. Engine/production authenticated SQLite cases include +native Atreides, Sardaukar, advisor and Guild parents, BG overlays, actual +auction payment, races, no-write corrupt/orphan rejection and later recycled +custody. The existing Fremen remote-Cunning pre-offer scope guard remains; +native special summon and its unsupported remote offer boundary are exercised. +Both final scoped independent re-reviews report no remaining finding. + +Four actual CLI entries continued owned ready QA rooms from version 5 to setup +6. Genuine setup/Storm/Spice completed before conserved pre-action resources +were arranged. Standalone runtime and 390-pixel controls prove Basic/Advanced +weapon fuel success and distinct prevention. Actual selectors and separate +fuel/Nexus inspectors pass; rivals see no private fuel inspector. Success +retains Karama/BG Baliset and bans activation; prevention retains fuel, spends +Nexus and resumes original shipment/conversion. Refresh/direct saved-state +proof retains full physical inventory, exact source history and unchanged spice. +No existing human games, seats or credentials were reset/modified. + +Current local built-worker HTTP is **49/55**: four POST 503s and two timeouts +retain the Wrangler-dev limitation, not green acceptance. Exact proxy-free +container CI/publication is recorded after the push; live NAS, wider modules, +full CHOAM/Nexus games and calibrated AI remain open. No public gate changes. + ## 30 September 2026 — bounded Richese Nexus Betrayal auction integration Five implementation lanes delivered pure source/invoice/custody policy, diff --git a/docs/NEXUS_CHOAM_RULES.md b/docs/NEXUS_CHOAM_RULES.md index 5aceb219..4fe9f6e8 100644 --- a/docs/NEXUS_CHOAM_RULES.md +++ b/docs/NEXUS_CHOAM_RULES.md @@ -1,10 +1,10 @@ # CHOAM Nexus Cunning -Source audit: 2026-09-10. This source audit defines native Cunning and its saved-receipt contract. The separate [runtime checkpoint](NEXUS_CHOAM_RUNTIME.md) implements five effects; this audit does not resolve the other CHOAM Nexus panels or lift any [Nexus/expansion release gate](NEXUS_CARD_RUNTIME.md). +Source audit: 2026-09-10; bounded Kull integration contract updated 30 September 2026. This source audit defines native Cunning and its saved-receipt contract. The separate [runtime checkpoint](NEXUS_CHOAM_RUNTIME.md) preserves the earlier five-effect evidence and records the new explicit `nexus-kull` profile separately. Neither scope resolves the other CHOAM Nexus panels or lifts any [Nexus/expansion release gate](NEXUS_CARD_RUNTIME.md). ## Authority and printed scope -The [original GF9 Nexus component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), independently inspected at `/tmp/dune-nexus-cards.jpg`, permits native CHOAM to discard any Treachery Card to use any Worthless Card special effect. The choice is one held physical card and one effect. It does not require holding the corresponding named Worthless card, transform the chosen card's printed identity, grant money, or supply a phase-long conversion permission. +The [original GF9 Nexus component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), independently inspected at `/tmp/dune-nexus-cards.jpg`, gives CHOAM Cunning this printed instruction: **“Discard any Treachery Card to obtain a Worthless Card special effect of your choice.”** The choice is one held physical card and one effect. It does not require holding the corresponding named Worthless card, transform the chosen card's printed identity, grant money, or supply a phase-long conversion permission. The [GF9 Ecaz & Moritani rulebook, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) classifies Cunning as enhancement of a native advantage. The holder must remain unallied, and a played Nexus Card is discarded. Those common conditions still apply. Publisher-indexed text and the previously fetched [publisher-authored E3 mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) were used; no community compilation supplies a ruling. @@ -12,7 +12,7 @@ CHOAM's Treachery advantage is on the ordinary faction-rules page, before its Ad ## Six effects and their timing -E2 lists **six**, not five. Five have current Cunning engine effect handlers. The separate opt-in [printed Kull preview](CHOAM_KULL_DESIGN.md) does not admit Nexus Cards and does not enable Cunning's sixth effect. +E2 lists **six**, not five. The earlier Cunning runtime implemented five. The separate opt-in [printed Kull profile](CHOAM_KULL_DESIGN.md#opt-in-development-scope) still does not admit Nexus Cards. Only the new explicit `nexus-kull` profile enables Cunning's Kull choice at the existing attempted-Karama opportunity; other Nexus previews retain their Kull guard. This is one bounded interaction, not a silent retrofit or a full-family release. | Chosen effect | Printed outcome and timing | | --- | --- | @@ -39,10 +39,33 @@ The effect choice and native use may be public without revealing the rest of CHO ## Existing boundaries to preserve -- **Kull:** the [source update](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026) records approved distinct-card counters and BG-before-conversion custody as product policy, not publisher clarification. Unpayable winning-overbid recovery is deliberately deferred. The printed-card Kull development profile excludes optional modules, so Cunning Kull remains unavailable. Five supported Cunning effects must not be presented as all six. +- **Kull:** the [source update](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026) records approved distinct-card counters and BG-before-conversion custody as user-selected product policy, not publisher clarification. The new `nexus-kull` profile composes those policies with physical any-Treachery fuel and CHOAM Nexus Cunning. It reserves the interrupted original, retains it and any special once-use on success, and resumes it once on decline or prevention. Unpayable winning-overbid recovery is deliberately deferred; the existing private precommitment fence remains independent of hidden ownership. The old `kull` profile still excludes Nexus, and other Nexus previews do not gain this choice. - **Phase cancellation scope:** the existing [Worthless continuation audit](CHOAM_WORTHLESS_CONTINUATION_AUDIT.md) records unresolved scope across other physical copies or effects. Current runtime binds a canceled physical card for the phase. A new Nexus use should preserve the selected policy rather than silently broaden or erase that historical restriction. The spent Nexus itself cannot be retried. - **Inherited effect boundaries:** fixed Ornithopter range versus Kulon, reactive movement/Revival/storm parents, special force custody, No-Field Gamont and other already documented exclusions remain properties of the selected effect. They are not reasons to reject an ordinary source-supported Cunning use with a different effect. -- **No fresh hidden reaction policy:** native CHOAM's existing effect opportunities and public circumstances supply the interaction points. Extend those owner's controls without pausing only because a secret Nexus card exists, adding compulsory no-choice confirmations, or reopening another player's completed action. Printed Kull uses a neutral attempted-play opportunity; adding a Cunning Kull cost still requires genuine Nexus integration and acceptance. +- **No fresh hidden reaction policy:** native CHOAM's existing effect opportunities and public circumstances supply the interaction points. Kull reuses the same neutral validated-attempt opportunity regardless of CHOAM's Nexus identity or eligible fuel. Do not pause only because a secret Nexus card exists, expose private cost lists to opponents, add compulsory no-choice confirmations, or reopen another player's completed action. This privacy protocol is application policy, not publisher-prescribed priority. + +## Kull source, cost and action boundary + +Fresh Basic or Advanced `nexus-kull` games require two to six ready seats with native CHOAM and otherwise classic factions, exactly the physical CHOAM and Ix Treachery decks, and Nexus alone. `initializeNexusKullGameForAudit` sets the explicit `nexusKullPreview` capability together with the shared `kullPreview` interception capability. It is not an in-play upgrade. No other expansion faction, Homeworld, Leader Skills, Discovery, Tech Token, Stronghold, Sandtrout, Ecaz variant, Semuta or Richese Betrayal composition is admitted by this profile. + +| Source | Required physical cost | When spent | +| --- | --- | --- | +| `printed` | One eligible held printed Kull Wahad | That card remains held while pending; discard once only on successful Kull. No Nexus is spent. | +| `nexus` | Unallied native CHOAM's actual singleton CHOAM Nexus **and** one eligible held canonical Treachery fuel card | The Nexus is spent once at accepted declaration, even if Kull is later prevented. Fuel remains held until successful settlement and is retained on prevention. | + +Any Treachery fuel identity is eligible in principle; live physical custody, reservations and own binding promises remain authoritative. This is not a free effect or a manufactured Worthless card. The pure `kullNexusCostCards(held, physicalCards, reservedIds)` helper classifies uniquely held canonical fuel and excludes reserved IDs; it does not authorize a profile, Nexus ownership, alliance, phase or promise. The engine proves those separately. A declaration must not reuse the original reserved attempt as a cost or counter. + +The API uses `kullReaction.plays`, not a bare `cards` list. Each private choice is `{ source: 'printed' | 'nexus', effect: 'kull', card, event, blocked }`. The selected `card` is the real fuel descriptor. Other viewers receive an empty plays list and the same public event/player/target/purpose. Client Use supplies only the canonical event, source and actual card ID; decline has no source or cost: + +```ts +{ type: 'kullDecision', event, decline: true } +{ type: 'kullDecision', event, source: 'printed', card } +{ type: 'kullDecision', event, source: 'nexus', card } +``` + +**Source is required on every Use, including printed Kull.** There is no omitted-source fallback and no client-selected effect, target, price or Nexus ID. The generic any-time Nexus action does not play Kull: the validated pending Karama opportunity is required. Reject stale/foreign events, malformed costs or broken saved ownership before costs or writes. + +The accepted-declaration Nexus spend and retained pending fuel reuse the existing native Cunning application convention. E3 establishes that a played Nexus is discarded; it does not prescribe this internal transaction boundary or a Kull nested-response priority. A distinct eligible counter acts before the stamped phase ban; the original remains reserved. Success discards fuel once, keeps the original printed/BG card unplayed and special once-use unspent, and restricts its activating player for the current turn and phase. Prevention retains fuel under the existing exact-cost phase policy, leaves Nexus spent and resumes the saved original once. Decline spends neither cost and resumes it once. BG interception is before conversion; the original ordinary conversion and its FAQ disposal rules resume only after decline or prevention. ## Pure saved receipt and integration contract @@ -65,12 +88,12 @@ type NexusChoamReceipt = { }; ``` -[game/nexus-choam.ts](../game/nexus-choam.ts) exports the six-name `CHOAM_NEXUS_EFFECTS` map, `NexusChoamEffect`, `NexusChoamContext`, `NexusChoamReceipt`, `createNexusChoam(context, owner, phase, card, effect)` and `validateNexusChoam(context, receipt)`. Context contains the current turn and ID/faction roster only. Intrinsic validation checks exact keys, effect/phase compatibility, original native owner/roster, nonempty card ID, safe numbers and signature. The event is `JSON.stringify(['nexusChoam', turn, phase, owner, card, effect])`. Kull's intrinsic timing admits phases 0–8; this does not authorize playing its unavailable Cunning effect. Historical validation neither reads private hands nor requires the card still held, CHOAM still unallied or the old Nexus still present. +[game/nexus-choam.ts](../game/nexus-choam.ts) exports the six-name `CHOAM_NEXUS_EFFECTS` map, `NexusChoamEffect`, `NexusChoamContext`, `NexusChoamReceipt`, `createNexusChoam(context, owner, phase, card, effect)` and `validateNexusChoam(context, receipt)`. Context contains the current turn and ID/faction roster only. Intrinsic validation checks exact keys, effect/phase compatibility, original native owner/roster, nonempty card ID, safe numbers and signature. The event is `JSON.stringify(['nexusChoam', turn, phase, owner, card, effect])`. Kull's intrinsic timing admits phases 0–8; only the engine's explicit profile and canonical pending attempt authorize its use. Historical validation neither reads private hands nor requires the card still held, CHOAM still unallied or the old Nexus still present. The engine should separately bind progress (`pending`, `canceled`, `discarded`, `complete`) and the exact original resume context. An independently retained latest-progress marker prevents deleting or rewinding the detailed frame from restoring the Nexus or causing another discard. Bind target, territory/sector, elite type, No-Field event and movement/revival/storm parent only where the selected effect uses them. Avoid freezing unrelated hidden hands, resource changes or nested response passes in a signature. Integrate through the existing native effect validator with an explicit selected effect, preserving the original selected Card. Do not construct a fake Worthless hand entry merely to satisfy name-based checks. On cancellation, validate and resume the existing denied suffix without applying or paying for the chosen effect. On allowed disposal, record the physical event before yielding to a fresh-discard child; the continuation must know whether the card and effect have already committed. Reject stale actor/event/effect/target/custody before costs or private output. -Four focused pure tests in [tests/nexus-choam.test.ts](../tests/nexus-choam.test.ts) pass: six-effect timing, later-turn/reordered-roster recovery with private getter traps, edited receipt rejection, and malformed/foreign creation. Type-aware lint and whitespace checks pass. These are receipt checks, not gameplay integration certification. +At the source-audit checkpoint, four focused pure tests in [tests/nexus-choam.test.ts](../tests/nexus-choam.test.ts) passed: six-effect timing, later-turn/reordered-roster recovery with private getter traps, edited receipt rejection, and malformed/foreign creation. Type-aware lint and whitespace checks passed then. These are preserved historical receipt checks, not new Kull gameplay integration certification. -Meaningful integration tests should cover all five currently supported effects in their genuine producers, Basic/Advanced parity, arbitrary canonical card families, successful and canceled selected-card custody, normal Worthless parity, denied-card phase behavior, poison-income and fresh-discard children, real BG/Box interruption, exact saved parent, private owner projection, bots, JSON/CAS recovery and failed-action immutability. Kull remains a named **Cunning integration** boundary, not an unanswered counter/BG product choice. Pure receipts alone do not establish gameplay acceptance or blanket Nexus immunity to Karama. +The historical five-effect matrix remains separate from the new source-aware Kull evidence. The frozen Kull checkpoint passes types, lint, 6,022 offline tests and build, plus actual Basic/Advanced CLI/runtime/phone Use and distinct prevention with refresh and conserved source costs. Authenticated SQLite cases cover saved source ownership, races, corrupt rejection and bounded old printed-counter migration; other Nexus profiles do not gain Kull. Wider verification remains **Partial**: local HTTP is 49/55 with four POST 503s and two timeouts, and full-mode/deployed acceptance stays open. See [current evidence and boundaries](NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile). Pure receipts alone do not establish publisher priority or blanket Nexus immunity to Karama. diff --git a/docs/NEXUS_CHOAM_RUNTIME.md b/docs/NEXUS_CHOAM_RUNTIME.md index d9dcf7bb..1a034e14 100644 --- a/docs/NEXUS_CHOAM_RUNTIME.md +++ b/docs/NEXUS_CHOAM_RUNTIME.md @@ -1,6 +1,6 @@ # CHOAM Nexus Cunning runtime -This checkpoint integrates native CHOAM Cunning with five Worthless effects, in Basic and Advanced. It does not complete CHOAM, Nexus or the expansions. The [source contract](NEXUS_CHOAM_RULES.md) lists **six** effects: Kull Wahad remains unavailable **as a Cunning choice**. The later opt-in [printed Kull preview](CHOAM_KULL_DESIGN.md) excludes Nexus Cards, so it does not expand this five-effect runtime. CHOAM Secret Ally and Betrayal have separate later contracts and are not implemented by this Cunning checkpoint. +The historical checkpoint below integrated native CHOAM Cunning with five Worthless effects in Basic and Advanced. The separately verified 1 October 2026 `nexus-kull` profile adds bounded source-aware Kull, with broader verification **Partial**. The [source contract](NEXUS_CHOAM_RULES.md) lists all six printed effects, not a full-family release. The older [printed Kull profile](CHOAM_KULL_DESIGN.md) still excludes Nexus; other Nexus previews still reject Kull. CHOAM Secret Ally and Betrayal retain separate contracts. No profile silently enables new choices in older games or certifies complete CHOAM, Nexus or expansions. ## Physical cost and timing @@ -20,24 +20,61 @@ Playing Cunning spends its physical Nexus card and opens one ordinary CHOAM-effe The engine's owner-only `choamWorthless.plays` separates `source`, `effect`, the actual `card`, an optional Nexus `event`, and a blocking reason. The original printed-card descriptor remains available. [Shared options](../game/choam-power-options.ts) construct ordinary card actions unchanged and add the explicit effect/event only for a Nexus use. They fence stale events, actor identity and current interruptions, and use the server's source-specific blocking reason. -The [cost selector](../components/choam-power-cost.tsx) is reused by the existing [storm](../components/choam-storm.tsx), [revival/range](../components/choam-worthless.tsx), [movement prevention](../components/choam-baliset.tsx) and [force return](../components/choam-gamont.tsx) panels. It names and inspects the real cost card separately from the effect, supports transport-busy state, and exposes blocked choices without offering an illegal action. Cunning Kull Wahad has a visible unavailable explanation; approved printed-Kull timing is not Cunning runtime authorization. Public reaction windows and controls disclose neither hidden Nexus ownership nor CHOAM's private cost list. +The [cost selector](../components/choam-power-cost.tsx) is reused by the existing [storm](../components/choam-storm.tsx), [revival/range](../components/choam-worthless.tsx), [movement prevention](../components/choam-baliset.tsx) and [force return](../components/choam-gamont.tsx) panels. It names and inspects the real cost card separately from the effect, supports transport-busy state, and exposes blocked choices without offering an illegal action. The new Kull panel uses this source-aware convention only in its explicitly enabled reaction profile; generic proactive Nexus Kull remains unavailable. Public reaction windows and controls disclose neither hidden Nexus ownership nor CHOAM's private cost list. All four bot profiles use the same projected plays. They preserve printed effects first, then prefer lower-value legal Nexus fuel, retaining their existing public-target policies. Easy's existing policy of allowing free revival remains unchanged. The ordinary Trip to Gamont preservation policy still reserves that printed card during a closing market or cash-in even though playing it must wait for the next legal window. These are legal policy extensions, not a new AI calibration claim. Existing reactive windows depend on public circumstances rather than whether CHOAM holds a matching card. Cunning uses those same windows, so holding a private Nexus card does not introduce an identifying public wait. Opponents receive no private cost list. Saved engine receipts, cancellation and physical disposal are handled by the engine; consumer code does not reconstruct an opportunity from card names or old saves. Later bounded Semuta integration does not establish every Cunning fresh-discard composition. Native Cunning remains subject to its ordinary Karama response; neither this checkpoint nor the separate printed-Kull preview grants blanket Nexus immunity. -## Recovery evidence +## Bounded Nexus Kull profile + +Create a fresh local `nexus-kull` room through genuine setup with two to six ready seats, native CHOAM plus classic factions, Basic or Advanced, exactly the CHOAM/Ix Treachery decks and Nexus alone. `initializeNexusKullGameForAudit` identifies this capability with `nexusKullPreview` and the shared `kullPreview`. No optional module beyond Nexus, other expansion faction, Semuta or Richese Betrayal is admitted. The `kull` profile remains printed-only; existing saves and other Nexus previews do not inherit the new choice. The visible warning and closed public expansion start gate remain. + +At the canonical validated Karama opportunity, CHOAM's private `kullReaction.plays` contains `{ source, effect: 'kull', card, event, blocked }` choices. The old `cards` field is removed. Printed source requires actual held Kull Wahad; Nexus source requires unallied native CHOAM's physical CHOAM Nexus and any eligible actual Treachery fuel. Canonical unique custody, reservation exclusion and source-specific own-promise feasibility apply before acceptance. A Karama fuel discard does not also activate Karama. Other viewers get empty plays and identical public event/actors/purpose regardless of secret Nexus identity or fuel; controls never infer an offer from the hand. + +Use submits `{ type: 'kullDecision', event, source: 'printed' | 'nexus', card }`, with **source required** even for printed Kull. Decline submits `{ type: 'kullDecision', event, decline: true }`. There is no old omitted-source fallback, extra client-selected effect/target/price/Nexus ID or proactive any-time Kull action. The event is the pending attempted-play event, not a guessed generic Cunning receipt. + +| Outcome | Physical Nexus | Selected fuel | Interrupted original | +| --- | --- | --- | --- | +| Decline | Retained | Retained | Resume exactly once with normal costs. | +| Accepted Nexus declaration, response pending | Spent once immediately | Held/reserved pending success | Held/reserved; a distinct eligible counter may act before the ban. | +| Kull prevented | Remains spent; no refund | Retained under existing exact-cost phase prevention | Resume exactly once; no Kull activation ban. | +| Kull succeeds | Remains spent | Discarded exactly once | Retained unplayed; effect/additional costs and special once-use unspent; activating player phase-blocked. | + +Printed declarations spend no Nexus and retain their existing printed-card settlement. The original attempted card cannot serve as its own counter. Advanced BG originals are intercepted before conversion/discard; decline or prevention resumes their separate ordinary conversion, while success keeps the original Worthless card. A distinct BG counter has its own conversion ownership. The successful ban is stamped to turn/phase and applies to activations, not holding, trades or nonactivation disposal. + +Distinct-counter priority, pre-conversion custody and deferred unpayable-overbid recovery are the user's selected policies. The neutral offer and Nexus-spent-at-accepted-declaration boundary are existing application conventions, not a located publisher response-order ruling. The private non-CHOAM unfunded Karama-dependent bid fence remains **before commitment**, independent of hidden Kull/Nexus ownership; no restart, free lot, funding remedy or numeric UI cap is introduced. Live original/fuel/counter promises must remain satisfiable; unsupported continuations are guarded before offering rather than releasing a promise. + +The saved pending Kull and existing Cunning receipt/history must bind chosen source/fuel, accepted Nexus spend and the exact original typed continuation, including suspended BG or Karama parents. Refresh must not replay raw actions, double-discard, refund Nexus or restore a mutable whole-Game snapshot. Stale, foreign, malformed, deleted or orphaned source/receipt/history/original ownership must fail before view/normalization/action/SQL writes. Completed historical receipts do not require old spent cards to remain forever in discard. + +### Five-part status for the new interaction + +| Area | Status | Bounded claim | +| --- | --- | --- | +| Implementation | Partial | Explicit source-aware interception and fresh-profile boundary; wider faction/module continuations and complete games remain outside scope. | +| Player controls | Partial | Actual private fuel selection and fuel/Nexus inspectors, source-required Use, distinct counter/allow and Basic/Advanced phone refresh pass; wider accessibility/presentation acceptance remains open. | +| AI | Partial | Four profiles use private canonical legal plays and existing counters; no full strategy/calibration claim. | +| Documentation | Partial | Source/effect, real fuel, spend timing, user policies, privacy protocol and old/new profile boundaries are recorded; publisher clarification/full-family acceptance remain open. | +| Verification | Partial | Types, lint, 6,022 offline tests and build pass, with actual fresh CLI entry, source runtime, Basic/Advanced phone success/prevention and saved physical-cost proof. Local HTTP is 49/55, not green; wider/deployed acceptance remains open. | + +The frozen suites cover non-Worthless/printed fuel, successful phase ban, distinct prevention/original resumption, BG preconversion, decline and identity-neutral offers, physical conservation, reservations/promises, four legal bots and authenticated SQLite restart/races/immutable malformed or orphan rejection. Independent review repaired canonical descriptor trust, suspended native Nexus parents, nested parent-cost counters and CHOAM winning-payment coverage, including retained ordinary Karama with a canceled Cunning-effect block. Fresh setup excludes physical Sandtrout. Verified old printed-only counter saves receive a bounded immutable selection-binding migration; new Nexus-profile missing selections still reject. No source-omitting action shim or new-choice retrofit is introduced. + +Actual `nexus-kull` CLI continued four owned fresh QA rooms from version 5 to setup 6. Genuine setup/Storm/Spice completed before conserved pre-action resources were arranged. Standalone runtime and 390-pixel controls prove Basic/Advanced weapon fuel Use followed by success or a distinct printed counter: Nexus spends once on declaration, success discards fuel/retains original/bans the actor; prevention retains fuel/leaves Nexus spent/resumes the original once. BG success keeps Baliset before conversion; prevention resumes its normal conversion. Refresh and direct saved-state checks preserve exact inventory, source history and unchanged spice. Fuel and CHOAM Nexus inspectors show their distinct actual components; rival views expose no private fuel inspector. + +Local built-worker HTTP passes 49/55: four POST 503s and two timeouts retain the known Wrangler-dev limitation. Proxy-free container CI and live deployment are recorded separately after the checkpoint push. Full optional-module/faction modes, calibrated AI, complete games and deployed acceptance remain separate gates. + +## Historical five-effect recovery evidence The saved receipt binds the physical card, selected effect, original phase and owner, declared target and the specific suspended movement, revival, storm or Mentat context. Independent review reproduced legal-looking replacements of a pending movement quantity, revival quantity/price and storm distance. Those edits now reject before reads, automatic normalization or either outcome can write state. Historical completed records do not freeze unrelated future resources. Actual paid Nullentropy searches can suspend and restore the response. If CHOAM legitimately consumes the selected card in an intervening cash-in, Cunning fizzles with its Nexus still spent and resumes the original operation once. Cancellation retains the card and applies the existing phase block. Production SQLite tests cover private refresh and simultaneous cancellation versus the final pass; exactly one version wins. -## Verification +## Historical five-effect verification -Five new consumer regressions are in [controls](../tests/nexus-choam-controls.test.ts) and [bots](../tests/nexus-choam-bots.test.ts). They exercise actual fixture-generated windows for all five supported effects, submitted source actions, the real cost-card inspector, blocked Kull and reserved cards, stale events, actor/interrupt fences, private-field traps, all four policies, and unchanged printed actions. The combined consumer, existing Worthless/storm/movement/Gamont, Nexus guidance and reference selection passed **150/150** tests. Type-aware lint passed for all owned code and tests; all nine local links in this document resolve. The log is `/tmp/dune-choam-consumer-tests.log`. +Five consumer regressions at that checkpoint were in [controls](../tests/nexus-choam-controls.test.ts) and [bots](../tests/nexus-choam-bots.test.ts). They exercised actual fixture-generated windows for all five supported effects, submitted source actions, the real cost-card inspector, Kull unavailable outside the new profile and reserved cards, stale events, actor/interrupt fences, private-field traps, all four policies, and unchanged printed actions. The combined consumer, existing Worthless/storm/movement/Gamont, Nexus guidance and reference selection passed **150/150** tests. Type-aware lint passed for owned code/tests at that checkpoint; its then-existing nine local links resolved. The log is `/tmp/dune-choam-consumer-tests.log`. This is preserved historical evidence, not a fresh check of the new Kull interaction. -Final integrated checks pass: **types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests**. Thirty new cases cover the pure receipt, engine, consumers, independent saved-parent review and production SQLite matrices. Logs are `/tmp/dune-choam-{check,build,http}.log`. +That checkpoint's integrated checks passed: **types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests**. Thirty new cases covered the pure receipt, engine, consumers, independent saved-parent review and production SQLite matrices. Logs are `/tmp/dune-choam-{check,build,http}.log`. These results are not rerun totals or proof for the later Nexus Kull profile. Two isolated three-seat browser rooms passed actual phone declarations, desktop/phone response inspection and all six private refreshes. `GDP8NNRE` version 4 allowed Baliset: the pending two-force movement was prevented, the three original source forces remained and the real Stunner payment was discarded. `WJTUH62T` version 5 canceled Jubba Cloak: Slip Tip stayed held, the Nexus stayed spent, and continuing the original storm removed four CHOAM and three opposing forces. No phone overflow or page errors were observed. `/tmp/dune-choam-restore.cjs` performs read-only private restoration checks. -The opening backup and final comparison preserve all **3,484** older room versions and state hashes. Only two browser rooms and thirty HTTP-test rooms were added, for 3,516 total. The existing server was reused; no hourly restart was due. Automation remains removed. These checks do not certify a complete six-effect CHOAM advantage or full module play. +The opening backup and final comparison at that checkpoint preserved all **3,484** older room versions and state hashes. Only two browser rooms and thirty HTTP-test rooms were added, for 3,516 total. The existing server was reused; no hourly restart was due. Automation remained removed. Those historical checks did not certify a complete six-effect CHOAM advantage or full module play, and the later new-profile contract does not expand their evidence. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 42c46ca8..6c4e34e2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -48,7 +48,7 @@ Updated 30 September 2026. This is the authoritative navigation index for existi | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | | Duke Vidal revival custody | The user selected set-aside custody after Ecaz revives the shared disc: revival clears the Tanks but does not itself grant battle control. Ecaz still needs a separate Ambassador or Nexus acquisition; only Ecaz may revive Duke, including by Ghola. This resolves one post-revival product interpretation, **not** the separate six-disc repeated-cycle, exceptional capture or battle-used Ghola questions. No publisher clarification assigning a post-revival controller was located. | [Publisher Ecaz/Moritani pp. 7–9](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=9), [revival readiness](ECAZ_DUKE_REVIVAL_READINESS.md) | | Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) | -| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. A declaration is pending; only successful settlement activates the stamped phase ban. Decline or prevention resumes the original once; success retains its unplayed card and special once-use. The explicit Basic/Advanced CHOAM-plus-classic preview excludes optional modules/Nexus, leaves older games unchanged and retains native exact-cost cancellation semantics. An unplayable sole-Karama winning overbid is privately guarded before costs; no auction remedy was approved. This is product policy, not publisher clarification; full CHOAM starts remain gated. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [runtime and remaining limits](CHOAM_KULL_DESIGN.md) | +| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) | | Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) | Shipment-only AND/OR now follows the FAQ's whole-question truth: Yes to AND diff --git a/game/bot-choam-kull.ts b/game/bot-choam-kull.ts index 4b088248..5a445d26 100644 --- a/game/bot-choam-kull.ts +++ b/game/bot-choam-kull.ts @@ -7,11 +7,14 @@ export function botChoamKullActions(g: GameView): Action[] { !offer.canDecline) return []; const own = g.players.find((player) => player.id === g.me); if (!(own?.bot ?? own?.autopilot)) return []; - const card = !offer.blocked && offer.target !== own.ally - ? offer.cards[0] + const play = !offer.blocked && offer.target !== own.ally + ? offer.plays.find((candidate) => !candidate.blocked) : undefined; - return card - ? [{ type: 'kullDecision', event: offer.event, card: card.id }] + return play + ? [{ + type: 'kullDecision', event: offer.event, + source: play.source, card: play.card.id, + }] : [{ type: 'kullDecision', event: offer.event, decline: true }]; } diff --git a/game/choam-kull.ts b/game/choam-kull.ts index 080d5b72..b44e58a7 100644 --- a/game/choam-kull.ts +++ b/game/choam-kull.ts @@ -152,6 +152,17 @@ export function kullNativeCostCards( !reservedIds.includes(card.id) && uniquelyHeld(card, held, physicalCards)); } +/** Any canonical held Treachery card can pay Nexus Kull. + * Nexus authority, timing and live promise eligibility belong to the caller. */ +export function kullNexusCostCards( + held: readonly Card[], + physicalCards: readonly Card[], + reservedIds: readonly string[] = [], +): Card[] { + return held.filter(card => card && !reservedIds.includes(card.id) && + uniquelyHeld(card, held, physicalCards)); +} + /** The caller supplies effective eligibility (BG, Shrine, phase bans and live reservations). * This helper never reads a Game or infers activatability from a printed card alone. */ export function kullCounterCards( diff --git a/game/engine.ts b/game/engine.ts index b46a8c40..69520e98 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -465,7 +465,7 @@ import { import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; -import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; +import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy'; @@ -1092,6 +1092,7 @@ export type Game = { } | null; /** Explicit development profile; never enabled by a public start or player action. */ kullPreview?: boolean; + nexusKullPreview?: boolean; kullSequence?: number; kullRestrictions?: KullPhaseRestriction[]; pendingKull?: { @@ -1103,6 +1104,7 @@ export type Game = { phase: number; form: 'printed' | 'substitution'; stage: 'offer' | 'counter'; + selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string }; intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent }; resume: NonNullable['resume']; worthless: Game['pendingChoamWorthless']; @@ -2639,21 +2641,17 @@ function nexusFremenCunningIntegrity(g: Game) { 'Fremen Cunning has lost its current rider choice.'); } if (stage === 'pending') { - const live = g.response; - const prior = suspended?.response; - const liveKarama = g.pendingKarama; - const priorKarama = suspended?.pendingKarama; - requireRule( - (live?.kind === 'nexusFremenCunning' && - live.owner === authorization.owner && live.intent === occurrence.event) || - (prior?.kind === 'nexusFremenCunning' && - prior.owner === authorization.owner && prior.intent === occurrence.event) || - (liveKarama?.use.kind === 'cancel' && - liveKarama.use.response.kind === 'nexusFremenCunning' && - liveKarama.use.response.intent === occurrence.event) || - (priorKarama?.use.kind === 'cancel' && - priorKarama.use.response.kind === 'nexusFremenCunning' && - priorKarama.use.response.intent === occurrence.event), + const controls = [g, suspended, g.pendingKull?.resume]; + requireRule(controls.some(control => { + const response = control?.response; + const karama = control?.pendingKarama; + return (response?.kind === 'nexusFremenCunning' && + response.owner === authorization.owner && response.intent === occurrence.event) || + (karama?.use.kind === 'cancel' && + karama.use.response.kind === 'nexusFremenCunning' && + karama.use.response.owner === authorization.owner && + karama.use.response.intent === occurrence.event); + }), 'Fremen Cunning has lost its pending cancellation window.'); } } @@ -3179,7 +3177,7 @@ function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) { function nexusAdvisorIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, - g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,g.bureaucratPayments?.pending?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,g.bureaucratPayments?.pending?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; @@ -3296,7 +3294,7 @@ function validateNexusSardaukarResponse(g: Game, response: ResponseWindow) { function nexusSardaukarIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, - g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; @@ -3464,7 +3462,7 @@ function nexusInspectionIntegrity(g: Game) { nexusRule(() => validateNexusInspection(battleInspectionContext(g), record)); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, - g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; @@ -8189,7 +8187,7 @@ export function initializeSemutaGameForAudit(state: Game): Game { } /** Opt-in printed Kull gameplay with the physical Ix deck, not a public release gate. */ export function initializeKullGameForAudit(state: Game): Game { - requireRule(!state.kullPreview && !state.pendingKull && !state.kullRestrictions && + requireRule(!state.kullPreview && !state.nexusKullPreview && !state.pendingKull && !state.kullRestrictions && state.expansions.length === 2 && state.expansions.includes('choam') && state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') && state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'), @@ -8198,6 +8196,28 @@ export function initializeKullGameForAudit(state: Game): Game { g.kullPreview = true; return g; } +/** Fresh native CHOAM Kull with the physical Ix deck and only Nexus Cards. */ +export function initializeNexusKullGameForAudit(state: Game): Game { + requireRule(!state.kullPreview && !state.nexusKullPreview && !state.pendingKull && + state.kullRestrictions === undefined && state.kullSequence === undefined && + !state.semutaPreview && !state.richeseBetrayalPreview && !state.richeseBetrayal && + !state.pendingRicheseBetrayal && !state.homeworlds && !state.leaderSkills && + !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && + !state.techTokens && !state.strongholdCards && !state.ecazTreachery && + state.nexusCards?.cards === null && state.nexusCards.phase === null && + typeof state.advanced === 'boolean' && state.expansions.length === 2 && + state.expansions.includes('choam') && state.expansions.includes('ix') && + state.players.some(p => p.faction === 'choam') && + state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'), + 'Nexus Kull requires a fresh native CHOAM/classic lobby, CHOAM and Ix decks, and only Nexus Cards.'); + requireFreshBaseRuntime(state); + const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); + g.kullPreview = true; + g.nexusKullPreview = true; + // Ix supplies Treachery cards here, not the optional Sandtrout Spice card. + g.spiceDeck = shuffle(spiceDeck()); + return g; +} /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker, @@ -10573,7 +10593,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null { if (g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) return 'Kull Wahad prevents your Karama activations for this phase.'; if (g.pendingKull?.stage === 'counter' && - (g.pendingKull.card === card.id || g.pendingChoamWorthless?.card === card.id)) + (g.pendingKull.card === card.id || g.pendingChoamWorthless?.card === card.id || + g.pendingKull.worthless?.card === card.id)) return 'This physical card is reserved for the interrupted Kull transaction.'; if (g.pendingIxRicheseTechnology?.card.id === card.id) return 'This card is reserved for the pending Richese lot and Ixian choice.'; @@ -11369,31 +11390,76 @@ function assertKaramaPromiseFeasibility( return true; } type KullFrame = NonNullable; -function kullCostPromisesAllow(g: Game, p: Player, card: Card): boolean { - const battlePromises = g.battle && !g.battle.revealed && !g.battle.plans[p.id] - ? (g.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released) - : []; - const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn); - if (!battlePromises.length && !shipmentPromises.length) return true; - const projected = structuredClone(g.pendingKull ? kullSource(g, g.pendingKull) : g); - const owner = getPlayer(projected, p.id); - owner.hand = owner.hand.filter(held => held.id !== card.id); - if (g.pendingKull) - projected.kullRestrictions = [ - ...activeKullRestrictions(projected.kullRestrictions, projected.turn, projected.phase), - { player: g.pendingKull.owner, turn: projected.turn, phase: projected.phase }, - ]; - projected.response = null; - projected.decision = null; - projected.pendingKarama = null; - return (!battlePromises.length || - !!findReachableBattlePlan(projected, owner, { promises: battlePromises })) && - (!shipmentPromises.length || !!findShipmentCompletion(projected, owner, shipmentPromises)); +// Independent printed descriptors, assembled once rather than trusting a saved +// hand to define its own identity or rebuilding the physical deck per quote. +const kullCanonicalCards: Readonly> = + Object.fromEntries(treacheryDeck(['choam', 'ix']).map(card => [card.id, card])); +function canonicalKullCard(card: Card): boolean { + const expected = Object.hasOwn(kullCanonicalCards, card.id) ? kullCanonicalCards[card.id] : undefined; + return !!expected && card.name === expected.name && card.kind === expected.kind && + card.effect === expected.effect; } -function kullCostCards(g: Game, p: Player): Card[] { - return kullNativeCostCards(cashInCards(g, p), physicalTreacheryCards(g)) - .filter(card => !choamWorthlessBlocked(g, card.id) && !giftReserved(g, p.id, card.id) && - !transferCardBlock(g, p, card) && kullCostPromisesAllow(g, p, card)); +function kullCostRetainsAuctionPayment(g: Game, p: Player, card: Card, physical: readonly Card[]): boolean { + return !g.auction || g.auction.bidder !== p.id || auctionSpicePaymentFunded(g, p) || + p.hand.some(held => held.id !== card.id && canonicalKullCard(held) && + physical.reduce((count, candidate) => count + Number(candidate.id === held.id), 0) === 1 && + !karamaSpendingBlock(g, p, held)); +} +function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'printed' | 'nexus'): boolean { + const source = g.pendingKull ? kullSource(g, g.pendingKull) : g; + const battlePromises = source.battle && !source.battle.revealed && !source.battle.plans[p.id] + ? (source.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released) + : []; + const shipmentPromises = liveShipmentPromises(source.shipmentPromises ?? [], p.id, source.turn); + if (!battlePromises.length && !shipmentPromises.length) return true; + for (const success of [true, false]) { + const projected = structuredClone(source); + const owner = getPlayer(projected, p.id); + // Acceptance spends Cunning in either outcome. Do not let feasibility + // reuse its physical Nexus as a future preparation or shipment resource. + if (fuelSource === 'nexus' && projected.nexusCards?.cards?.hands[p.id] === 'choam') + projected.nexusCards.cards = nexusRule(() => discardNexusCard(projected.nexusCards!.cards!, p.id, projected.players)); + if (success) { + owner.hand = owner.hand.filter(held => held.id !== card.id); + if (g.pendingKull) + projected.kullRestrictions = [ + ...activeKullRestrictions(projected.kullRestrictions, projected.turn, projected.phase), + { player: g.pendingKull.owner, turn: projected.turn, phase: projected.phase }, + ]; + } + projected.response = null; + projected.decision = null; + projected.pendingKarama = null; + if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) || + (shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises))) + return false; + } + return true; +} +function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] { + const original = g.pendingKull ? kullSource(g, g.pendingKull) : g; + if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally || + (g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam'))) + return []; + const reserved = [ + g.pendingKull?.card, g.pendingKull?.worthless?.card, + ].filter((id): id is string => typeof id === 'string'); + const physical = physicalTreacheryCards(g); + const candidates = source === 'nexus' + ? kullNexusCostCards(p.hand, physical, reserved) + : kullNativeCostCards(cashInCards(g, p), physical).filter(card => !reserved.includes(card.id)); + return candidates.filter(card => canonicalKullCard(card) && + kullCostRetainsAuctionPayment(original, p, card, physical) && !choamWorthlessBlocked(original, card.id) && + !giftReserved(original, p.id, card.id) && !transferCardBlock(original, p, card) && + !retentionReservesCard(original, p.id, card.id) && original.battle?.lateDefense?.[p.id] !== card.id && + ![original.battle?.plans[p.id]?.weapon, original.battle?.plans[p.id]?.defense, + original.battle?.plans[p.id]?.leader].includes(card.id) && + !(original.battle && committedPlanElements(original.battle, p.id).some(element => element.value === card.id)) && + kullCostPromisesAllow(g, p, card, source)); +} +function kullPlays(g: Game, p: Player) { + return (['printed', 'nexus'] as const).flatMap(source => kullCostCards(g, p, source).map(card => + ({ source, effect: 'kull' as const, card, event: g.pendingKull!.event, blocked: null as string | null }))); } function kullSource(g: Game, frame: KullFrame): Game { return { ...g, ...frame.resume, pendingKull: null, pendingChoamWorthless: frame.worthless }; @@ -11403,6 +11469,7 @@ function kullSignature(g: Game, frame: KullFrame): string { return JSON.stringify({ event: frame.event, player: frame.player, owner: frame.owner, card: frame.card, turn: frame.turn, phase: frame.phase, form: frame.form, intent: frame.intent, + selection: frame.selection, controls: nullentropyParentSignature(source, frame.resume, frame.event), status: source.status, advanced: source.advanced, active: source.active, order: source.order, ready: source.ready, hajr: source.hajr, choamMarket: source.choamMarket, @@ -11412,8 +11479,35 @@ function kullSignature(g: Game, frame: KullFrame): string { stormResolution: source.stormResolution, worthless: frame.worthless, }); } +/** Only the verified pre-source printed profile may bind its old held cost. */ +function migratePrintedKullCounter(state: Game): Game { + const frame = state.pendingKull; + if (!frame || frame.stage !== 'counter' || frame.selection !== undefined || + state.nexusKullPreview || state.nexusCards) return state; + const declaration = state.pendingChoamWorthless; + requireRule(state.kullPreview === true && declaration?.effect === 'kull' && + !declaration.nexusEvent && declaration.owner === frame.player && + frame.signature === kullSignature(state, frame), + 'The legacy printed Kull counter has lost its original declaration or signature.'); + const migrated = structuredClone(state); + migrated.pendingKull!.selection = { source: 'printed', card: declaration.card }; + migrated.pendingKull!.signature = kullSignature(migrated, migrated.pendingKull!); + // All original stamp, intent, custody, response and quote checks still apply. + kullIntegrity(migrated); + return migrated; +} function kullIntegrity(g: Game): void { try { + requireRule(g.nexusKullPreview === undefined || g.nexusKullPreview === true && + g.kullPreview === true && !!g.nexusCards?.cards && + g.expansions.length === 2 && g.expansions.includes('choam') && g.expansions.includes('ix') && + g.players.some(p => p.faction === 'choam') && + g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base') && + !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && + !g.discoveryStash && !g.greatMaker && !g.sandtrout && + !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview && + !g.richeseBetrayalPreview && !g.richeseBetrayal && !g.pendingRicheseBetrayal, + 'The Nexus Kull profile has lost its supported native inventory and modules.'); if (g.kullRestrictions !== undefined) validateKullPhaseRestrictions(g, g.kullRestrictions); const frame = g.pendingKull; if (!frame) { @@ -11434,6 +11528,12 @@ function kullIntegrity(g: Game): void { requireRule(frame.signature === kullSignature(g, frame), 'The saved Kull attempt no longer matches its original controls and opportunity.'); const source = kullSource(g, frame), owner = getPlayer(source, frame.owner); + if (frame.worthless) { + const parentOwner = getPlayer(source, frame.worthless.owner); + requireRule(kullNexusCostCards(parentOwner.hand, physicalTreacheryCards(g)) + .some(card => card.id === frame.worthless!.card && canonicalKullCard(card)), + 'The interrupted CHOAM declaration must retain its exact unique physical cost.'); + } const card = owner.hand.find(card => card.id === frame.card); requireRule(card, 'The original attempted Karama must remain in its owner’s hand.'); requireRule(frame.form === (card.kind === 'worthless' ? 'substitution' : 'printed'), @@ -11449,21 +11549,25 @@ function kullIntegrity(g: Game): void { requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use), 'The saved special Karama selection has changed.'); } - if (frame.stage === 'offer') + if (frame.stage === 'offer') { + requireRule(frame.selection === undefined, 'An undeclared Kull offer cannot reserve a selected source.'); requireRule(JSON.stringify(g.response) === JSON.stringify(frame.resume.response) && JSON.stringify(g.decision) === JSON.stringify(frame.resume.decision) && JSON.stringify(g.pendingKarama ?? null) === JSON.stringify(frame.resume.pendingKarama ?? null) && JSON.stringify(g.pendingChoamWorthless ?? null) === JSON.stringify(frame.worthless ?? null), 'The Kull offer no longer owns its original controls.'); - else { + } else { const declaration = g.pendingChoamWorthless; const response = g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : g.response; - requireRule(declaration?.effect === 'kull' && declaration.owner === frame.player && + requireRule(frame.selection && ['printed','nexus'].includes(frame.selection.source) && + declaration?.effect === 'kull' && declaration.owner === frame.player && + declaration.card === frame.selection.card && declaration.nexusEvent === frame.selection.nexusEvent && + (frame.selection.source === 'nexus' ? !!declaration.nexusEvent : !declaration.nexusEvent) && declaration.target === frame.owner && response?.kind === 'choamWorthless' && response.owner === frame.player && response.intent === 'Kull Wahad' && response.recipient === frame.owner && - kullCostCards(g, getPlayer(g, frame.player)).some(card => card.id === declaration.card), + kullCostCards(g, getPlayer(g, frame.player), frame.selection.source).some(card => card.id === declaration.card), 'The Kull counter no longer matches its held cost and original attempted player.'); currentChoamWorthlessCancellationQuote(g, response); } @@ -11522,25 +11626,33 @@ function resumeKullAttempt(g: Game, prevented: boolean): void { function decideKull(g: Game, p: Player, action: Action): void { const frame = g.pendingKull!; requireRule(action.type === 'kullDecision' && action.event === frame.event && - p.id === frame.player && Object.keys(action).every(key => ['type','event','decline','card'].includes(key)), + p.id === frame.player && Object.keys(action).every(key => ['type','event','decline','source','card'].includes(key)), 'Choose the current Kull opportunity as CHOAM.'); if (action.decline === true) { - requireRule(action.card === undefined, 'Decline or choose one Kull card, not both.'); + requireRule(action.card === undefined && action.source === undefined, 'Decline or choose one Kull source and card, not both.'); resumeKullAttempt(g, false); return; } - requireRule(action.decline === undefined && typeof action.card === 'string' && - kullCostCards(g, p).some(card => card.id === action.card), - 'Choose your exact eligible held Kull Wahad card.'); + requireRule(action.decline === undefined && (action.source === 'printed' || action.source === 'nexus') && + typeof action.card === 'string' && kullCostCards(g, p, action.source).some(card => card.id === action.card), + 'Choose your exact eligible held Kull source and card.'); + const source = action.source; + const card = p.hand.find(card => card.id === action.card)!; + const nexusEvent = source === 'nexus' + ? JSON.stringify(['nexusChoam', g.turn, g.phase, p.id, card.id, 'kull']) : undefined; + frame.selection = { source, card: card.id, ...(nexusEvent ? { nexusEvent } : {}) }; + frame.signature = kullSignature(g, frame); frame.stage = 'counter'; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; g.pendingChoamWorthless = { owner: p.id, card: action.card, effect: 'kull', target: frame.owner, revival: false, + ...(nexusEvent ? { nexusEvent } : {}), }; g.response = { kind: 'choamWorthless', owner: p.id, recipient: frame.owner, intent: 'Kull Wahad', passed: [] }; + if (source === 'nexus') recordNexusChoam(g, p, card, 'kull'); log(g, `${p.name} declared Kull Wahad. Only a different Karama can prevent it.`); } function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered = false) { @@ -23228,7 +23340,7 @@ function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'gui } function nexusGuildCunningIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; - const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume, + const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(c => { if (!c) return []; @@ -23690,21 +23802,30 @@ function nexusChoamParent(g: Game, pending: NonNullable { + const responseList = (contexts: typeof controls) => contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; - }).filter(response => response?.kind === 'choamWorthless'); + }).filter((response): response is ResponseWindow => response?.kind === 'choamWorthless'); + const transactions = [ + { state: g, pending: g.pendingChoamWorthless, responses: responseList(controls) }, + ...(g.pendingKull ? [{ state: kullSource(g, g.pendingKull), pending: g.pendingKull.worthless, + responses: responseList([g.pendingKull.resume]) }] : []), + ]; + const declared = transactions.filter(transaction => transaction.pending?.nexusEvent); const history = g.nexusChoamHistory; if (history === undefined) { - requireRule(!g.nexusChoamLast && !pending?.nexusEvent, 'CHOAM Cunning has lost its original saved history.'); + requireRule(!g.nexusChoamLast && !declared.length, 'CHOAM Cunning has lost its original saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, @@ -23723,19 +23844,22 @@ function nexusChoamIntegrity(g: Game) { const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'CHOAM Cunning has lost its latest completed or pending outcome.'); - requireRule((last.stage === 'pending') === !!pending?.nexusEvent, + requireRule((last.stage === 'pending') === (declared.length > 0) && + declared.every(transaction => transaction.pending?.nexusEvent === last.receipt.event), 'CHOAM Cunning has lost or reopened its declared effect.'); if (last.stage === 'pending') { - requireRule(pending && pending.nexusEvent === last.receipt.event && - pending.owner === last.receipt.owner && pending.card === last.receipt.card && pending.effect === last.receipt.effect && - g.turn === last.receipt.turn && g.phase === last.receipt.phase && - JSON.stringify(pending) === last.frame && nexusChoamParent(g,pending) === last.parent && responses.length > 0 && g.nexusCards.cards.discard.includes('choam'), + for (const { state, pending, responses } of declared) { + requireRule(pending && pending.owner === last.receipt.owner && pending.card === last.receipt.card && + pending.effect === last.receipt.effect && g.turn === last.receipt.turn && g.phase === last.receipt.phase && + JSON.stringify(pending) === last.frame && nexusChoamParent(state,pending) === last.parent && + responses.length > 0 && g.nexusCards.cards.discard.includes('choam'), 'CHOAM Cunning no longer matches its original card, selected effect or response.'); - for (const response of responses) { - try { quoteChoamWorthlessCancellation(g,response!); } - catch(error) { - if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); - throw error; + for (const response of responses) { + try { quoteChoamWorthlessCancellation(state,response); } + catch(error) { + if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); + throw error; + } } } } @@ -24693,6 +24817,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); @@ -24780,7 +24905,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;} if (g.pendingKull) { settleAutomaticContinuations(g); - if (g.pendingKull) { kullIntegrity(g); return g; } + if (g.pendingKull) { kullIntegrity(g); marketGholaIntegrity(g); return g; } } finishLeaderSkillCustody(g, state); observeOccupation(g); @@ -25014,6 +25139,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); @@ -28927,6 +29053,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + state = migratePrintedKullCounter(state); richeseBetrayalIntegrity(state); kullIntegrity(state); advancedPreviewIntegrity(state); @@ -29441,13 +29568,14 @@ export function viewGame(state: Game, id: string) { })() : null, kullPreview: !!g.kullPreview, + nexusKullPreview: !!g.nexusKullPreview, kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null, karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id) ? 'Kull Wahad prevents your Karama activations for this phase.' : null, kullReaction: g.pendingKull?.stage === 'offer' ? { event: g.pendingKull.event, player: g.pendingKull.player, target: g.pendingKull.owner, intent: g.pendingKull.intent.kind === 'ordinary' ? g.pendingKull.intent.use.kind : 'special', - cards: id === g.pendingKull.player ? kullCostCards(g, me) : [], + plays: id === g.pendingKull.player ? kullPlays(g, me) : [], canDecline: id === g.pendingKull.player, blocked: null as string | null } : null, choamWorthless: diff --git a/game/nexus-choam.ts b/game/nexus-choam.ts index 17a6b341..5bbc9898 100644 --- a/game/nexus-choam.ts +++ b/game/nexus-choam.ts @@ -65,8 +65,7 @@ function phaseMatches(effect: NexusChoamEffect, phase: number): boolean { return phase === 8; case 'jubba': return phase === 0; - // This describes printed timing only; the engine must separately authorize - // a real Karama attempt and retains Kull's unresolved implementation gate. + // Any phase can contain a Karama attempt; the caller authorizes that timing. case 'kull': return true; default: diff --git a/game/reference.ts b/game/reference.ts index fbc3b1e8..9dda771c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2428,7 +2428,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.', 'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.', ], - related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull'], + related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull', 'nexus-choam-kull'], }, { id: 'truthtrance-forces', @@ -2747,7 +2747,7 @@ export const RULE_TOPICS: RuleTopic[] = [ `advanced-${f.id}`, 'faction-sheets', ...(f.id === 'richese' ? ['richese-cards', 'richese-no-field', 'richese-gift', 'richese-acquisition'] : []), - ...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-kull', 'choam-combat'] : []), + ...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'choam-combat'] : []), ...(f.id === 'ecaz' ? ['ecaz-loyalty', 'ecaz-ambassadors', 'duke-vidal', 'implementation-checklist'] : []), @@ -3321,13 +3321,14 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.', 'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.', 'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.', - 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a separate opt-in development preview; see Kull Wahad for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. This is not six-effect Nexus Cunning: its Kull choice remains unavailable. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Normal expansion starts remain disabled.', + 'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a separate opt-in development preview; the explicit Nexus Kull profile adds Cunning fuel choices at that same reaction, not in every Nexus game. See both Kull guides for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Normal expansion starts remain disabled.', ], related: [ 'faction-choam', 'choam-gamont', 'choam-jubba', 'choam-kull', + 'nexus-choam-kull', 'choam-market', 'choam-revival', 'movement', @@ -3344,7 +3345,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.', steps: [ 'This opt-in development preview supports CHOAM with classic factions in Basic or Advanced, using the physical CHOAM and Ix Treachery decks without optional modules. Basic supports printed ordinary Karama; Advanced also supports Bene Gesserit substitutions and the legal special powers of this roster. Existing games are not retrofitted. Normal expansion starts remain closed, and the visible preview warning is not rules-completion approval.', - 'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.', + 'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.', 'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.', 'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. The activating player cannot activate printed or substituted Karama again during this turn’s current phase. An allied shipment recipient is not automatically the restricted player.', 'The successful restriction ends when the phase or turn changes, not during a temporary response, market or worm substep. It does not prohibit holding, acquiring, transferring or otherwise legally discarding a Karama without activating it.', @@ -3352,10 +3353,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Advanced Bene Gesserit is intercepted before Worthless conversion or discard: successful Kull keeps that original unplayed Worthless card held. If CHOAM declines or Kull is countered, the original proceeds to its separate conversion response. Ordinary cancellation of that conversion still leaves its Worthless card discarded. A different BG Worthless counter has its own conversion response; it is never the reserved original.', 'Winning overbids whose Karama payment would become unplayable remain deferred. To avoid accepting a bid that the preview cannot settle, a non-CHOAM unfunded Karama-dependent bid is refused privately before commitment, independently of CHOAM’s hidden Kull ownership. CHOAM self-activations are outside interception; normal non-preview bidding is unchanged. An already unfunded winning activation remains guarded. Holding Karama alone is not a reaction trigger. This unfinished-composition fence does not restart bidding, award the lot free, supply new funding or change numeric UI bid limits.', 'A live Truthtrance battle or shipment promise also constrains original attempts, Kull costs and counters. A cost that cannot be proved compatible is unavailable before disposal; CHOAM may decline instead. Proven supported cancellations remain available. Pending Kull cannot release a suspended promise, and its counter choices exclude unproven costs so legal AI can progress. Unsupported continuations remain an incomplete preview composition, not a publisher ban or permission to release the promise.', - 'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. Nexus Cunning Kull, other expansion rosters, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this preview. Nexus cards do not receive blanket immunity to Karama.', + 'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; other expansion rosters, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside both previews. Nexus cards do not receive blanket immunity to Karama.', ], example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held and BG cannot activate any Worthless-as-Karama again this phase. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.', - related: ['choam-worthless', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], + related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], checklist: [ { area: 'Implementation', status: 'Partial', detail: 'Opt-in printed Kull interception binds typed ordinary or prepared special intents, distinct physical counters and a stamped activation restriction. Deferred overbid recovery and combined-module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] }, @@ -3982,6 +3983,35 @@ export const RULE_TOPICS: RuleTopic[] = [ }, ], }, + { + id: 'nexus-choam-kull', + title: 'CHOAM Nexus Cunning: choose Kull Wahad', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Verified', + summary: 'In the explicit Nexus Kull preview, choose one actual Treachery fuel card at a Karama attempt; the CHOAM Nexus is spent when the declaration is accepted, even if Kull is prevented.', + steps: [ + 'This is the fresh local nexus-kull development profile, not a public expansion start or an upgrade to existing saves. It admits two to six ready seats with native CHOAM and otherwise classic factions in Basic or Advanced, physical CHOAM and Ix Treachery decks, and Nexus alone. Other optional modules, expansion factions, Semuta and Richese Betrayal stay outside its boundary. The older kull profile remains printed-only and has no Nexus cards.', + 'The CHOAM Nexus face says: Discard any Treachery Card to obtain a Worthless Card special effect of your choice. The CHOAM rulebook gives Kull its attempted-Karama timing and phase restriction. Cunning changes the fuel identity, not the effect, target or timing. Neither text supplies a nested counter priority: distinct counters and BG-before-conversion custody are the recorded user-selected policy, not a publisher clarification.', + 'Wait for another player’s validated Karama attempt. There is no proactive Kull choice in the generic Nexus panel. The public event, attempted player and purpose are the same whether CHOAM holds CHOAM Nexus, another Nexus, no eligible fuel or no Nexus at all. Only CHOAM receives its private source-aware plays and blocking reasons; opponents do not see the cost list or reserved original card.', + 'Choose a cost through the shared selector and inspect that actual card before Use. Printed source requires a held printed Kull Wahad. Nexus source requires unallied native CHOAM holding the actual CHOAM Nexus in this profile and one uniquely held canonical Treachery Card: a weapon, defense, special, Karama or Worthless card can be fuel. Reserved or promise-incompatible fuel is unavailable. The card does not become a manufactured Kull, and Karama used as fuel does not activate its own power.', + 'Use must identify the offered event, selected source (printed or nexus) and actual fuel card. Omitting the source, guessing another event or supplying someone else’s card is not a valid Use. Decline needs only the event and spends nothing; the exact original attempt resumes once. You do not choose a new target, price, effect or Nexus identity.', + 'An accepted Nexus declaration discards the physical CHOAM Nexus once immediately. The selected fuel stays held while the ordinary CHOAM-effect response is pending. Pending Kull is not yet a phase ban. The interrupted original stays reserved; it cannot counter its own interruption. A distinct eligible printed Karama or Advanced BG substitution uses the existing response controls before any restriction begins.', + 'If Kull succeeds, discard exactly the selected fuel once, retain the original unplayed Karama or BG Worthless card and leave the original special once-use and additional costs unspent. The activating player, not an allied shipment beneficiary, cannot activate printed or substituted Karama for the stamped current turn and phase. This does not ban holding, acquiring, trading or otherwise legally discarding Karama.', + 'If a distinct counter prevents Kull, the selected fuel remains held under the existing exact-cost phase prevention policy, but the accepted Nexus spend is not refunded. No Kull activation ban is established. Resume the original attempt once with its normal costs. A BG original resumes its separate ordinary conversion response; ordinary prevention of that conversion still leaves its Worthless card discarded. A BG counter likewise owns its separate conversion, never the reserved original.', + 'The selected deferred-overbid policy still privately refuses non-CHOAM unfunded Karama-dependent bids before commitment, independently of hidden Kull or Nexus ownership. No auction restart, free award, invented funding or numeric UI cap is added. Own Truthtrance promises and reservations constrain the original, fuel and counters; unsupported continuations are guarded before an offer rather than silently releasing a promise.', + 'Refresh restores the same event, selected source, physical costs, spent Nexus and original continuation, including a suspended BG response. Corrupt ownership rejects without changing the game. Verified older printed-only counter saves receive a bounded immutable binding migration, not Nexus choices or a source-omitting action shim. Frozen checks and actual Basic/Advanced CLI/runtime/phone success and prevention are verified locally; complete CHOAM/Nexus, wider combinations and deployed acceptance remain open.', + ], + example: 'CHOAM selects a held weapon as Nexus fuel when BG attempts a Worthless-as-Karama activation. Accepted Use spends CHOAM Nexus but keeps the weapon pending. If everyone allows Kull, the weapon is discarded and BG keeps its original Worthless card unplayed. If a different Karama prevents Kull, CHOAM keeps the weapon but not the Nexus, and BG’s original conversion continues once.', + related: ['choam-kull', 'choam-worthless', 'nexus-cards', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'privacy', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'The explicit new profile adds source-aware Kull fuel to the existing typed interception, with accepted-declaration Nexus spend and saved original continuation. It does not retrofit old games or admit wider module/faction compositions.', evidence: ['game/choam-kull.ts', 'game/nexus-choam.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Actual private weapon-fuel selection, fuel and Nexus inspectors, required source Use, distinct counter/allow and refreshed Basic/Advanced phone paths pass. Wider controls/presentation acceptance remains open.', evidence: ['components/choam-kull.tsx', 'components/choam-power-cost.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four profiles complete source-aware legal choices, decline and distinct counter continuations from canonical own projections. These are bounded legal policies, not calibrated Nexus strategy.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'This guide separates the printed effect and Cunning fuel from user-selected priority/custody and application privacy/spend conventions. New-profile limits and old printed-only behavior are explicit; publisher clarification and full-family acceptance remain open.', evidence: ['docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Types/lint/6,022 offline tests/build, authenticated SQLite source ownership/restart/races and actual Basic/Advanced CLI/runtime/phone success/prevention/refresh pass. Review repairs protect canonical fuel, suspended native parents, reserved counters, payment coverage and old printed saves. Fresh profile omits Sandtrout. Local HTTP 49/55 retains four 503s and two timeouts; full combinations and deployed acceptance remain open.', evidence: ['tests/nexus-choam-kull-engine.test.ts', 'tests/nexus-choam-kull-recovery.test.ts', 'tests/nexus-choam-kull-controls.test.tsx', 'tests/bot-nexus-choam-kull.test.ts', 'tests/choam-kull-recovery.test.ts', 'docs/NEXUS_CHOAM_RUNTIME.md'] }, + ], + }, { id: 'nexus-choam-trade', title: 'CHOAM Nexus: Collection trade or victorious inspection', @@ -4119,7 +4149,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.', 'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.', 'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.', - 'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Five of the six printed effects are supported: Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont. Cunning Kull remains unavailable; the separate printed Kull development preview excludes optional modules and does not authorize that sixth Nexus choice.', + 'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont retain their existing runtime. The explicit new Nexus Kull profile adds Kull at the canonical Karama reaction only; it remains unavailable in other Nexus previews. The old printed Kull profile still excludes Nexus. See the source-aware Kull guide for cost custody, counters and pending verification.', 'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.', 'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.', 'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.', @@ -4130,13 +4160,13 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.', - 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide; its verification remains Planned.', + 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.', 'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.', ], - related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-richese-betrayal'], + related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal'], checklist: [ { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, diff --git a/tests/bot-choam-kull.test.ts b/tests/bot-choam-kull.test.ts index dc5e442a..61c5004e 100644 --- a/tests/bot-choam-kull.test.ts +++ b/tests/bot-choam-kull.test.ts @@ -24,7 +24,7 @@ void test('every profile uses the projected physical Kull and cannot act from a } } const actions = botActions(own); - assert.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, card: 'ix-kull-wahad' }]); + assert.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, source: 'printed', card: 'ix-kull-wahad' }]); assert.deepEqual(botActions(viewGame(saved, 'h')), []); let settled = applyAction(saved, 'c', actions[0]); // A non-target declines the native response with its own legal card held. @@ -74,7 +74,7 @@ void test('every profile counters Kull only with a distinct projected card and r game.players[1].hand.push(counter); const offer = applyAction(game, 'e', kullShipmentAttempt(game)); const declared = applyAction(offer, 'c', { - type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad', + type: 'kullDecision', event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad', }); const own = viewGame(JSON.parse(JSON.stringify(declared)), 'e'); assert.ok(!own.responseControls!.cancelCards.includes(reserved)); @@ -97,7 +97,7 @@ void test('a blocked projected cost is declined rather than treated as a playabl const offer = applyAction(game, 'e', kullShipmentAttempt(game)); const own = viewGame(offer, 'c'); own.kullReaction!.blocked = 'The cost is reserved for another continuation.'; - Object.defineProperty(own.kullReaction, 'cards', { + Object.defineProperty(own.kullReaction, 'plays', { get() { throw new Error('Read ineligible cost choices'); }, }); const action = botActions(own)[0]; @@ -118,7 +118,7 @@ void test('BG counter conversion keeps all profiles in standard response control const counter = game.players[2].hand[0].id; const offer = applyAction(game, 'e', kullShipmentAttempt(game)); const declared = applyAction(offer, 'c', { - type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad', + type: 'kullDecision', event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad', }); const action = botActions(viewGame(declared, 'b'))[0]; assert.deepEqual(action, { type: 'card', mode: 'cancel', card: counter }); diff --git a/tests/bot-nexus-choam-kull.test.ts b/tests/bot-nexus-choam-kull.test.ts new file mode 100644 index 00000000..7f049359 --- /dev/null +++ b/tests/bot-nexus-choam-kull.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import type { Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { choamPowerAction } from '../game/choam-power-options'; +import { createNexusChoamKullFixture } from './fixture-nexus-choam-kull'; + +void test('all four profiles settle saved Basic and Advanced Nexus Kull with only their own projected physical fuel', () => { + for (const profile of DIFFICULTIES) { + for (const advanced of [false, true]) { + for (const fuel of ['karama', 'worthless', 'weapon'] as const) { + const fixture = createNexusChoamKullFixture({ advanced, fuel, counter: false }); + const saved: Game = JSON.parse(JSON.stringify(fixture.game)); + saved.players.find(player => player.id === fixture.choam)!.bot = profile; + saved.players.find(player => player.id === fixture.other)!.bot = profile; + const own = viewGame(saved, fixture.choam); + for (const player of own.players) { + if (player.id === own.me) continue; + for (const key of ['hand', 'traitors', 'spice']) { + Object.defineProperty(player, key, { get() { throw new Error(`Read rival ${key}`); } }); + } + } + const actions = botActions(own); + assert.equal(actions.length, 1); + assert.deepEqual(botActions(viewGame(saved, fixture.other!)), []); + const settled = applyAction(saved, fixture.choam, actions[0]); + assert.equal(settled.pendingKull, null); + assert.equal(settled.karamaShipping, null); + assert.equal(settled.discard.filter(card => card.id === fixture.fuel).length, 1); + assert.ok(settled.players.find(player => player.id === fixture.actor)!.hand.some(card => card.id === fixture.original)); + assert.ok(viewGame(settled, fixture.actor).karamaBlocked); + assert.equal(settled.nexusCards!.cards!.hands[fixture.choam], null); + assert.equal(settled.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1); + assert.equal(settled.nexusChoamHistory!.at(-1)!.stage, 'complete'); + } + } + } +}); + +void test('all profiles decline unavailable Nexus costs rather than falling through to proactive powers or consuming fuel', () => { + for (const profile of DIFFICULTIES) { + const fixture = createNexusChoamKullFixture({ nexusFace: 'richese', counter: false }); + fixture.game.players.find(player => player.id === fixture.choam)!.bot = profile; + const actions = botActions(viewGame(fixture.game, fixture.choam)); + assert.equal(actions.length, 1); + const resumed = applyAction(fixture.game, fixture.choam, actions[0]); + assert.equal(resumed.pendingKull, null); + assert.equal(resumed.karamaShipping?.owner, fixture.actor); + assert.equal(resumed.discard.filter(card => card.id === fixture.original).length, 1); + assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel)); + assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], 'richese'); + assert.equal(resumed.nexusChoamHistory, undefined); + } +}); + +void test('every profile can prevent Nexus Kull for an ally using a distinct legal counter, retaining fuel but not refunding Nexus', () => { + for (const profile of DIFFICULTIES) { + const fixture = createNexusChoamKullFixture({ fuel: 'weapon' }); + const other = fixture.game.players.find(player => player.id === fixture.other)!; + const actor = fixture.game.players.find(player => player.id === fixture.actor)!; + other.bot = profile; + other.ally = actor.id; + actor.ally = other.id; + const declared = applyAction(fixture.game, fixture.choam, { + type: 'kullDecision', event: fixture.event, source: 'nexus', card: fixture.fuel, + }); + const actions = botActions(viewGame(JSON.parse(JSON.stringify(declared)), other.id)); + assert.equal(actions.length, 1); + const resumed = applyAction(declared, other.id, actions[0]); + assert.equal(resumed.pendingKull, null); + assert.equal(resumed.karamaShipping?.owner, actor.id); + assert.equal(resumed.discard.filter(card => card.id === fixture.counter).length, 1); + assert.equal(resumed.discard.filter(card => card.id === fixture.original).length, 1); + assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel)); + assert.equal(viewGame(resumed, actor.id).karamaBlocked, null); + assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], null); + assert.equal(resumed.nexusChoamHistory!.at(-1)!.stage, 'canceled'); + } +}); + +void test('all profiles leave the interrupted BG Worthless card unconverted when Nexus Kull succeeds', () => { + for (const profile of DIFFICULTIES) { + const fixture = createNexusChoamKullFixture({ advanced: true, bg: true, fuel: 'weapon', counter: false }); + fixture.game.players.find(player => player.id === fixture.choam)!.autopilot = profile; + const action = botActions(viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.choam))[0]; + assert.ok(action); + const settled = applyAction(fixture.game, fixture.choam, action); + assert.equal(settled.pendingKull, null); + const original = settled.players.find(player => player.id === fixture.actor)!.hand.find(card => card.id === fixture.original); + assert.equal(original?.kind, 'worthless'); + assert.ok(!settled.discard.some(card => card.id === fixture.original)); + assert.ok(viewGame(settled, fixture.actor).karamaBlocked); + } +}); + +void test('a blocked projected Nexus cost is declined and cannot be submitted through the generic proactive CHOAM path', () => { + for (const profile of DIFFICULTIES) { + const fixture = createNexusChoamKullFixture({ fuel: 'weapon', counter: false }); + fixture.game.players.find(player => player.id === fixture.choam)!.bot = profile; + const own = viewGame(fixture.game, fixture.choam); + const play = own.kullReaction!.plays[0]; + assert.equal(choamPowerAction(own, play), null); + play.blocked = 'Reserved by another commitment.'; + const action = botActions(own)[0]; + assert.ok(action); + const resumed = applyAction(fixture.game, fixture.choam, action); + assert.equal(resumed.pendingKull, null); + assert.equal(resumed.karamaShipping?.owner, fixture.actor); + assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel)); + assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], 'choam'); + } +}); diff --git a/tests/choam-kull-controls.test.tsx b/tests/choam-kull-controls.test.tsx index 24a87415..d24f4626 100644 --- a/tests/choam-kull-controls.test.tsx +++ b/tests/choam-kull-controls.test.tsx @@ -4,7 +4,8 @@ import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { applyAction, viewGame, type GameView } from '../game/engine'; +import { applyAction, viewGame } from '../game/engine'; +import type { GameView } from '../game/engine'; import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull'; const aliases = registerHooks({ @@ -43,7 +44,7 @@ void test('only the reactor receives physical Kull cost controls; outsiders cann } const html = markup(holder); assert.match(html, /]*>Decline Kull/); - assert.match(html, /]*>Use Kull Wahad \(ix-kull-wahad\)/); + assert.match(html, /]*>Use Kull Wahad[^<]*ix-kull-wahad/); assert.match(html, /Inspect card/); const outsider = markup(rival); assert.doesNotMatch(outsider, /Use Kull Wahad/); assert.match(markup(holder), /]*>Decline Kull/); holder.kullReaction!.blocked = null; - holder.kullReaction!.cards = []; + holder.kullReaction!.plays = []; assert.doesNotMatch(markup(holder), />Use Kull Wahad/); assert.match(markup(holder), /]*>Decline Kull/); holder.kullReaction!.canDecline = false; @@ -75,7 +76,6 @@ void test('the real saved table mounts the offer instead of shipment and volunta })); assert.match(html, /aria-label="Kull Wahad response"/); assert.match(html, /]*>Use Kull Wahad/); - assert.match(html, /href="\/rules\?topic=choam-kull#choam-kull"/); assert.doesNotMatch(html, /]*>[^<]*(?:Ship forces|Cash in|Play Karama|End movement)/i); const disabled = renderToStaticMarkup(createElement(GameTable, { game: holder, send: async () => {}, busy: true, onExit() {}, @@ -87,7 +87,7 @@ void test('a successful phase ban removes the retained Karama shipment-rate cont const initial = choamKullGame(); let g = applyAction(initial, 'e', kullShipmentAttempt(initial)); g = applyAction(g, 'c', { - type: 'kullDecision', event: g.pendingKull!.event, card: 'ix-kull-wahad', + type: 'kullDecision', event: g.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad', }); for (let step = 0; g.pendingKull && step < 12; step++) { const actor = g.players.find(p => { diff --git a/tests/choam-kull-engine.test.ts b/tests/choam-kull-engine.test.ts index d7408165..26a79d1a 100644 --- a/tests/choam-kull-engine.test.ts +++ b/tests/choam-kull-engine.test.ts @@ -16,7 +16,7 @@ function inventory(g: Game) { } function declare(g: Game) { const reaction = viewGame(g, 'c').kullReaction!; - return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, card: 'ix-kull-wahad' }); + return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, source: 'printed', card: 'ix-kull-wahad' }); } function finishKull(g: Game) { for (let step = 0; g.pendingKull && step < 12; step++) { @@ -62,11 +62,11 @@ void test('ordinary printed attempts offer neutral CHOAM timing before cost with assert.ok(held(offered, 'e', original)); assert.equal(offered.discard.some(card => card.id === original), false); for (const id of ['e', 'b', 'h']) { - assert.deepEqual(viewGame(offered, id).kullReaction?.cards, []); + assert.deepEqual(viewGame(offered, id).kullReaction?.plays, []); assert.equal(viewGame(offered, id).kullReaction?.canDecline, false); assert.equal(viewGame(offered, id).response, null); } - assert.deepEqual(viewGame(offered, 'c').kullReaction?.cards.map(card => card.id), ['ix-kull-wahad']); + assert.deepEqual(viewGame(offered, 'c').kullReaction?.plays.map(play => [play.source, play.card.id]), [['printed', 'ix-kull-wahad']]); assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered)); const event = offered.pendingKull!.event; for (const [id, bad] of [ @@ -570,11 +570,11 @@ void test('CHOAM cannot voluntarily spend the Kull card required by its own batt }]; g.response = { kind: 'eliteStrength', owner: 'e', passed: [] }; const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id }); - assert.deepEqual(viewGame(offered, 'c').kullReaction?.cards, []); + assert.deepEqual(viewGame(offered, 'c').kullReaction?.plays, []); assert.equal(viewGame(offered, 'c').kullReaction?.canDecline, true); const before = JSON.stringify(offered); assert.throws(() => applyAction(offered, 'c', { - type: 'kullDecision', event: offered.pendingKull!.event, card: 'ix-kull-wahad', + type: 'kullDecision', event: offered.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad', }), /eligible held Kull/); assert.equal(JSON.stringify(offered), before); const declined = applyAction(offered, 'c', { diff --git a/tests/choam-kull-recovery.test.ts b/tests/choam-kull-recovery.test.ts index 9f1fe3f8..af61aed2 100644 --- a/tests/choam-kull-recovery.test.ts +++ b/tests/choam-kull-recovery.test.ts @@ -40,9 +40,13 @@ async function persisted(prepare?: (g: Game) => void) { prepare?.(initial); assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') .run(JSON.stringify(initial), initial.version, code, current.version).changes, 1); + const unrelated = await store.rooms.createRoom('Preserved unrelated room', 'atreides', false, []); + const unrelatedCode = unrelated.view.code; + const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelatedCode); store.writes.length = 0; return { ...store, code, tokens, auths, initial, - originalCredentials: credentials(store.sqlite), originalCards: inventory(initial) }; + originalCredentials: credentials(store.sqlite), originalCards: inventory(initial), + unrelatedCode, unrelatedRow }; } catch (error) { store.sqlite.close(); throw error; @@ -60,11 +64,17 @@ type Fixture = { initial: Game; originalCredentials: { seats: unknown[]; entries: unknown[]; keys: unknown[]; recoveries: unknown[] }; originalCards: string[]; + unrelatedCode: string; + unrelatedRow: Record | undefined; }; async function act(f: Fixture, g: Game, actor: string, action: Action) { const rooms = f.restart(); - const auth = f.auths.find(auth => auth.playerId === actor)!; + const index = f.auths.findIndex(auth => auth.playerId === actor); + assert.ok(index >= 0); + const auth = await rooms.authenticate(f.code, f.tokens[index]); + assert.equal(auth.playerId, actor); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); await rooms.act(f.code, auth, g.version, action, clock); return rooms.readRoom(f.code); } @@ -74,6 +84,8 @@ async function restored(f: Fixture, g: Game) { const views: GameView[] = []; for (let index = 0; index < f.tokens.length; index++) { const auth = await rooms.authenticate(f.code, f.tokens[index]); + assert.equal(auth.playerId, f.auths[index].playerId); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); const view = await rooms.readSeatView(f.code, auth); assert.deepEqual(view, viewGame(g, auth.playerId)); if (g.pendingKull) { @@ -88,6 +100,7 @@ async function restored(f: Fixture, g: Game) { views.push(view); } assert.deepEqual(credentials(f.sqlite), f.originalCredentials); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow); assert.deepEqual(inventory(g), f.originalCards); assert.equal(new Set(inventory(g)).size, f.originalCards.length); return views; @@ -104,6 +117,7 @@ async function rejected(f: Fixture, g: Game, actor: string, action: Action, vers assert.deepEqual(attemptedStatements, [], 'rejected game actions must not attempt SQL writes'); assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before); assert.deepEqual(credentials(f.sqlite), f.originalCredentials); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow); } async function stationary(f: Fixture, g: Game) { @@ -158,9 +172,10 @@ async function offer(f: Fixture, initial: Game, actor: string, action: Action, e assert.equal(reaction.player, f.auths[0].playerId); assert.equal(reaction.target, actor); assert.equal(reaction.canDecline, true); - assert.deepEqual(reaction.cards.map(card => card.id), expectedCards); + assert.deepEqual(reaction.plays.map(play => ({ source: play.source, card: play.card.id })), + expectedCards.map(card => ({ source: 'printed', card }))); const publicFields = { event: reaction.event, player: reaction.player, target: reaction.target, - intent: reaction.intent, cards: [], canDecline: false, blocked: null }; + intent: reaction.intent, plays: [], canDecline: false, blocked: null }; for (const view of views.slice(1)) assert.deepEqual(view.kullReaction, publicFields); assert.deepEqual(g.players, initial.players, 'the public offer must precede every card, revival, income and once-use cost'); assert.deepEqual(g.discard, initial.discard); @@ -183,7 +198,7 @@ void test('persisted Kull offer and racing decline revive Emperor forces exactly const decline = { type: 'kullDecision', event, decline: true }; await rejected(f, g, emperor, decline); await rejected(f, g, f.auths[0].playerId, { ...decline, event: `${event}-obsolete` }); - await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, card: card.id }); + await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, source: 'printed', card: card.id }); await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, decline: 'yes' }); await rejected(f, g, f.auths[0].playerId, decline, g.version - 1); const done = await compete(f, g, f.auths[0].playerId, decline); @@ -205,7 +220,7 @@ void test('persisted Kull success races the final pass, retains printed Karama a const emperor = f.auths[1].playerId, choam = f.auths[0].playerId; const original = kullShipmentAttempt(f.initial, emperor); const { g: offered, event } = await offer(f, f.initial, emperor, original); - let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + let g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); assert.equal(g.response?.kind, 'choamWorthless'); assert.equal(g.karamaShipping, null); assert.equal(g.discard.some(card => card.id === original.card), false); @@ -232,7 +247,7 @@ void test('persisted Kull success races the final pass, retains printed Karama a assert.equal(player(done, emperor).hand.filter(card => card.id === original.card).length, 1); assert.equal(player(done, emperor).spice, player(f.initial, emperor).spice); await rejected(f, done, emperor, original); - await rejected(f, done, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + await rejected(f, done, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); await stationary(f, done); } finally { f.sqlite.close(); } }); @@ -247,7 +262,7 @@ void test('a distinct held Karama counters persisted Kull and resumes the origin const original = kullShipmentAttempt(f.initial, emperor); const distinct = player(f.initial, emperor).hand.find(card => card.effect === 'karama' && card.id !== original.card)!; const { g: offered, event } = await offer(f, f.initial, emperor, original); - const g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + const g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); const views = await restored(f, g); assert.deepEqual(views[1].responseControls?.cancelCards, [distinct.id]); assert.equal(views[1].responseControls?.cancelCards.includes(String(original.card)), false); @@ -279,7 +294,7 @@ void test('Kull intercepts persisted BG substitution before conversion and retai const original = { type: 'card', mode: 'shipment', card: worthless[0].id, target: emperor }; const { g: offered, event } = await offer(f, f.initial, bg, original); assert.equal(offered.pendingKarama, null); - let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + let g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); assert.equal(g.response?.kind, 'choamWorthless'); assert.equal(g.pendingKarama, null); assert.equal(player(g, bg).hand.some(card => card.id === worthless[0].id), true); @@ -335,7 +350,7 @@ void test('successful persisted Kull prevents Emperor revival before forces or i const karama = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!; const original = { type: 'card', mode: 'special', card: karama.id, amount: 2 }; const { g: offered, event } = await offer(f, f.initial, emperor, original); - const declared = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + const declared = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); await restored(f, declared); const done = await passes(f, declared); assert.deepEqual(player(done, emperor), player(f.initial, emperor)); @@ -354,7 +369,7 @@ void test('a Kull-free saved CHOAM hand still receives the neutral private offer const original = kullShipmentAttempt(f.initial, emperor); await rejected(f, f.initial, emperor, { type: 'card', mode: 'special', card: original.card, amount: 2 }); const { g, event } = await offer(f, f.initial, emperor, original, []); - await rejected(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + await rejected(f, g, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); const done = await act(f, g, choam, { type: 'kullDecision', event, decline: true }); assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card }); assert.equal(done.discard.filter(card => card.id === original.card).length, 1); @@ -374,7 +389,7 @@ void test('corrupt saved Kull intent ownership cannot execute or write after a r assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?') .run(JSON.stringify(corrupt), f.code, g.version).changes, 1); await rejected(f, corrupt, choam, { type: 'kullDecision', event, decline: true }); - await rejected(f, corrupt, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); await assert.rejects(f.restart().readSeatView(f.code, f.auths[0])); assert.deepEqual(credentials(f.sqlite), f.originalCredentials); } finally { f.sqlite.close(); } @@ -386,7 +401,7 @@ void test('orphaned saved Kull counters cannot be projected or commit a partial try { const emperor = f.auths[1].playerId, choam = f.auths[0].playerId; const { g, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor)); - let counter = await act(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' }); + let counter = await act(f, g, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); if (conversion) { const bg = f.auths[2].playerId; counter = await act(f, counter, bg, { @@ -399,8 +414,99 @@ void test('orphaned saved Kull counters cannot be projected or commit a partial .run(JSON.stringify(corrupt), f.code, counter.version).changes, 1); await rejected(f, corrupt, f.auths[3].playerId, { type: 'passResponse' }); for (const auth of f.auths) - await assert.rejects(f.restart().readSeatView(f.code, auth), /lost its interrupted Karama frame/); + await assert.rejects(f.restart().readSeatView(f.code, auth)); assert.deepEqual(credentials(f.sqlite), f.originalCredentials); } finally { f.sqlite.close(); } } }); + +void test('a verified pre-upgrade printed Kull counter migrates without selecting a new cost or losing its exact suspended response', async () => { + const f = await persisted(); + try { + const choam = f.auths[0].playerId, emperor = f.auths[1].playerId, harkonnen = f.auths[3].playerId; + const original = kullShipmentAttempt(f.initial, emperor); + const { g: offered, event } = await offer(f, f.initial, emperor, original); + const declared = await act(f, offered, choam, + { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); + const legacy = structuredClone(declared); + const retained = JSON.parse(JSON.stringify({ + response: legacy.response, resume: legacy.pendingKull!.resume, + declaration: legacy.pendingChoamWorthless, players: legacy.players, discard: legacy.discard, + })); + // The old producer signed these same controls before the source selection field existed. + const signature: Record = JSON.parse(legacy.pendingKull!.signature); + delete signature.selection; + delete legacy.pendingKull!.selection; + legacy.pendingKull!.signature = JSON.stringify(signature); + const legacyJSON = JSON.stringify(legacy); + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?') + .run(legacyJSON, f.code, legacy.version).changes, 1); + f.writes.length = 0; + const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code); + const old = await f.restart().readRoom(f.code); + assert.equal(old.pendingKull!.selection, undefined); + const oldViews = await restored(f, old); + assert.equal(oldViews[3].responseControls?.cancelCards.includes( + player(old, harkonnen).hand.find(card => card.effect === 'karama')!.id), true); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before); + assert.equal(f.writes.length, 0, 'reading the old save does not acknowledge its human counter'); + await f.restart().continueRoomAutomatic(f.code, clock); + const migrated = await f.restart().readRoom(f.code); + assert.equal(migrated.version, legacy.version + 1, 'the bounded binding is persisted once'); + assert.deepEqual(f.writes.map(write => write.changes), [1]); + assert.deepEqual(migrated.pendingKull!.selection, { source: 'printed', card: 'ix-kull-wahad' }); + assert.equal(migrated.pendingKull!.event, event); + assert.deepEqual(JSON.parse(JSON.stringify({ + response: migrated.response, resume: migrated.pendingKull!.resume, + declaration: migrated.pendingChoamWorthless, players: migrated.players, discard: migrated.discard, + })), retained); + await restored(f, migrated); + await stationary(f, migrated); + const counter = player(migrated, harkonnen).hand.find(card => card.effect === 'karama')!; + const done = await act(f, migrated, harkonnen, { type: 'card', mode: 'cancel', card: counter.id }); + assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card }); + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.response, null); + assert.equal(done.discard.filter(card => card.id === original.card).length, 1); + assert.equal(done.discard.filter(card => card.id === counter.id).length, 1); + assert.equal(player(done, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1); + assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false); + await restored(f, done); + await rejected(f, done, harkonnen, { type: 'card', mode: 'cancel', card: counter.id }); + await stationary(f, done); + } finally { f.sqlite.close(); } +}); + +void test('an old printed counter with altered controls or an unverified signature cannot use the bounded save migration', async () => { + const f = await persisted(); + try { + const choam = f.auths[0].playerId, emperor = f.auths[1].playerId; + const { g: offered, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor)); + const declared = await act(f, offered, choam, + { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' }); + for (const change of [ + (g: Game) => { g.pendingKull!.signature += 'forged'; }, + (g: Game) => { g.pendingKull!.resume.decision = { kind: 'auctionPayment', player: emperor }; }, + (g: Game) => { g.pendingChoamWorthless!.card = g.deck[0].id; }, + ]) { + const corrupt = structuredClone(declared); + const signature: Record = JSON.parse(corrupt.pendingKull!.signature); + delete signature.selection; + delete corrupt.pendingKull!.selection; + corrupt.pendingKull!.signature = JSON.stringify(signature); + change(corrupt); + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?') + .run(JSON.stringify(corrupt), f.code, corrupt.version).changes, 1); + f.writes.length = 0; + const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code); + for (const token of f.tokens) { + const rooms = f.restart(); + await assert.rejects(rooms.readSeatView(f.code, await rooms.authenticate(f.code, token))); + } + await rejected(f, corrupt, emperor, { type: 'passResponse' }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before); + assert.equal(f.writes.length, 0); + } + } finally { f.sqlite.close(); } +}); diff --git a/tests/choam-kull.test.ts b/tests/choam-kull.test.ts index d2e5764c..cc56decd 100644 --- a/tests/choam-kull.test.ts +++ b/tests/choam-kull.test.ts @@ -9,6 +9,7 @@ import { kullBlocksKarama, kullCounterCards, kullNativeCostCards, + kullNexusCostCards, validateKullAttemptStamp, validateKullPhaseRestrictions, type KullAttemptStamp, @@ -151,6 +152,48 @@ void test('native Kull costs require canonical identity and unique actual custod assert.deepEqual({ held, physical }, before); }); +void test('Nexus Kull accepts canonical non-Worthless Treachery fuel without narrowing to printed Kull', () => { + const physical = [...baseDeck(), ...ixStandardCards()]; + const weapon = physical.find(card => card.kind === 'projectile')!; + const karama = physical.find(card => card.effect === 'karama')!; + const worthless = physical.find(card => card.kind === 'worthless' && card.id !== 'ix-kull-wahad')!; + const kull = physical.find(card => card.id === 'ix-kull-wahad')!; + const held: Card[] = JSON.parse(JSON.stringify([weapon, karama, worthless, kull])); + const before = structuredClone({ held, physical }); + assert.deepEqual(kullNexusCostCards(held, physical).map(card => card.id), + [weapon.id, karama.id, worthless.id, kull.id]); + assert.deepEqual(kullNativeCostCards(held, physical).map(card => card.id), [kull.id]); + assert.deepEqual(kullNexusCostCards(held, physical, [karama.id, kull.id, 'absent']).map(card => card.id), + [weapon.id, worthless.id]); + assert.deepEqual(kullNexusCostCards([], physical), []); + assert.deepEqual({ held, physical }, before); +}); + +void test('Nexus Kull excludes counterfeit, duplicated and reserved custody without losing unrelated real fuel', () => { + const physical = [...baseDeck(), ...ixStandardCards()]; + const weapon = physical.find(card => card.kind === 'projectile')!; + const karama = physical.find(card => card.effect === 'karama')!; + const held = Object.freeze([Object.freeze({ ...weapon }), Object.freeze({ ...karama })]); + const before = structuredClone({ held, physical }); + for (const fake of [ + { ...weapon, id: 'invented' }, + { ...weapon, id: '' }, + { ...weapon, name: 'counterfeit' }, + { ...weapon, kind: 'worthless' as const }, + { ...weapon, effect: 'karama' }, + ]) { + assert.deepEqual(kullNexusCostCards([fake, karama], physical).map(card => card.id), [karama.id]); + } + assert.deepEqual(kullNexusCostCards([weapon, { ...weapon }, karama], physical).map(card => card.id), + [karama.id]); + assert.deepEqual(kullNexusCostCards(held, [...physical, { ...weapon }]).map(card => card.id), + [karama.id]); + assert.deepEqual(kullNexusCostCards(held, physical.filter(card => card.id !== weapon.id)).map(card => card.id), + [karama.id]); + assert.deepEqual(kullNexusCostCards(held, physical, [weapon.id, karama.id]), []); + assert.deepEqual({ held, physical }, before); +}); + void test('distinct counters honor effective BG and revealed Shrine eligibility without reusing the original', () => { const physical = baseDeck(); const karamas = physical.filter(card => card.effect === 'karama'); diff --git a/tests/fixture-nexus-choam-kull.ts b/tests/fixture-nexus-choam-kull.ts new file mode 100644 index 00000000..46dbaabe --- /dev/null +++ b/tests/fixture-nexus-choam-kull.ts @@ -0,0 +1,203 @@ +import { applyAction, battles, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { takeKullCard } from './fixture-choam-kull'; +import { orderNexusSpice } from './fixture-nexus-cards'; +import type { FactionId } from '../game/catalog'; + +export function createNexusChoamKullFixture(options: { + advanced?: boolean; bg?: boolean; seatIds?: string[]; nexusFace?: 'choam' | 'richese'; + actorFaction?: Extract; + fuel?: 'karama' | 'worthless' | 'weapon'; counter?: boolean; +} = {}): { game: Game; choam: string; actor: string; other: string | null; event: string; + original: string; fuel: string; counter: string | null } { + const ids = options.seatIds ?? ['c', 'e', 'h']; + if (ids.length !== 3 || new Set(ids).size !== 3) throw new Error('Nexus Kull fixture requires three distinct seats.'); + const [choam, actor, other] = ids; + const needsSubstitute = options.fuel === 'karama' && !options.bg; + const substitute = needsSubstitute && (options.advanced ?? true); + if (needsSubstitute && options.advanced === false && options.counter === true) + throw new Error('Basic has only two physical Karama cards: original and fuel leave no distinct counter.'); + let game = createGame('NXKULL01', newPlayer(choam, 'CHOAM', 'choam'), options.advanced ?? true, ['choam', 'ix']); + joinGame(game, newPlayer(actor, 'Actor', options.actorFaction ?? (options.bg ? 'beneGesserit' : 'emperor'))); + joinGame(game, newPlayer(other, 'Other', substitute ? 'beneGesserit' : 'harkonnen')); + for (const p of game.players) { p.bot = 'Medium'; p.ready = true; } + game.nexusCards = { cards: null, phase: null }; + game = initializeNexusKullGameForAudit(game); + for (let step = 0; game.status === 'setup' && step < 100; step++) { + let next: Game | undefined; + for (const p of game.players) { + const action = botActions(viewGame(game, p.id))[0]; + if (action) { next = applyAction(game, p.id, action); break; } + } + if (!next) throw new Error('Genuine Nexus Kull setup has no legal action.'); + game = next; + } + if (game.status !== 'playing') throw new Error('Genuine Nexus Kull setup did not complete.'); + for (const phase of [0, 1]) { + if (phase === 1) orderNexusSpice(game, ['land', 'land']); + for (let step = 0; game.phase === phase && step < 80; step++) { + let next: Game | undefined; + for (const p of game.players) { + const view = viewGame(game, p.id); + view.players.find(seat => seat.id === p.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) { next = applyAction(game, p.id, action); break; } + } + if (!next) throw new Error('Genuine CHOAM/Ix Nexus phase has no legal continuation.'); + game = next; + } + if (game.phase !== phase + 1) throw new Error('Genuine CHOAM/Ix Nexus phase did not finish.'); + } + game.deck.push(...game.players.flatMap(p => p.hand)); + for (const p of game.players) { p.hand = []; delete p.bot; } + const original = takeKullCard(game, options.bg ? 'Baliset' : 'karama'); + game.players[1].hand.push(original); + const fuel = takeKullCard(game, options.fuel === 'karama' ? 'karama' : options.fuel === 'weapon' ? 'ix-hunter-seeker' : 'La La La'); + game.players[0].hand.push(fuel); + const counter = options.counter === false || (needsSubstitute && !substitute) + ? null : takeKullCard(game, substitute ? 'Jubba Cloak' : 'karama'); + if (counter) game.players[2].hand.push(counter); + const cards = game.nexusCards!.cards!; + const face = options.nexusFace ?? 'choam'; + const old = cards.hands[choam]; + const holder = game.players.find(p => cards.hands[p.id] === face); + if (holder) cards.hands[holder.id] = old; + else { + const zone = cards.deck.includes(face) ? cards.deck : cards.discard; + const index = zone.indexOf(face); + if (index < 0) throw new Error('Missing physical Nexus fixture face.'); + zone.splice(index, 1); + if (old) cards.deck.push(old); + } + cards.hands[choam] = face; + Object.assign(game, { turn: 2, phase: 5, active: actor, storm: 18, ready: [], decision: null, + response: null, pendingKarama: null, phaseOpening: null, stormPending: null, + stormResolution: null, movementRemaining: [...ids] }); + game = applyAction(game, actor, { type: 'card', mode: 'shipment', card: original.id, target: actor }); + if (!game.pendingKull) throw new Error('Genuine Karama attempt did not create the Kull opportunity.'); + return { game, choam, actor, other, event: game.pendingKull.event, + original: original.id, fuel: fuel.id, counter: counter?.id ?? null }; +} + +/** A native declaration is produced by actions before its Karama/Kull parent + * is suspended. The final faction roster is fixed before genuine setup. */ +export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | 'advisors' | 'guild', + seatIds?: string[]) { + const actorFaction = { inspection: 'atreides', sardaukar: 'emperor', + advisors: 'beneGesserit', guild: 'guild' } as const; + const f = createNexusChoamKullFixture({ actorFaction: actorFaction[kind], fuel: 'weapon', counter: false, seatIds }); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + game.players[1].hand.push(takeKullCard(game, f.original)); + const counter = takeKullCard(game, 'karama'); + game.players[2].hand.push(counter); + const face = actorFaction[kind], cards = game.nexusCards!.cards!; + const old = cards.hands[f.actor]; + const holder = game.players.find(p => cards.hands[p.id] === face); + if (holder) cards.hands[holder.id] = old; + else { + const zone = cards.deck.includes(face) ? cards.deck : cards.discard; + const index = zone.indexOf(face); + if (index < 0) throw new Error(`Missing native ${face} Nexus.`); + zone.splice(index, 1); + if (old) cards.deck.push(old); + } + cards.hands[f.actor] = face; + Object.assign(game, { phase: kind === 'inspection' || kind === 'sardaukar' ? 6 : 5, + active: f.actor, karamaShipping: null, movementRemaining: null, ready: [] }); + for (const p of game.players) { + p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0; + if (p.elites) { p.elites.forces = {}; p.elites.tanks = 0; p.elites.reserves = p.faction === 'emperor' ? 5 : 3; } + if (p.advisors) p.advisors = {}; + } + if (kind === 'inspection' || kind === 'sardaukar') { + for (const p of game.players.slice(1)) { p.forces = { 'pasty_mesa:5': 7 }; p.reserves = 13; } + const choice = battles(game).find(battle => battle.territory === 'pasty_mesa'); + if (!choice) throw new Error('Native parent setup has no contested battle.'); + game.active = choice.chooser; + game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); + for (const id of [f.actor, f.other!]) if (game.battle!.preLeader && !game.battle!.preLeader.closed) + game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.event }); + if (kind === 'inspection') { + if (game.response) game = allowNativeParent(game); + game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' }); + game = allowNativeParent(game); + game = applyAction(game, f.other!, { type: 'prescienceAnswer', value: 0 }); + if (game.decision?.kind === 'fullPlanOffer') + game = applyAction(game, game.decision.player, { type: 'decision', decline: true }); + game = applyAction(game, f.actor, { type: 'nexusAtreides', event: game.battle!.event, mode: 'cunning', field: 'weapon' }); + } else { + while (game.battle!.preparation) + game = applyAction(game, game.battle!.preparation.owner, { type: 'declineBattlePower' }); + if (game.decision?.kind === 'fullPlanOffer') + game = applyAction(game, game.decision.player, { type: 'decision', decline: true }); + game = applyAction(game, f.actor, { type: 'nexusSardaukar', event: viewGame(game, f.actor).nexusSardaukar!.offer!.event }); + } + } else if (kind === 'advisors') { + game.players[1].forces = { 'pasty_mesa:5': 2 }; game.players[1].reserves = 18; + game.players[1].advisors = { pasty_mesa: {} }; + game.players[2].forces = { 'pasty_mesa:5': 1 }; game.players[2].reserves = 19; + game = applyAction(game, f.actor, { type: 'nexusAdvisors', + event: viewGame(game, f.actor).nexusAdvisors!.offer!.event, territories: ['pasty_mesa'] }); + } else { + game = applyAction(game, f.actor, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 }); + if (game.decision?.kind === 'guildShipment') + game = applyAction(game, game.decision.player, { type: 'decision', allow: true }); + game = allowNativeParent(game); + game = applyAction(game, f.actor, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 }); + game = applyAction(game, f.actor, { type: 'endMovement', nexus: viewGame(game, f.actor).nexusGuildCunning!.offer!.event }); + } + return { ...f, game, reactor: f.other!, reaction: counter.id }; +} + +function allowNativeParent(game: Game): Game { + for (let step = 0; game.response && step < 20; step++) { + const responder = game.players.find(p => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + if (!responder) throw new Error('Native response has no explicit responder.'); + game = applyAction(game, responder.id, { type: 'passResponse' }); + } + return game; +} + +export function createNexusKullNestedParent(seatIds?: string[]) { + const f = createNexusChoamKullFixture({ bg: true, fuel: 'karama', seatIds }); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + game = allowNativeParent(game); + game.players[1].hand.push(takeKullCard(game, f.original)); + const printed = takeKullCard(game, 'ix-kull-wahad'); + game.players[0].hand.push(printed); + const bgCounter = takeKullCard(game, 'Jubba Cloak'); + game.players[1].hand.push(bgCounter); + Object.assign(game, { phase: 4, karamaShipping: null }); + const play = viewGame(game, f.choam).choamWorthless!.plays.find(play => + play.source === 'nexus' && play.effect === 'laLaLa' && play.card.id === f.fuel); + if (!play || !('event' in play)) throw new Error('Missing genuine Nexus parent quote.'); + game = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, + nexus: play.event, effect: 'laLaLa', target: f.actor }); + return { ...f, game, printed: printed.id, bgCounter: bgCounter.id, nexusEvent: play.event }; +} + +export function createNexusKullAuctionParent(seatIds?: string[]) { + const f = createNexusChoamKullFixture({ fuel: 'weapon', counter: false, seatIds }); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + const payment = takeKullCard(game, f.original); + game.players[0].hand.push(payment); + const printed = takeKullCard(game, 'ix-kull-wahad'); + game.players[0].hand.push(printed); + const reaction = takeKullCard(game, 'karama'); + game.players[2].hand.push(reaction); + game.players[1].hand.push(takeKullCard(game, 'Shield')); + const lot = takeKullCard(game, 'ix-snooper'); + Object.assign(game, { phase: 3, active: f.choam, karamaShipping: null, movementRemaining: [], + auction: { cards: [lot], index: 0, bid: 0, bidder: null, active: f.choam, passed: [], opener: 0 } }); + game.players[0].spice = 1; + game = applyAction(game, f.choam, { type: 'bid', amount: 2 }); + for (let step = 0; game.auction && !game.decision && step < 12; step++) + game = applyAction(game, game.auction.active!, { type: 'passBid' }); + if (game.decision?.kind !== 'auctionPayment') throw new Error('CHOAM did not reach genuine winning payment.'); + return { ...f, game, payment: payment.id, printed: printed.id, reactor: f.other!, + reaction: reaction.id, lot: lot.id }; +} diff --git a/tests/nexus-choam-kull-controls.test.tsx b/tests/nexus-choam-kull-controls.test.tsx new file mode 100644 index 00000000..9fc8d7f5 --- /dev/null +++ b/tests/nexus-choam-kull-controls.test.tsx @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import type { ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame } from '../game/engine'; +import type { GameView } from '../game/engine'; +import { createNexusChoamKullFixture } from './fixture-nexus-choam-kull'; +import { takeKullCard } from './fixture-choam-kull'; +import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +// Static imports would resolve Next and CSS before the Node-only SSR alias hook. +const { ChoamKull } = await import('../components/choam-kull'); +const { GameTable } = await import('../components/game-table'); + +const controls = (game: GameView, busy = false) => renderToStaticMarkup(createElement(ChoamKull, { + game, act() { assert.fail('Rendering must not play a card.'); }, busy, +})); +const table = (game: ComponentProps['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, { + game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy, onExit() {}, +})); + +void test('ordinary Treachery fuel is inspectable as itself only by the entitled CHOAM seat', () => { + for (const advanced of [false, true]) { + const fixture = createNexusChoamKullFixture({ advanced, fuel: 'weapon' }); + const own = viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.choam); + const fuel = own.kullReaction!.plays.find(play => play.card.id === fixture.fuel)!.card; + const html = controls(own); + assert.ok(html.includes(fuel.name)); + assert.ok(html.includes(fuel.id)); + assert.ok(html.includes(`Inspect card: ${fuel.name}`)); + assert.match(html, /aria-label="Inspect CHOAM Nexus card"/); + assert.match(html, /]*>Use Kull Wahad/); + const rival = viewGame(fixture.game, fixture.actor); + for (const player of rival.players) { + if (player.id === rival.me) continue; + Object.defineProperty(player, 'hand', { get() { throw new Error('Read private rival hand'); } }); + } + // The component also refuses an overfilled foreign cost projection. + rival.kullReaction = own.kullReaction; + const outsider = controls(rival); + assert.ok(!outsider.includes(fuel.id)); + assert.ok(!outsider.includes(fuel.name)); + assert.doesNotMatch(outsider, / { + const fixture = createNexusChoamKullFixture({ fuel: 'weapon' }); + const printed = takeKullCard(fixture.game, 'ix-kull-wahad'); + fixture.game.players.find(player => player.id === fixture.choam)!.hand.push(printed); + const own = viewGame(fixture.game, fixture.choam); + const html = controls(own); + const options = [...html.matchAll(/]*>(.*?)<\/option>/g)].map(match => match[1]); + const fuel = own.kullReaction!.plays.find(play => play.card.id === fixture.fuel)!.card; + assert.ok(options.some(option => option.includes(fuel.name))); + assert.ok(options.some(option => option.includes(printed.name))); + assert.ok(own.kullReaction!.plays.some(play => play.source === 'printed')); + assert.ok(own.kullReaction!.plays.some(play => play.source === 'nexus' && play.card.kind !== 'worthless')); +}); + +void test('absent CHOAM Nexus gives decline only without exposing a false cost inspector', () => { + const fixture = createNexusChoamKullFixture({ nexusFace: 'richese', fuel: 'weapon' }); + const own = viewGame(fixture.game, fixture.choam); + const html = controls(own); + assert.match(html, /]*>Decline Kull/); + assert.doesNotMatch(html, />Use Kull Wahad|Inspect card/); + assert.ok(!html.includes(fixture.fuel)); +}); + +void test('the actual table suspends voluntary panels throughout offer and counter, honoring all control locks', () => { + const fixture = createNexusChoamKullFixture({ fuel: 'weapon' }); + const own = viewGame(fixture.game, fixture.choam); + assert.match(table(own), /]*>Use Kull Wahad/); + const declared = applyAction(fixture.game, fixture.choam, { + type: 'kullDecision', event: fixture.event, source: 'nexus', card: fixture.fuel, + }); + const counter = viewGame(JSON.parse(JSON.stringify(declared)), fixture.other!); + assert.match(table(counter), /]*>Cancel with Karama/); + assert.ok(!table(counter).includes(fixture.fuel)); + for (const [view, action] of [[own, 'Use Kull Wahad'], [counter, 'Cancel with Karama']] as const) { + assert.doesNotMatch(table(view), /]*>[^<]*(?:Ship forces|Cash in|Play Karama|End movement|Make a bribe)/i); + assert.match(table(view, true), new RegExp(`disabled=""[^>]*>${action}`)); + for (const restriction of ['paused', 'closed', 'autopilot'] as const) { + const locked: ComponentProps['game'] = structuredClone(view); + if (restriction === 'autopilot') locked.players.find(player => player.id === locked.me)!.autopilot = 'Easy'; + else locked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true }; + assert.match(table(locked), new RegExp(`disabled=""[^>]*>${action}`)); + } + } +}); + +aliases.deregister(); diff --git a/tests/nexus-choam-kull-engine.test.ts b/tests/nexus-choam-kull-engine.test.ts new file mode 100644 index 00000000..db0a6755 --- /dev/null +++ b/tests/nexus-choam-kull-engine.test.ts @@ -0,0 +1,469 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { createNexusChoamKullFixture, createNexusKullNativeParent, createNexusKullNestedParent, + createNexusKullAuctionParent } from './fixture-nexus-choam-kull'; +import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull'; +import { spiceDeck } from '../game/cards'; +import { createNexusCardPhase } from '../game/nexus-card-phase'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const held = (game: Game, owner: string, card: string) => game.players.find(p => p.id === owner)!.hand.some(c => c.id === card); +function inventory(game: Game) { + return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort(); +} +function finish(game: Game): Game { + for (let step = 0; game.pendingKull && step < 20; step++) { + const responder = game.players.find(p => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + game = responder ? applyAction(reload(game), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(game)); + } + assert.equal(game.pendingKull, null); + return game; +} +function reject(game: Game, owner: string, action: Action) { + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, owner, action)); + assert.equal(JSON.stringify(game), before); +} + +void test('fresh admission accepts exactly native classic CHOAM, physical CHOAM/Ix and only Nexus', () => { + const lobby = () => { + const game = createGame('NXKULL01', newPlayer('c', 'CHOAM', 'choam'), false, ['choam', 'ix']); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + for (const p of game.players) p.ready = true; + game.nexusCards = { cards: null, phase: null }; + return game; + }; + const valid = lobby(), before = JSON.stringify(valid); + const initialized = initializeNexusKullGameForAudit(valid); + assert.equal(JSON.stringify(valid), before); + assert.equal(initialized.kullPreview, true); + assert.equal(initialized.nexusKullPreview, true); + assert.ok(initialized.deck.some(card => card.id === 'ix-kull-wahad')); + assert.deepEqual(initialized.spiceDeck.map(card => JSON.stringify(card)).sort(), + spiceDeck().map(card => JSON.stringify(card)).sort()); + assert.equal(initialized.spiceDeck.some(card => 'sandtrout' in card), false); + for (const corrupt of [ + (g: Game) => { g.expansions = ['choam']; }, + (g: Game) => { g.nexusCards = undefined; }, + (g: Game) => { g.players[1] = newPlayer('e', 'Richese', 'richese'); g.players[1].ready = true; }, + (g: Game) => { g.semutaPreview = true; }, + (g: Game) => { g.kullSequence = 1; }, + (g: Game) => { g.turn = 2; }, + (g: Game) => { g.response = { kind: 'eliteStrength', owner: 'e', passed: [] }; }, + (g: Game) => { g.sandtrout = true; }, + (g: Game) => { g.discoveryEnabled = true; }, + ]) { + const game = lobby(); corrupt(game); + const saved = JSON.stringify(game); + assert.throws(() => initializeNexusKullGameForAudit(game)); + assert.equal(JSON.stringify(game), saved); + } +}); + +for (const advanced of [false, true]) for (const fuel of ['karama', 'worthless', 'weapon'] as const) + void test(`Nexus Kull succeeds once with ${fuel} fuel in ${advanced ? 'Advanced' : 'Basic'}`, () => { + const f = createNexusChoamKullFixture({ advanced, fuel }); + const physical = inventory(f.game); + const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); + assert.equal(held(declared, f.choam, f.fuel), f.counter !== null); + assert.ok(held(declared, f.actor, f.original)); + assert.equal(declared.nexusCards!.cards!.hands[f.choam], null); + assert.equal(declared.nexusChoamHistory!.at(-1)!.stage, f.counter ? 'pending' : 'complete'); + const done = finish(declared); + assert.deepEqual(inventory(done), physical); + assert.ok(held(done, f.actor, f.original)); + assert.equal(held(done, f.choam, f.fuel), false); + assert.equal(done.discard.filter(card => card.id === f.fuel).length, 1); + assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1); + assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'complete'); + assert.equal(done.karamaShipping, null); + reject(done, f.actor, { type: 'card', mode: 'shipment', card: f.original, target: f.actor }); + reject(done, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); + }); + +void test('distinct prevention retains fuel, spends accepted Nexus, and resumes original exactly once', () => { + const f = createNexusChoamKullFixture({ fuel: 'weapon' }); + const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); + reject(declared, f.actor, { type: 'card', mode: 'cancel', card: f.original }); + const done = applyAction(reload(declared), f.other!, { type: 'card', mode: 'cancel', card: f.counter! }); + assert.equal(done.pendingKull, null); + assert.ok(held(done, f.choam, f.fuel)); + assert.equal(done.discard.filter(card => card.id === f.original).length, 1); + assert.equal(done.discard.filter(card => card.id === f.counter).length, 1); + assert.deepEqual(done.karamaShipping, { player: f.actor, owner: f.actor, card: f.original }); + assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'canceled'); + assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1); + assert.equal(viewGame(done, f.actor).karamaBlocked, null); + reject(done, f.other!, { type: 'card', mode: 'cancel', card: f.counter! }); +}); + +void test('BG interception precedes conversion; successful Kull keeps original while decline converts once', () => { + const f = createNexusChoamKullFixture({ bg: true, fuel: 'worthless' }); + assert.equal(f.game.pendingKarama, null); + assert.ok(held(f.game, f.actor, f.original)); + const success = finish(applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel })); + assert.ok(held(success, f.actor, f.original)); + assert.equal(success.pendingKarama, null); + const decline = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + assert.equal(decline.response?.kind, 'worthlessKarama'); + assert.equal(decline.discard.filter(card => card.id === f.original).length, 1); + assert.equal(decline.nexusCards!.cards!.hands[f.choam], 'choam'); + assert.equal(decline.nexusChoamHistory, undefined); +}); + +void test('source-required actions reject foreign, stale, malformed and source aliases immutably', () => { + const f = createNexusChoamKullFixture(); + for (const action of [ + { type: 'kullDecision', event: f.event, card: f.fuel }, + { type: 'kullDecision', event: f.event, source: 'printed', card: f.fuel }, + { type: 'kullDecision', event: f.event, source: 'nexus', card: f.original }, + { type: 'kullDecision', event: 'stale', source: 'nexus', card: f.fuel }, + { type: 'kullDecision', event: f.event, decline: true, source: 'nexus' }, + { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel, effect: 'kull' }, + ]) reject(f.game, f.choam, action); + reject(f.game, f.actor, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); +}); + +void test('offer timing and other-player views are independent of hidden Nexus face and costs', () => { + const f = createNexusChoamKullFixture(); + const other = reload(f.game); + const cards = other.nexusCards!.cards!; + const richeseHolder = other.players.find(p => cards.hands[p.id] === 'richese'); + if (richeseHolder) cards.hands[richeseHolder.id] = 'choam'; + else { const index = cards.deck.indexOf('richese'); cards.deck[index] = 'choam'; } + cards.hands[f.choam] = 'richese'; + for (const viewer of [f.actor, f.other!]) { + assert.deepEqual(viewGame(f.game, viewer), viewGame(other, viewer)); + assert.deepEqual(viewGame(f.game, viewer).kullReaction!.plays, []); + } + // Change hidden fuel availability without changing the public draw-pile size. + // Only the third seat compares this transfer: the recipient knows its own hand. + other.players.find(p => p.id === f.actor)!.hand.push(...other.players.find(p => p.id === f.choam)!.hand.splice(0)); + assert.deepEqual(inventory(other), inventory(f.game)); + assert.deepEqual(viewGame(f.game, f.other!), viewGame(other, f.other!)); + assert.deepEqual(viewGame(other, f.choam).kullReaction!.plays, []); + assert.deepEqual(viewGame(f.game, f.choam).kullReaction!.plays.map(play => [play.source, play.card.id]), [['nexus', f.fuel]]); +}); + +void test('printed choice in new profile does not spend Nexus and cannot alias another printed cost', () => { + const f = createNexusChoamKullFixture(); + const original = reload(f.game); + original.players[0].hand.push(takeKullCard(original, 'ix-kull-wahad')); + const declared = applyAction(original, f.choam, { type: 'kullDecision', event: f.event, source: 'printed', card: 'ix-kull-wahad' }); + const done = finish(declared); + assert.equal(done.nexusCards!.cards!.hands[f.choam], 'choam'); + assert.equal(done.nexusChoamHistory, undefined); + assert.ok(held(done, f.choam, f.fuel)); + assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1); +}); + +void test('deleted source, receipt, history, cost and original frame reject before projection or automatic settlement', () => { + const f = createNexusChoamKullFixture(); + const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); + for (const corrupt of [ + (g: Game) => { delete g.pendingKull!.selection; }, + (g: Game) => { delete g.pendingKull; }, + (g: Game) => { delete g.nexusChoamHistory; }, + (g: Game) => { delete g.pendingChoamWorthless!.nexusEvent; }, + (g: Game) => { g.players[0].hand = []; }, + (g: Game) => { g.players[1].hand = []; }, + (g: Game) => { g.pendingKull!.resume.response = { kind: 'eliteStrength', owner: f.actor, passed: [] }; }, + ]) { + const game = reload(declared); corrupt(game); + const before = JSON.stringify(game); + assert.throws(() => viewGame(game, f.other!)); + assert.throws(() => normalizeAutomaticGame(game)); + reject(game, f.other!, { type: 'passResponse' }); + assert.equal(JSON.stringify(game), before); + } +}); + +void test('Nexus Kull reserves a suspended printed parent cost and restores that exact response after success', () => { + const f = createNexusChoamKullFixture(); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + const original = takeKullCard(game, f.original); + game.players[1].hand.push(original); + const fuel = takeKullCard(game, 'ix-hunter-seeker'); + game.players[0].hand.push(fuel); + Object.assign(game, { phase: 4, karamaShipping: null }); + game = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, target: f.actor }); + const response = reload(game).response; + const offered = applyAction(game, f.actor, { type: 'card', mode: 'cancel', card: original.id }); + const event = offered.pendingKull!.event; + assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false); + reject(offered, f.choam, { type: 'kullDecision', event, source: 'nexus', card: f.fuel }); + const done = finish(applyAction(offered, f.choam, { type: 'kullDecision', event, source: 'nexus', card: fuel.id })); + assert.deepEqual(done.response, response); + assert.equal(done.pendingChoamWorthless!.card, f.fuel); + assert.ok(held(done, f.choam, f.fuel)); + assert.ok(held(done, f.actor, original.id)); + assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'complete'); +}); + +void test('printed Kull discovers a suspended Nexus parent through direct and BG counter overlays', () => { + const f = createNexusChoamKullFixture({ bg: true }); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + for (let step = 0; game.response?.kind === 'worthlessKarama' && step < 20; step++) { + const p = game.players.find(p => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game); + } + game.players[1].hand.push(takeKullCard(game, f.original)); + game.players[0].hand.push(takeKullCard(game, 'ix-kull-wahad')); + const bgCounter = takeKullCard(game, 'Jubba Cloak'); + game.players[1].hand.push(bgCounter); + Object.assign(game, { phase: 4, karamaShipping: null }); + const play = viewGame(game, f.choam).choamWorthless!.plays.find(play => + play.source === 'nexus' && play.effect === 'laLaLa' && play.card.id === f.fuel); + assert.ok(play && 'event' in play); + const nexusEvent = play.event; + const parent = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, nexus: nexusEvent, effect: 'laLaLa', target: f.actor }); + const offered = applyAction(parent, f.actor, { type: 'card', mode: 'cancel', card: f.original }); + const declaration = applyAction(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad' }); + assert.equal(declaration.nexusChoamHistory!.at(-1)!.stage, 'pending'); + assert.equal(viewGame(declaration, f.actor).response?.kind, 'choamWorthless'); + const converted = applyAction(declaration, f.actor, { type: 'card', mode: 'cancel', card: bgCounter.id }); + assert.equal(converted.response?.kind, 'worthlessKarama'); + assert.equal(viewGame(reload(converted), f.other!).response?.kind, 'worthlessKarama'); + assert.equal(normalizeAutomaticGame(reload(converted)).pendingKull?.stage, 'counter'); + const success = finish(declaration); + assert.equal(success.pendingChoamWorthless?.nexusEvent, nexusEvent); + assert.equal(success.nexusChoamHistory!.at(-1)!.stage, 'pending'); + const orphan = reload(declaration); + delete orphan.nexusChoamHistory; + assert.throws(() => viewGame(orphan, f.other!)); + assert.throws(() => normalizeAutomaticGame(orphan)); +}); + +void test('Nexus fuel needed by a live own Truthtrance battle promise cannot be accepted', () => { + const f = createNexusChoamKullFixture({ fuel: 'weapon' }); + let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + Object.assign(game, { phase: 6, active: f.choam, karamaShipping: null }); + for (const p of game.players) { p.forces = {}; p.reserves = 20; } + for (const p of game.players.slice(0, 2)) { p.forces = { 'arrakeen:10': 3 }; p.reserves = 17; } + game = applyAction(game, f.choam, { type: 'chooseBattle', territory: 'arrakeen', target: f.actor }); + for (let step = 0; game.response && step < 20; step++) { + const p = game.players.find(p => { + const controls = viewGame(game, p.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; + }); + game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game); + } + game.battle!.preparation = undefined; + game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }]; + game.response = { kind: 'eliteStrength', owner: f.actor, passed: [] }; + const offered = applyAction(game, f.other!, { type: 'card', mode: 'cancel', card: f.counter! }); + assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false); + reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'nexus', card: f.fuel }); + assert.equal(offered.nexusCards!.cards!.hands[f.choam], 'choam'); + assert.ok(held(offered, f.choam, f.fuel)); +}); + +void test('generic any-time CHOAM Cunning cannot bypass the canonical reactive opportunity', () => { + const f = createNexusChoamKullFixture(); + const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + const play = viewGame(game, f.choam).choamWorthless!.plays.find(play => + play.source === 'nexus' && play.effect === 'kull' && play.card.id === f.fuel); + assert.ok(play && 'event' in play && play.blocked); + reject(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, nexus: play.event, effect: 'kull' }); + assert.equal(game.nexusCards!.cards!.hands[f.choam], 'choam'); +}); + +void test('verified pre-source printed counters migrate without mutating saves or loosening new actions', () => { + const base = choamKullGame(); + const offer = applyAction(base, 'e', kullShipmentAttempt(base)); + const declaration = applyAction(offer, 'c', { type: 'kullDecision', + event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad' }); + const old = reload(declaration); + delete old.pendingKull!.selection; + const signature = JSON.parse(old.pendingKull!.signature) as Record; + delete signature.selection; + old.pendingKull!.signature = JSON.stringify(signature); + const saved = JSON.stringify(old); + assert.equal(viewGame(old, 'h').response?.kind, 'choamWorthless'); + const normalized = normalizeAutomaticGame(old); + assert.deepEqual(normalized.pendingKull!.selection, { source: 'printed', card: 'ix-kull-wahad' }); + assert.equal(JSON.stringify(old), saved); + const resumed = applyAction(old, 'h', { type: 'card', mode: 'cancel', card: base.players[3].hand[0].id }); + assert.equal(resumed.pendingKull, null); + assert.deepEqual(resumed.karamaShipping, { player: 'e', owner: 'e', card: base.players[1].hand[0].id }); + assert.ok(held(resumed, 'c', 'ix-kull-wahad')); + const success = finish(normalized); + assert.ok(held(success, 'e', base.players[1].hand[0].id)); + assert.equal(held(success, 'c', 'ix-kull-wahad'), false); + const corrupt = reload(old); + corrupt.pendingKull!.resume.response = { kind: 'eliteStrength', owner: 'e', passed: [] }; + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, 'c')); + assert.throws(() => normalizeAutomaticGame(corrupt)); + reject(corrupt, 'h', { type: 'passResponse' }); + assert.equal(JSON.stringify(corrupt), before); + reject(offer, 'c', { type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad' }); +}); + +void test('independent canonical descriptors and unique live custody govern fuel before and after JSON restore', () => { + const f = createNexusChoamKullFixture({ fuel: 'weapon' }); + for (const change of [ + (g: Game) => { g.players[0].hand[0].id = 'invented-treachery'; }, + (g: Game) => { g.players[0].hand[0].name = 'Invented Weapon'; }, + (g: Game) => { g.players[0].hand[0].kind = 'worthless'; }, + (g: Game) => { g.players[0].hand[0].effect = 'karama'; }, + (g: Game) => { g.deck.push({ ...g.players[0].hand[0] }); }, + ]) { + const offer = reload(f.game); change(offer); + const card = offer.players[0].hand[0].id; + assert.equal(viewGame(offer, f.choam).kullReaction!.plays.some(play => play.card.id === card), false); + reject(offer, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card }); + const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }); + const corrupt = reload(declared); change(corrupt); + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, f.other!)); + assert.throws(() => normalizeAutomaticGame(corrupt)); + reject(corrupt, f.other!, { type: 'passResponse' }); + assert.equal(JSON.stringify(corrupt), before); + } +}); + +for (const kind of ['inspection', 'sardaukar', 'advisors', 'guild'] as const) + void test(`native ${kind} parent remains readable and resolves its saved response through Nexus Kull`, () => { + const f = createNexusKullNativeParent(kind); + const physical = inventory(f.game), parent = reload(f.game).response; + const before = JSON.stringify(f.game); + const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'cancel', card: f.reaction }); + assert.equal(JSON.stringify(f.game), before); + assert.deepEqual(offered.pendingKull!.resume.response, parent); + const declared = applyAction(offered, f.choam, { type: 'kullDecision', + event: offered.pendingKull!.event, source: 'nexus', card: f.fuel }); + assert.equal(viewGame(reload(declared), f.reactor).response?.kind, 'choamWorthless'); + assert.equal(normalizeAutomaticGame(reload(declared)).pendingKull?.stage, 'counter'); + const result = finish(reload(declared)); + assert.deepEqual(inventory(result), physical); + assert.ok(held(result, f.reactor, f.reaction)); + assert.equal(result.nexusChoamHistory!.at(-1)!.stage, 'complete'); + if (kind === 'inspection') { + assert.equal(result.battle!.nexusInspection!.stage, 'answer'); + assert.equal(result.battle!.preparation!.kind, 'nexusPrescienceAnswer'); + const answered = applyAction(reload(result), f.reactor, { type: 'nexusPrescienceAnswer', + event: result.battle!.event, value: null }); + assert.equal(answered.battle!.nexusInspection!.stage, 'answered'); + } else if (kind === 'sardaukar') assert.equal(result.nexusSardaukarHistory!.at(-1)!.stage, 'active'); + else if (kind === 'advisors') { + assert.equal(result.nexusAdvisorHistory!.at(-1)!.stage, 'completed'); + assert.equal(Object.hasOwn(result.players[1].advisors ?? {}, 'pasty_mesa'), false); + } else assert.equal(result.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment'); + const canceled = applyAction(reload(declared), f.actor, { type: 'card', mode: 'cancel', card: f.original }); + assert.equal(canceled.pendingKull, null); + assert.equal(canceled.nexusChoamHistory!.at(-1)!.stage, 'canceled'); + assert.equal(canceled.discard.filter(card => card.id === f.reaction).length, 1); + if (kind === 'inspection') assert.equal(canceled.battle!.nexusInspection!.stage, 'canceled'); + else if (kind === 'sardaukar') assert.equal(canceled.nexusSardaukarHistory!.at(-1)!.stage, 'canceled'); + else if (kind === 'advisors') assert.equal(canceled.nexusAdvisorHistory!.at(-1)!.stage, 'canceled'); + else assert.equal(canceled.nexusGuildCunningHistory!.at(-1)!.stage, 'canceled'); + viewGame(reload(canceled), f.choam); + }); + +void test('a Karama-funded Nexus parent stays reserved in authoritative and projected BG counter overlays', () => { + const f = createNexusKullNestedParent(); + const offered = applyAction(f.game, f.actor, { type: 'card', mode: 'cancel', card: f.original }); + const declared = applyAction(offered, f.choam, { type: 'kullDecision', + event: offered.pendingKull!.event, source: 'printed', card: f.printed }); + const converted = applyAction(reload(declared), f.actor, { type: 'card', mode: 'cancel', card: f.bgCounter }); + assert.equal(converted.response?.kind, 'worthlessKarama'); + assert.equal(viewGame(reload(converted), f.choam).responseControls!.cancelCards.includes(f.fuel), false); + reject(converted, f.choam, { type: 'card', mode: 'cancel', card: f.fuel }); + const restored = normalizeAutomaticGame(reload(converted)); + assert.ok(held(restored, f.choam, f.fuel)); + assert.equal(restored.pendingKull!.worthless!.card, f.fuel); + const success = finish(reload(declared)); + assert.equal(success.pendingChoamWorthless?.nexusEvent, f.nexusEvent); + assert.deepEqual(success.response, f.game.response); +}); + +for (const source of ['printed', 'nexus'] as const) + void test(`${source} Kull keeps CHOAM's own winning payment covered while admitting other legitimate fuel`, () => { + const f = createNexusKullAuctionParent(); + const before = JSON.stringify(f.game); + const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'special', + card: f.reaction, target: f.actor, amount: 1 }); + assert.equal(JSON.stringify(f.game), before); + assert.equal(offered.pendingKull!.resume.decision?.kind, 'auctionPayment'); + const plays = viewGame(reload(offered), f.choam).kullReaction!.plays; + assert.equal(plays.some(play => play.card.id === f.payment), false); + assert.ok(plays.some(play => play.source === 'printed' && play.card.id === f.printed)); + assert.ok(plays.some(play => play.source === 'nexus' && play.card.id === f.fuel)); + reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, + source: 'nexus', card: f.payment }); + const declared = applyAction(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, + source, card: source === 'printed' ? f.printed : f.fuel }); + const success = finish(reload(declared)); + assert.equal(success.decision?.kind, 'auctionPayment'); + assert.ok(held(success, f.choam, f.payment)); + const paid = applyAction(reload(success), f.choam, { type: 'decision', karama: true, card: f.payment }); + assert.ok(held(paid, f.choam, f.lot)); + assert.equal(paid.discard.filter(card => card.id === f.payment).length, 1); + assert.equal(paid.players[0].spice, 1); + }); + +void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning behind its existing pre-offer guard', () => { + const f = createNexusChoamKullFixture({ actorFaction: 'fremen', fuel: 'weapon', counter: false }); + const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true }); + game.players[1].hand.push(takeKullCard(game, f.original)); + const counter = takeKullCard(game, 'karama'); + game.players[2].hand.push(counter); + const cards = game.nexusCards!.cards!, old = cards.hands[f.actor]; + const holder = game.players.find(p => cards.hands[p.id] === 'fremen'); + if (holder) cards.hands[holder.id] = old; + else { + const zone = cards.deck.includes('fremen') ? cards.deck : cards.discard; + const index = zone.indexOf('fremen'); + assert.ok(index >= 0); + zone.splice(index, 1); + if (old) cards.deck.push(old); + } + cards.hands[f.actor] = 'fremen'; + for (const p of game.players) { + p.forces = {}; p.reserves = 20; + if (p.elites) { p.elites.forces = {}; p.elites.reserves = 3; } + } + game.players[1].forces = { 'wind_pass:14': 2 }; game.players[1].reserves = 18; + Object.assign(game, { phase: 1, karamaShipping: null }); + game.nexusCards!.phase = createNexusCardPhase(game.turn); + const before = JSON.stringify(game); + const offered = applyAction(game, f.actor, { type: 'card', mode: 'special', + card: f.original, territory: 'hagga_basin' }); + assert.equal(JSON.stringify(game), before); + const summoned = applyAction(reload(offered), f.choam, { + type: 'kullDecision', event: offered.pendingKull!.event, decline: true }); + assert.equal(summoned.players[1].specialKaramaUsed, true); + assert.equal(summoned.nexusFremenCunningOffer ?? null, null); + assert.equal(summoned.nexusFremenCunningRides ?? null, null); + assert.equal(summoned.nexusCards!.cards!.hands[f.actor], 'fremen'); + assert.equal(summoned.discard.filter(card => card.id === f.original).length, 1); + reject(summoned, f.actor, { type: 'decision', event: 'unsupported-remote-ride', accept: true }); + viewGame(reload(summoned), f.actor); +}); + +void test('a canceled CHOAM-effect block does not remove ordinary Karama auction-payment coverage', () => { + const f = createNexusKullAuctionParent(); + f.game.choamWorthlessBlocked = { turn: f.game.turn, phase: f.game.phase, cards: [f.payment] }; + const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'special', + card: f.reaction, target: f.actor, amount: 1 }); + const plays = viewGame(reload(offered), f.choam).kullReaction!.plays; + assert.ok(plays.some(play => play.source === 'printed' && play.card.id === f.printed)); + assert.equal(plays.some(play => play.card.id === f.payment), false); + const declared = applyAction(offered, f.choam, { type: 'kullDecision', + event: offered.pendingKull!.event, source: 'printed', card: f.printed }); + const done = finish(reload(declared)); + assert.ok(held(done, f.choam, f.payment)); + const paid = applyAction(reload(done), f.choam, { type: 'decision', karama: true, card: f.payment }); + assert.ok(held(paid, f.choam, f.lot)); + assert.equal(paid.discard.filter(card => card.id === f.payment).length, 1); + assert.equal(paid.players[0].spice, 1); +}); diff --git a/tests/nexus-choam-kull-recovery.test.ts b/tests/nexus-choam-kull-recovery.test.ts new file mode 100644 index 00000000..53da54e9 --- /dev/null +++ b/tests/nexus-choam-kull-recovery.test.ts @@ -0,0 +1,873 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync, SQLOutputValue } from 'node:sqlite'; +import { viewGame, type Action, type Game, type GameView } from '../game/engine'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import type { RoomsClock, SeatAuth } from '../db/rooms'; +import type * as Rooms from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { createNexusChoamKullFixture, createNexusKullNativeParent, + createNexusKullNestedParent, createNexusKullAuctionParent } from './fixture-nexus-choam-kull'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +const plain = (value: T): T => JSON.parse(JSON.stringify(value)); +/** Malformed or forged client payloads used only to prove rejection; never valid actions. */ +const forged = (payload: object): Action => payload as Action; +const player = (g: Game, id: string) => { + const found = g.players.find(p => p.id === id); + assert.ok(found, `missing seated identity ${id}`); + return found; +}; +/** Offer-stage contract of the shared Nexus Kull fixture, named for authenticated SQL setup. */ +type NexusChoamKullOptions = Parameters[0]; +type NexusChoamKullGame = ReturnType; +type CredentialRows = { + seats: Record[]; + entries: Record[]; + keys: Record[]; + recoveries: Record[]; +}; + +function physical(g: Game) { + const ids = [ + ...g.deck, ...g.discard, ...(g.richeseCache ?? []), + ...g.players.flatMap(p => p.hand), + ...(g.auction?.cards.slice(g.auction.index) ?? []), + ].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'physical Treachery custody stays unique'); + return ids; +} +function nexusInventory(g: Game) { + const cards = g.nexusCards?.cards; + assert.ok(cards, 'the Nexus Kull table owns its physical Nexus inventory'); + validateNexusCards(cards, g.players); + assert.deepEqual([ + ...cards.deck, ...cards.discard, + ...Object.values(cards.hands).filter(card => card !== null), + ].sort(), [...NEXUS_FACTIONS].sort()); +} +function credentials(sqlite: DatabaseSync): CredentialRows { + return { + seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(), + entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(), + keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(), + recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(), + }; +} + +async function persisted(t: test.TestContext, options: NexusChoamKullOptions = {}, + create?: (seatIds: string[]) => NexusChoamKullGame): Promise { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('Nexus Kull SQL', 'choam', options.advanced ?? true, ['choam', 'ix']); + const code = made.view.code; + const tokens = [made.token]; + tokens.push((await store.rooms.joinRoom(code, 'Actor', options.actorFaction ?? (options.bg ? 'beneGesserit' : 'emperor'))).token!); + tokens.push((await store.rooms.joinRoom(code, 'Other', 'harkonnen')).token!); + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const ids = auths.map(auth => auth.playerId); + const madeGame: NexusChoamKullGame = create ? create(ids) : createNexusChoamKullFixture({ ...options, seatIds: ids }); + const initial = madeGame.game; + initial.code = code; + initial.host = auths[0].playerId; + initial.version = (await store.rooms.readRoom(code)).version; + const save = (g: Game) => assert.equal( + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code).changes, 1); + save(initial); + const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'atreides', false, []); + const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code); + const credentialRows = credentials(store.sqlite); + const stock = physical(initial); + nexusInventory(initial); + store.writes.length = 0; + return { + ...store, ...madeGame, initial, code, tokens, auths, save, stock, credentialRows, + unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow, + }; +} +interface Fixture extends NexusChoamKullGame { + rooms: typeof Rooms; + restart: () => typeof Rooms; + sqlite: DatabaseSync; + hooks: { beforeWrite?: () => Promise; beforeStatement?: (sql: string) => Promise }; + writes: { expected: number; changes: number }[]; + initial: Game; + code: string; + tokens: string[]; + auths: SeatAuth[]; + save(g: Game): void; + stock: string[]; + credentialRows: CredentialRows; + unrelatedCode: string; + unrelatedToken: string; + unrelatedRow: Record | undefined; +} + +const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code); +function protectedRows(f: Fixture) { + assert.deepEqual(credentials(f.sqlite), f.credentialRows, 'consumer credential rows are never rewritten'); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), + f.unrelatedRow, 'an unrelated room is never touched'); +} +function inventory(f: Fixture, g: Game) { + assert.deepEqual(physical(g), f.stock, 'the full physical Treachery inventory is conserved'); + nexusInventory(g); + protectedRows(f); +} +async function authenticate(f: Fixture, id: string) { + const index = f.auths.findIndex(auth => auth.playerId === id); + assert.ok(index >= 0, `no credential for ${id}`); + const auth = await f.restart().authenticate(f.code, f.tokens[index]); + // Authorization is the credential's values, not its realm, prototype or brand. + assert.equal(auth.playerId, id); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); + return auth; +} +async function act(f: Fixture, g: Game, id: string, action: Action) { + const rooms = f.restart(); + await rooms.act(f.code, await authenticate(f, id), g.version, action, clock); + return f.restart().readRoom(f.code); +} +async function restored(f: Fixture, g: Game) { + const before = row(f); + const writes = f.writes.length; + const views: GameView[] = []; + for (const [index, token] of f.tokens.entries()) { + const rooms = f.restart(); + const auth = await rooms.authenticate(f.code, token); + assert.equal(auth.playerId, f.auths[index].playerId); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId))); + assert.equal(view.nexusKullPreview, true); + for (const key of ['pendingKull', 'pendingChoamWorthless', 'nexusChoamHistory', 'nexusChoamLast']) + assert.equal(Object.hasOwn(view, key), false, 'intent, receipt and cursor stay private'); + for (const rival of view.players.filter(p => p.id !== auth.playerId)) { + assert.equal(Object.hasOwn(rival, 'hand'), false); + assert.equal(Object.hasOwn(rival, 'spice'), false); + } + views.push(view); + } + await f.restart().continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before, 'refresh or recovery cannot acknowledge a human opportunity'); + assert.equal(f.writes.length, writes); + inventory(f, g); + return views; +} +async function stationary(f: Fixture, g: Game) { + const before = row(f); + const writes = f.writes.length; + await f.restart().continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, writes); + await restored(f, g); +} +async function rejected(f: Fixture, g: Game, id: string, action: Action, version = g.version) { + const before = row(f); + const attempted: string[] = []; + f.hooks.beforeStatement = async sql => { attempted.push(sql); }; + try { + await assert.rejects(f.restart().act(f.code, await authenticate(f, id), version, action, clock)); + } finally { delete f.hooks.beforeStatement; } + assert.deepEqual(attempted, [], 'a rejected Kull choice must not attempt a room write'); + assert.deepEqual(row(f), before); + protectedRows(f); +} +async function settle(f: Fixture, g: Game) { + for (let step = 0; g.response && step < 16; step++) { + const response = g.response; + const responder = g.players.find(p => !response.passed.includes(p.id)); + assert.ok(responder, 'an open response retains an unpassed real seat'); + g = await act(f, g, responder.id, { type: 'passResponse' }); + await restored(f, g); + } + assert.equal(g.response, null); + return g; +} +async function race(f: Fixture, g: Game, id: string, first: Action, second: Action) { + f.writes.length = 0; + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; + const auth = await authenticate(f, id); + let results: PromiseSettledResult[]; + try { + // Both competitors park at the version fence, then commit independently. + results = await Promise.allSettled([ + f.rooms.act(f.code, auth, g.version, first, clock).finally(release), + f.restart().act(f.code, auth, g.version, second, clock).finally(release), + ]); + } finally { delete f.hooks.beforeWrite; } + assert.equal(arrivals, 2, 'both competitors reached the version fence'); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, g.version + 1, 'exactly one competitor commits'); + await restored(f, done); + return done; +} + +for (const advanced of [false, true]) + for (const fuel of ['karama', 'weapon', 'worthless'] as const) + void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} Nexus Kull spends its exact held ${fuel} fuel and the physical CHOAM Nexus once, then bans the interrupted Karama`, async t => { + const f = await persisted(t, { advanced, fuel }); + const { choam, actor, event, original, fuel: fuelCard, counter } = f; + assert.equal(f.initial.pendingKull?.stage, 'offer'); + assert.equal(f.initial.pendingKull?.event, event); + assert.equal(player(f.initial, actor).hand.some(card => card.id === original), true); + assert.equal(f.initial.nexusCards!.cards!.hands[choam], 'choam'); + assert.equal(player(f.initial, choam).hand.some(card => card.id === fuelCard), true); + // Canonical base plus Ix holds two Karama: one original and one counter at most. + const heldCounter = counter !== null; + if (heldCounter) assert.notEqual(counter, original); + + const views = await restored(f, f.initial); + const reaction = views[0].kullReaction; + assert.ok(reaction); + assert.deepEqual({ event: reaction.event, player: reaction.player, target: reaction.target, intent: reaction.intent }, + { event, player: choam, target: actor, intent: 'shipment' }); + assert.deepEqual(reaction.plays.map(play => ({ source: play.source, effect: play.effect, card: play.card.id, event: play.event, blocked: play.blocked })), + [{ source: 'nexus', effect: 'kull', card: fuelCard, event, blocked: null }]); + assert.equal(reaction.canDecline, true); + for (const view of views.slice(1)) + assert.deepEqual(view.kullReaction, + { event, player: choam, target: actor, intent: 'shipment', plays: [], canDecline: false, blocked: null }); + await stationary(f, f.initial); + + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuelCard }); + assert.equal(declared.version, f.initial.version + 1); + const nexusEvent = JSON.stringify(['nexusChoam', declared.turn, declared.phase, choam, fuelCard, 'kull']); + // Without a distinguishable held Karama nobody can prevent it, so the + // real window resolves inside the accepted declaration instead. + assert.equal(declared.response === null, !heldCounter); + if (heldCounter) { + assert.equal(declared.pendingKull?.stage, 'counter'); + assert.deepEqual(declared.pendingKull?.selection, { source: 'nexus', card: fuelCard, nexusEvent }); + assert.deepEqual(declared.pendingChoamWorthless, { + owner: choam, card: fuelCard, effect: 'kull', target: actor, revival: false, nexusEvent, + }); + assert.equal(declared.response?.kind, 'choamWorthless'); + assert.equal(declared.response?.intent, 'Kull Wahad'); + assert.equal(declared.response?.owner, choam); + assert.equal(declared.response?.recipient, actor); + assert.equal(player(declared, choam).hand.some(card => card.id === fuelCard), true, + 'the declared fuel is only retained while the response is pending'); + assert.equal(player(declared, actor).hand.some(card => card.id === original), true, + 'the interrupted card stays reserved rather than spent'); + assert.equal(declared.discard.some(card => card.id === original), false); + assert.equal(declared.nexusChoamHistory?.length, 1); + assert.equal(declared.nexusChoamHistory![0].stage, 'pending'); + assert.deepEqual(declared.nexusChoamLast, { event: nexusEvent, stage: 'pending' }); + await restored(f, declared); + await stationary(f, declared); + } + assert.equal(declared.nexusCards!.cards!.hands[choam], null); + assert.deepEqual(declared.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'], + 'the declared Nexus is spent on acceptance'); + assert.equal(declared.nexusChoamHistory![0].receipt.card, fuelCard); + assert.equal(declared.nexusChoamHistory![0].receipt.effect, 'kull'); + assert.equal(declared.nexusChoamHistory![0].receipt.owner, choam); + + const done = heldCounter ? await settle(f, declared) : declared; + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.pendingChoamWorthless ?? null, null); + assert.equal(done.response, null); + assert.equal(done.discard.filter(card => card.id === fuelCard).length, 1, 'the chosen fuel is discarded exactly once'); + assert.equal(player(done, choam).hand.some(card => card.id === fuelCard), false); + assert.equal(done.discard.some(card => card.id === original), false, 'the interrupted card is not discarded'); + assert.equal(player(done, actor).hand.filter(card => card.id === original).length, 1); + assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'], + 'the Nexus is not returned or double spent'); + assert.equal(done.nexusChoamHistory?.length, 1); + assert.equal(done.nexusChoamHistory![0].stage, 'complete'); + assert.deepEqual(done.nexusChoamLast, { event: nexusEvent, stage: 'complete' }); + assert.deepEqual((done.kullRestrictions ?? []).filter(rule => rule.player === actor), + [{ player: actor, turn: done.turn, phase: done.phase }]); + assert.ok(viewGame(done, actor).karamaBlocked, 'the stamped phase ban is publicly visible'); + assert.equal(viewGame(done, choam).kullReaction, null); + await restored(f, done); + + await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuelCard }); + await rejected(f, done, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); + await stationary(f, done); + }); + +for (const advanced of [false, true]) + void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} distinct held Karama prevents the declared Nexus Kull, retains the fuel and leaves the spent Nexus spent while resuming the original attempt once`, async t => { + const f = await persisted(t, { advanced }); + const { choam, actor, other, event, original, fuel, counter } = f; + assert.ok(counter && other); + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + const views = await restored(f, declared); + assert.deepEqual(views[2].responseControls?.cancelCards, [counter], + 'only the physically distinct held Karama may prevent the declaration'); + assert.equal(views[2].responseControls?.hasPassed, false); + assert.equal((views[1].responseControls?.cancelCards ?? []).includes(original), false, + 'the reserved original cannot pay for its own prevention'); + assert.equal(views[0].responseControls?.cancelCards.length, 0); + assert.equal(views[1].players.find(p => p.id === choam)?.hand, undefined, 'the declared fuel stays private to CHOAM'); + assert.equal(views[1].kullReaction ?? null, null); + await stationary(f, declared); + + await rejected(f, declared, actor, { type: 'card', mode: 'cancel', card: original }); + await rejected(f, declared, choam, { type: 'card', mode: 'cancel', card: fuel }); + await rejected(f, declared, other, { type: 'card', mode: 'cancel', card: `${counter}-stale` }); + + const done = await race(f, declared, other, + { type: 'card', mode: 'cancel', card: counter }, + { type: 'card', mode: 'cancel', card: counter }); + assert.equal(done.response ?? null, null); + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.karamaShipping?.card, original, 'the interrupted shipment resumes exactly once'); + assert.equal(done.karamaShipping?.owner, actor); + assert.equal(done.discard.filter(card => card.id === original).length, 1); + assert.equal(done.discard.filter(card => card.id === counter).length, 1); + assert.equal(player(done, choam).hand.some(card => card.id === fuel), true, + 'a prevented declaration retains its declared fuel'); + assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'], + 'a canceled declaration leaves the accepted Nexus spend spent'); + assert.equal(done.nexusChoamHistory?.length, 1); + assert.equal(done.nexusChoamHistory![0].stage, 'canceled'); + assert.equal(done.nexusChoamLast?.stage, 'canceled'); + assert.deepEqual(done.kullRestrictions ?? [], [], 'a prevented declaration bans no Karama phase'); + await restored(f, done); + + await rejected(f, done, other, { type: 'card', mode: 'cancel', card: counter }); + await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); + await stationary(f, done); + }); + +void test('SQLite declining the offered Nexus Kull spends neither fuel nor Nexus and resumes the exact original attempt once', async t => { + const f = await persisted(t); + const { choam, actor, event, original, fuel } = f; + await restored(f, f.initial); + const done = await act(f, f.initial, choam, { type: 'kullDecision', event, decline: true }); + assert.equal(done.response ?? null, null); + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.nexusChoamHistory ?? null, null); + assert.equal(done.nexusChoamLast ?? null, null); + assert.equal(done.nexusCards!.cards!.hands[choam], 'choam', 'a declined offer spends no Nexus'); + assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false); + assert.equal(player(done, choam).hand.some(card => card.id === fuel), true); + assert.equal(done.discard.some(card => card.id === fuel), false, 'a declined offer discards no fuel'); + assert.equal(done.karamaShipping?.card, original, 'the interrupted shipment resumes exactly once'); + assert.equal(done.discard.filter(card => card.id === original).length, 1); + assert.deepEqual(done.kullRestrictions ?? [], []); + await restored(f, done); + + await rejected(f, done, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); + await stationary(f, done); +}); + +void test('SQLite a successful Nexus Kull intercepts the BG Worthless substitution before conversion and keeps every attempted physical card', async t => { + const f = await persisted(t, { bg: true }); + const { choam, actor, event, original, fuel } = f; + assert.equal(f.initial.pendingKull?.form, 'substitution'); + assert.equal(f.initial.pendingKarama ?? null, null); + assert.equal(player(f.initial, actor).hand.some(card => card.id === original), true); + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + assert.equal(declared.pendingKarama ?? null, null, 'the Kull counter window precedes BG conversion'); + assert.equal(player(declared, actor).hand.some(card => card.id === original), true); + assert.equal(declared.discard.some(card => card.id === original), false); + await restored(f, declared); + + const done = await settle(f, declared); + assert.equal(done.pendingKarama ?? null, null); + assert.equal(done.karamaShipping ?? null, null); + assert.deepEqual(player(done, actor).hand, player(f.initial, actor).hand, + 'the intercepted BG Worthless card is never converted or discarded'); + assert.equal(done.discard.some(card => card.id === original), false); + assert.equal(done.discard.filter(card => card.id === fuel).length, 1); + assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam']); + assert.equal(done.nexusChoamLast?.stage, 'complete'); + await restored(f, done); + await stationary(f, done); + await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); +}); + +void test('SQLite declining the offered Nexus Kull resumes the real BG conversion and shipment without repeated disposal', async t => { + const f = await persisted(t, { bg: true }); + const { choam, actor, event, original, fuel } = f; + const declined = await act(f, f.initial, choam, { type: 'kullDecision', event, decline: true }); + assert.equal(declined.pendingKull ?? null, null); + assert.equal(declined.nexusCards!.cards!.discard.includes('choam'), false, 'a declined offer spends no Nexus'); + assert.equal(declined.response?.kind, 'worthlessKarama'); + assert.ok(declined.pendingKarama, 'declining resumes the real Worthless conversion'); + assert.equal(declined.discard.filter(card => card.id === original).length, 1); + assert.equal(player(declined, actor).hand.some(card => card.id === original), false); + await restored(f, declined); + + const done = await settle(f, declined); + assert.equal(done.pendingKarama ?? null, null); + assert.equal(done.karamaShipping?.card, original); + assert.equal(done.karamaShipping?.owner, actor); + assert.equal(done.discard.filter(card => card.id === original).length, 1, 'the conversion disposes the card only once'); + assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false); + assert.equal(done.nexusChoamHistory ?? null, null); + await restored(f, done); + + await rejected(f, done, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await stationary(f, done); +}); + +void test('SQLite stale, foreign, malformed and replayed Nexus Kull choices reject before writes and preserve real credentials', async t => { + const f = await persisted(t); + const { choam, actor, other, event, original, fuel } = f; + assert.ok(other); + const before = row(f); + const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken); + await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken)); + await assert.rejects(f.restart().readSeatView(f.code, foreign)); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + protectedRows(f); + + await rejected(f, f.initial, actor, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, f.initial, other, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }, f.initial.version - 1); + await rejected(f, f.initial, choam, { type: 'kullDecision', event: `${event}-stale`, source: 'nexus', card: fuel }); + await rejected(f, f.initial, choam, { type: 'kullDecision', event, decline: true }, f.initial.version - 1); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, card: fuel })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'printed', card: fuel })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: original })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: `${fuel}-forged` })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: fuel, target: other })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: fuel, effect: 'kulon' })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, decline: true, card: fuel })); + await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, decline: true, source: 'nexus' })); + await rejected(f, f.initial, actor, { type: 'card', mode: 'special', card: original, amount: 2 }); + + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await restored(f, declared); + await rejected(f, declared, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, declared, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, declared, other, forged({ type: 'card', mode: 'cancel', card: original })); +}); + +void test('SQLite competing decline and Nexus declaration of one saved Kull offer commit exactly one original outcome', async t => { + const f = await persisted(t); + const { choam, actor, event, original, fuel } = f; + await restored(f, f.initial); + const done = await race(f, f.initial, choam, + { type: 'kullDecision', event, decline: true }, + { type: 'kullDecision', event, source: 'nexus', card: fuel }); + const declared = done.nexusChoamHistory !== undefined; + if (declared) { + assert.equal(done.pendingKull?.stage, 'counter'); + assert.deepEqual(done.pendingKull?.selection, + { source: 'nexus', card: fuel, nexusEvent: done.nexusChoamLast!.event }); + assert.equal(done.response?.kind, 'choamWorthless'); + assert.equal(player(done, choam).hand.some(card => card.id === fuel), true); + assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam']); + assert.equal(done.nexusChoamLast?.stage, 'pending'); + } else { + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.pendingChoamWorthless ?? null, null); + assert.equal(done.karamaShipping?.card, original); + assert.equal(done.nexusCards!.cards!.hands[choam], 'choam'); + assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false); + assert.equal(done.nexusChoamLast ?? null, null); + } + assert.equal(done.discard.some(card => card.id === original), !declared); + // A declaration reserves the original in hand; a decline resumes and spends it. + assert.equal(player(done, actor).hand.some(card => card.id === original), declared); + await restored(f, done); + await stationary(f, done); +}); + +void test('SQLite corrupt or orphaned saved Nexus Kull offers reject all-seat disclosure and settlement before writes', async t => { + const f = await persisted(t); + const { choam, actor, other, event, original, fuel } = f; + assert.ok(other); + const mutations: { name: string; change(this: void, g: Game): void }[] = [ + { name: 'forged opportunity signature', change: g => { g.pendingKull!.signature = 'forged'; } }, + { name: 'detached opportunity event', change: g => { g.pendingKull!.event += ':stale'; } }, + { name: 'unknown original card', change: g => { g.pendingKull!.card = g.deck[0].id; } }, + { name: 'foreign interrupted owner', change: g => { g.pendingKull!.owner = other; } }, + { name: 'foreign reacting faction', change: g => { g.pendingKull!.player = other; } }, + { name: 'changed opportunity turn', change: g => { g.pendingKull!.turn++; } }, + { name: 'wrong printed form', change: g => { g.pendingKull!.form = 'substitution'; } }, + { name: 'lost sequence', change: g => { g.kullSequence = 0; } }, + { name: 'disabled preview', change: g => { g.kullPreview = false; } }, + { name: 'duplicated original custody', change: g => { g.deck.push(structuredClone(player(g, actor).hand[0])); } }, + { name: 'relocated original custody', change: g => { + const owner = player(g, actor); + const index = owner.hand.findIndex(card => card.id === original); + assert.ok(index >= 0); + g.deck.push(...owner.hand.splice(index, 1)); + } }, + ]; + for (const { name, change } of mutations) { + const corrupt = structuredClone(f.initial); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const saved = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name); + await rejected(f, corrupt, choam, { type: 'kullDecision', event: corrupt.pendingKull?.event ?? event, decline: true }); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, corrupt, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), saved, name); + assert.equal(f.writes.length, 0, name); + protectedRows(f); + } +}); + +void test('SQLite corrupt or orphaned saved Nexus Kull declarations, receipts and cursors reject all-seat disclosure and settlement before writes', async t => { + const f = await persisted(t); + const { choam, actor, other, event, fuel, counter } = f; + assert.ok(counter && other); + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await restored(f, declared); + const mutations: { name: string; change(this: void, g: Game): void }[] = [ + { name: 'orphaned declaration without its frame', change: g => { delete g.pendingKull; } }, + { name: 'undeclared selection', change: g => { delete g.pendingKull!.selection; } }, + { name: 'forged counter signature', change: g => { g.pendingKull!.signature += 'x'; } }, + { name: 'detached receipt event', change: g => { delete g.pendingChoamWorthless!.nexusEvent; } }, + { name: 'changed declared card', change: g => { g.pendingChoamWorthless!.card = g.deck[0].id; } }, + { name: 'foreign declaration owner', change: g => { g.pendingChoamWorthless!.owner = other; } }, + { name: 'lost declaration', change: g => { delete g.pendingChoamWorthless; } }, + { name: 'premature completion', change: g => { g.nexusChoamHistory![0].stage = 'complete'; } }, + { name: 'lost history with a saved cursor', change: g => { delete g.nexusChoamHistory; } }, + { name: 'changed receipt card', change: g => { g.nexusChoamHistory![0].receipt.card = g.deck[0].id; } }, + { name: 'forged receipt signature', change: g => { g.nexusChoamHistory![0].signature = 'forged'; } }, + { name: 'changed Nexus turn', change: g => { g.turn++; } }, + { name: 'relocated declared fuel custody', change: g => { + const owner = player(g, choam); + const index = owner.hand.findIndex(card => card.id === fuel); + assert.ok(index >= 0); + g.deck.push(...owner.hand.splice(index, 1)); + } }, + { name: 'disabled Nexus preview', change: g => { g.nexusKullPreview = false; } }, + ]; + for (const { name, change } of mutations) { + const corrupt = structuredClone(declared); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const saved = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, corrupt, other, { type: 'card', mode: 'cancel', card: counter }); + await rejected(f, corrupt, actor, { type: 'passResponse' }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), saved, name); + assert.equal(f.writes.length, 0, name); + protectedRows(f); + } +}); + +void test('SQLite completed Nexus Kull history stays intrinsic after later physical card movement but not after deleted or corrupted evidence', async t => { + const f = await persisted(t); + const { choam, actor, event, original, fuel } = f; + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + const done = await settle(f, declared); + assert.equal(done.nexusChoamLast?.stage, 'complete'); + const nexusEvent = done.nexusChoamLast!.event; + + const later = structuredClone(done); + const fuelIndex = later.discard.findIndex(card => card.id === fuel); + assert.ok(fuelIndex >= 0); + later.deck.push(...later.discard.splice(fuelIndex, 1)); + const cards = later.nexusCards!.cards!; + const nexusIndex = cards.discard.indexOf('choam'); + assert.ok(nexusIndex >= 0); + cards.deck.push(...cards.discard.splice(nexusIndex, 1)); + f.save(later); + f.writes.length = 0; + const views = await restored(f, later); + assert.equal(views[0].kullReaction ?? null, null, 'a settled opportunity is never reopened by recycling'); + assert.ok(views[1].karamaBlocked, 'the stamped phase ban survives the later card movement'); + assert.equal(later.nexusChoamHistory!.length, 1, 'history never duplicates an event'); + assert.equal(later.nexusChoamHistory![0].stage, 'complete'); + assert.deepEqual(later.nexusChoamLast, { event: nexusEvent, stage: 'complete' }); + assert.equal(later.deck.filter(card => card.id === fuel).length, 1, + 'later recycling moves the spent fuel but cannot invalidate the intrinsic receipt'); + await rejected(f, later, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + await rejected(f, later, choam, { type: 'kullDecision', event, decline: true }); + await rejected(f, later, actor, { type: 'card', mode: 'shipment', card: original, target: actor }); + + for (const change of [ + (g: Game) => { delete g.nexusChoamHistory; }, + (g: Game) => { g.nexusChoamHistory![0].stage = 'pending'; }, + (g: Game) => { g.nexusChoamHistory![0].receipt.card = g.deck[0].id; }, + (g: Game) => { g.nexusChoamLast!.event += ':stale'; }, + (g: Game) => { delete g.nexusChoamLast; }, + ]) { + const corrupt = structuredClone(later); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const saved = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + assert.deepEqual(row(f), saved); + assert.equal(f.writes.length, 0); + protectedRows(f); + } +}); + +void test('SQLite a new Nexus-profile printed counter without a selection is corrupt, not a pre-upgrade save', async t => { + const f = await persisted(t); + const { choam, actor, event } = f; + const beforeDeclaration = structuredClone(f.initial); + const cost = beforeDeclaration.deck.find(card => card.id === 'ix-kull-wahad')!; + assert.ok(cost); + beforeDeclaration.deck.splice(beforeDeclaration.deck.indexOf(cost), 1); + player(beforeDeclaration, choam).hand.push(cost); + f.save(beforeDeclaration); + const declared = await act(f, beforeDeclaration, choam, + { type: 'kullDecision', event, source: 'printed', card: cost.id }); + const corrupt = structuredClone(declared); + const signature: Record = JSON.parse(corrupt.pendingKull!.signature); + delete signature.selection; + delete corrupt.pendingKull!.selection; + corrupt.pendingKull!.signature = JSON.stringify(signature); + f.save(corrupt); + f.writes.length = 0; + const before = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId))); + await rejected(f, corrupt, actor, { type: 'passResponse' }); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'printed', card: cost.id }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + inventory(f, corrupt); +}); + +void test('SQLite independently invented or altered Treachery descriptors never become Nexus Kull fuel', async t => { + const f = await persisted(t, { fuel: 'weapon' }); + const { choam, event, fuel } = f; + const mutations: { name: string; change(this: void, g: Game): string }[] = [ + { name: 'invented unique identifier', change: g => { + const card = player(g, choam).hand.find(card => card.id === fuel)!; + card.id = 'counterfeit-unique-treachery'; + return card.id; + } }, + { name: 'altered canonical name', change: g => { + player(g, choam).hand.find(card => card.id === fuel)!.name = 'Invented Hunter Seeker'; + return fuel; + } }, + { name: 'altered canonical kind', change: g => { + player(g, choam).hand.find(card => card.id === fuel)!.kind = 'worthless'; + return fuel; + } }, + { name: 'altered canonical effect', change: g => { + player(g, choam).hand.find(card => card.id === fuel)!.effect = 'karama'; + return fuel; + } }, + ]; + for (const { name, change } of mutations) { + const corrupt = structuredClone(f.initial); + const id = change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const before = row(f); + const view = await f.restart().readSeatView(f.code, await authenticate(f, choam)); + assert.equal(view.kullReaction?.plays.some(play => play.card.id === id), false, name); + await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: id }); + assert.deepEqual(row(f), before, name); + assert.equal(f.writes.length, 0, name); + protectedRows(f); + } +}); + +void test('SQLite invented or altered canonical selected fuel cannot project or settle a restored Nexus Kull declaration', async t => { + const f = await persisted(t, { fuel: 'weapon' }); + const { choam, actor, other, event, fuel, counter } = f; + assert.ok(other && counter); + const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + const mutations: { name: string; change(this: void, g: Game): void }[] = [ + { name: 'invented unique identifier', change: g => { + // Deliberate forged evidence keeps all event/cursor references aligned so + // independent physical admission, rather than a detached receipt, rejects it. + Object.assign(g, JSON.parse(JSON.stringify(g).replaceAll(fuel, 'counterfeit-unique-treachery'))); + } }, + { name: 'name', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.name += ' forged'; } }, + { name: 'kind', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.kind = 'worthless'; } }, + { name: 'effect', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.effect = 'karama'; } }, + ]; + for (const { name, change } of mutations) { + const corrupt = structuredClone(declared); + change(corrupt); + f.save(corrupt); + f.writes.length = 0; + const before = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name); + await rejected(f, corrupt, actor, { type: 'passResponse' }); + await rejected(f, corrupt, other, { type: 'card', mode: 'cancel', card: counter }); + await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(row(f), before, name); + assert.equal(f.writes.length, 0, name); + const expected = name === 'invented unique identifier' + ? f.stock.map(id => id === fuel ? 'counterfeit-unique-treachery' : id).sort() : f.stock; + assert.deepEqual(physical(corrupt), expected); + nexusInventory(corrupt); + protectedRows(f); + } +}); + +for (const outcome of ['success', 'prevention', 'decline'] as const) + void test(`SQLite native Atreides Nexus inspection survives a real suspended Kull ${outcome} and resumes its exact continuation after restart`, async t => { + let native!: NexusChoamKullGame & { reactor: string; reaction: string }; + const f = await persisted(t, { actorFaction: 'atreides', fuel: 'weapon' }, ids => { + native = createNexusKullNativeParent('inspection', ids); + return native; + }); + const { choam, actor, fuel } = f; + const { reactor, reaction } = native; + const parentResponse = plain(f.initial.response); + const parentInspection = plain(f.initial.battle!.nexusInspection); + const firstAnswer = plain(f.initial.battle!.prescience); + assert.equal(parentResponse?.kind, 'nexusPrescience'); + await restored(f, f.initial); + const offered = await act(f, f.initial, reactor, { type: 'card', mode: 'cancel', card: reaction }); + const event = offered.pendingKull!.event; + assert.deepEqual(offered.pendingKull!.resume.response, parentResponse); + const offerViews = await restored(f, offered); + assert.equal(offerViews[0].kullReaction?.event, event); + let done: Game; + if (outcome === 'decline') { + done = await act(f, offered, choam, { type: 'kullDecision', event, decline: true }); + } else { + const declared = await act(f, offered, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }); + assert.deepEqual(declared.pendingKull!.resume.response, parentResponse); + assert.deepEqual(declared.battle!.nexusInspection, parentInspection); + assert.deepEqual(declared.battle!.prescience, firstAnswer); + await restored(f, declared); + await stationary(f, declared); + if (outcome === 'prevention') { + done = await act(f, declared, actor, { type: 'card', mode: 'cancel', card: f.original }); + } else { + done = await settle(f, declared); + assert.equal(player(done, reactor).hand.some(card => card.id === reaction), true); + assert.equal(done.discard.filter(card => card.id === fuel).length, 1); + assert.equal(done.nexusChoamLast?.stage, 'complete'); + assert.equal(done.battle!.nexusInspection?.stage, 'answer'); + done = await act(f, done, reactor, { type: 'nexusPrescienceAnswer', event: done.battle!.event, value: null }); + assert.deepEqual(done.battle!.nexusInspection?.answers, [null]); + } + } + if (outcome !== 'success') { + assert.equal(done.battle!.nexusInspection?.stage, 'canceled'); + assert.equal(done.discard.filter(card => card.id === reaction).length, 1); + assert.equal(player(done, choam).hand.some(card => card.id === fuel), true); + assert.equal(done.discard.some(card => card.id === fuel), false); + if (outcome === 'prevention') { + assert.equal(done.discard.filter(card => card.id === f.original).length, 1); + assert.equal(done.nexusChoamLast?.stage, 'canceled'); + } else { + assert.equal(done.nexusCards!.cards!.hands[choam], 'choam'); + assert.equal(done.nexusChoamLast ?? null, null); + } + } + assert.equal(done.pendingKull ?? null, null); + assert.deepEqual(done.battle!.prescience, firstAnswer); + assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'atreides').length, 1); + await restored(f, done); + await rejected(f, done, choam, { type: 'kullDecision', event, decline: true }); + inventory(f, done); + }); + +void test('SQLite a suspended native CHOAM Karama fuel is never offered to cancel a nested BG Kull counter', async t => { + let nested!: NexusChoamKullGame & { printed: string; bgCounter: string; nexusEvent: string }; + const f = await persisted(t, { bg: true, fuel: 'karama' }, ids => { + nested = createNexusKullNestedParent(ids); + return nested; + }); + const { choam, actor, original, fuel } = f; + const retainedFuel = plain(player(f.initial, choam).hand.find(card => card.id === fuel)); + const parent = plain(f.initial.pendingChoamWorthless); + await restored(f, f.initial); + const offered = await act(f, f.initial, actor, { type: 'card', mode: 'cancel', card: original }); + const event = offered.pendingKull!.event; + const declared = await act(f, offered, choam, + { type: 'kullDecision', event, source: 'printed', card: nested.printed }); + assert.deepEqual(declared.pendingKull!.worthless, parent); + await restored(f, declared); + const converted = await act(f, declared, actor, { type: 'card', mode: 'cancel', card: nested.bgCounter }); + assert.equal(converted.response?.kind, 'worthlessKarama'); + assert.equal(converted.pendingKarama?.use.kind, 'cancel'); + const views = await restored(f, converted); + assert.equal(views[0].responseControls?.cancelCards.includes(fuel), false); + assert.equal(views[0].responseControls?.cancelCards.includes(nested.printed), false); + assert.deepEqual(player(converted, choam).hand.find(card => card.id === fuel), retainedFuel); + await rejected(f, converted, choam, { type: 'card', mode: 'cancel', card: fuel }); + await stationary(f, converted); + const done = await settle(f, converted); + assert.equal(done.pendingKull ?? null, null); + assert.equal(done.pendingKarama ?? null, null); + assert.equal(done.pendingChoamWorthless ?? null, null); + assert.equal(done.nexusChoamLast?.event, nested.nexusEvent); + assert.equal(done.nexusChoamLast?.stage, 'canceled'); + assert.deepEqual(player(done, choam).hand.find(card => card.id === fuel), retainedFuel); + assert.equal(done.discard.some(card => card.id === fuel), false); + assert.equal(player(done, choam).hand.filter(card => card.id === nested.printed).length, 1); + assert.equal(done.discard.filter(card => card.id === nested.bgCounter).length, 1); + assert.equal(done.discard.filter(card => card.id === original).length, 1); + assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1); + await restored(f, done); + await rejected(f, done, choam, { type: 'kullDecision', event, decline: true }); +}); + +for (const source of ['printed', 'nexus'] as const) + void test(`SQLite ${source} Kull keeps CHOAM's last winning-payment Karama reserved and the original auction payable after restart`, async t => { + let auction!: NexusChoamKullGame & { payment: string; printed: string; reactor: string; reaction: string; lot: string }; + const f = await persisted(t, { fuel: 'weapon' }, ids => { + auction = createNexusKullAuctionParent(ids); + return auction; + }); + const { choam, actor, fuel } = f; + const { payment, reactor, reaction, lot, printed } = auction; + assert.equal(f.initial.decision?.kind, 'auctionPayment'); + assert.equal(f.initial.auction?.bidder, choam); + assert.equal(f.initial.auction?.bid, 2); + assert.equal(player(f.initial, choam).spice, 1); + await restored(f, f.initial); + const offered = await act(f, f.initial, reactor, + { type: 'card', mode: 'special', card: reaction, target: actor, amount: 1 }); + const event = offered.pendingKull!.event; + const views = await restored(f, offered); + assert.equal(views[0].kullReaction?.plays.some(play => play.card.id === payment), false); + assert.ok(views[0].kullReaction?.plays.some(play => play.source === 'printed' && play.card.id === printed)); + assert.ok(views[0].kullReaction?.plays.some(play => play.source === 'nexus' && play.card.id === fuel)); + await rejected(f, offered, choam, { type: 'kullDecision', event, source: 'nexus', card: payment }); + const cost = source === 'printed' ? printed : fuel; + const declared = await act(f, offered, choam, { type: 'kullDecision', event, source, card: cost }); + await restored(f, declared); + await stationary(f, declared); + const prevented = await settle(f, declared); + assert.deepEqual(prevented.decision, { kind: 'auctionPayment', player: choam }); + assert.equal(prevented.auction?.bid, 2); + assert.equal(prevented.auction?.cards[prevented.auction.index].id, lot); + assert.equal(player(prevented, choam).spice, 1); + assert.equal(player(prevented, choam).hand.filter(card => card.id === payment).length, 1); + assert.equal(player(prevented, reactor).hand.filter(card => card.id === reaction).length, 1); + assert.equal(prevented.discard.filter(card => card.id === cost).length, 1); + const paid = await act(f, prevented, choam, { type: 'decision', karama: true }); + const done = await settle(f, paid); + assert.notEqual(done.decision?.kind, 'auctionPayment'); + assert.equal(player(done, choam).hand.filter(card => card.id === lot).length, 1); + assert.equal(done.discard.filter(card => card.id === payment).length, 1); + assert.equal(done.discard.filter(card => card.id === cost).length, 1); + assert.equal(player(done, choam).spice, 1); + assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, source === 'nexus' ? 1 : 0); + await restored(f, done); + await rejected(f, done, choam, { type: 'kullDecision', event, source, card: cost }); + inventory(f, done); + }); diff --git a/tests/nexus-choam.test.ts b/tests/nexus-choam.test.ts index 2ca05eb9..64f41866 100644 --- a/tests/nexus-choam.test.ts +++ b/tests/nexus-choam.test.ts @@ -1,7 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamContext, @@ -19,18 +18,7 @@ const context: NexusChoamContext = { }; const clone = (value: T): T => JSON.parse(JSON.stringify(value)); -void test('CHOAM Nexus records all six printed effects with their distinct phase windows', () => { - assert.deepEqual( - Object.values(CHOAM_NEXUS_EFFECTS).sort(), - [ - 'Baliset', - 'Jubba Cloak', - 'Kull Wahad', - 'Kulon', - 'La La La', - 'Trip to Gamont', - ].sort(), - ); +void test('CHOAM Nexus receipts enforce effect timing while leaving Kull response authority to the caller', () => { for (const [effect, phase] of [ ['kulon', 5], ['laLaLa', 4], @@ -216,3 +204,35 @@ void test('CHOAM receipt creation rejects malformed or foreign inputs before pro ); assert.deepEqual(context, before); }); + +void test('self-consistent saved Kull receipts still reject invalid intrinsic cost, owner and stamps', () => { + const receipt = createNexusChoam(context, 'c', 5, 'physical-weapon-1', 'kull'); + const patches: Partial[] = [ + { card: '' }, + { card: ' ' }, + { card: '__proto__' }, + { owner: 'a' }, + { owner: 'missing' }, + { turn: 0 }, + { turn: context.turn + 1 }, + { turn: 1.5 }, + { phase: -1 }, + { phase: 9 }, + { phase: 5.5 }, + { effect: 'unknown' as NexusChoamEffect }, + ]; + for (const patch of patches) { + const invalid = { ...clone(receipt), ...patch }; + invalid.event = JSON.stringify([ + 'nexusChoam', invalid.turn, invalid.phase, invalid.owner, invalid.card, invalid.effect, + ]); + invalid.signature = JSON.stringify([ + invalid.version, invalid.event, invalid.turn, invalid.phase, + invalid.owner, invalid.card, invalid.effect, + invalid.roster.map(player => [player.id, player.faction]), + ]); + const before = clone(invalid); + assert.throws(() => validateNexusChoam(context, invalid)); + assert.deepEqual(invalid, before); + } +}); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 8777e621..1ed6d0dd 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -12,6 +12,7 @@ import { startIxPrototypeRoom, startPrototypeRoom, } from '../tools/prototype-room'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; function fixture(t: test.TestContext) { const db = new DatabaseSync(':memory:'); @@ -301,3 +302,30 @@ void test('Richese Betrayal profile initializes one fresh paired Nexus game and assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); }); + +for (const advanced of [false, true]) { + void test(`Nexus Kull ${advanced ? 'Advanced' : 'Basic'} entry preserves canonical inventories and cannot redeal a saved game`, (t) => { + const { db } = fixture(t); + const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), advanced, ['choam', 'ix']); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + for (const p of game.players) p.ready = true; + db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code); + const seats = db.prepare('SELECT * FROM seats').all(); + const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'); + startPrototypeRoom(db, game.code, 7, 'nexus-kull'); + const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game; + assert.equal(saved.status, 'setup'); + assert.equal(saved.nexusKullPreview, true); + assert.equal(saved.kullPreview, true); + assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)), + [...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b))); + const cards = [...saved.deck, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])]; + assert.equal(cards.filter(card => card.id === 'ix-kull-wahad').length, 1); + const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all(); + assert.throws(() => startPrototypeRoom(db, game.code, 7, 'nexus-kull')); + assert.throws(() => startPrototypeRoom(db, game.code, 8, 'nexus-kull')); + assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows); + assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats); + assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other); + }); +} diff --git a/tests/table-turn.test.ts b/tests/table-turn.test.ts index b2734829..fc505890 100644 --- a/tests/table-turn.test.ts +++ b/tests/table-turn.test.ts @@ -216,7 +216,7 @@ void test('real Kull offer gives CHOAM public decision ownership before the susp const saved = JSON.parse(JSON.stringify(offered)) as Game; assert.deepEqual(owners(saved), owners(offered)); const counter = applyAction(saved, choam, { - type: 'kullDecision', event: saved.pendingKull!.event, card: 'ix-kull-wahad', + type: 'kullDecision', event: saved.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad', }); assert.equal(counter.response?.kind, 'choamWorthless'); assert.deepEqual(owners(counter), counter.players.map(() => null)); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index dbc976b2..42e0bef8 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -10,6 +10,7 @@ import { initializeSemutaGameForAudit, initializeKullGameForAudit, initializeRicheseBetrayalGameForAudit, + initializeNexusKullGameForAudit, viewGame, type Game, } from '../game/engine'; @@ -25,6 +26,7 @@ export const PROTOTYPE_PROFILES = [ 'semuta', 'kull', 'richese-betrayal', + 'nexus-kull', ] as const; export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number]; export function isPrototypeProfile(value: string): value is PrototypeProfile { @@ -80,6 +82,11 @@ export function startPrototypeRoom( ...initial, nexusCards: initial.nexusCards ?? { cards: null, phase: null }, }) + : profile === 'nexus-kull' + ? initializeNexusKullGameForAudit({ + ...initial, + nexusCards: initial.nexusCards ?? { cards: null, phase: null }, + }) : profile === 'factions' ? initializeFactionExpansionsGameForAudit(initial) : profile === 'nexus' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index af73dcd4..e0a35721 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -20,7 +20,7 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; }