From aeae1523705c866b4d4d59bf75a51bd4d21e60d5 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Wed, 30 Sep 2026 21:08:33 +1000
Subject: [PATCH] Add opt-in printed Kull interception and saved counter
continuation
---
README.md | 9 +
components/choam-kull.tsx | 80 +++
components/game-table.tsx | 65 ++-
docs/CHOAM_KULL_DESIGN.md | 106 ++--
docs/CHOAM_KULL_SOURCE_UPDATE.md | 31 +-
docs/CHOAM_REMAINING_RULES.md | 36 +-
docs/CURRENT_STATUS.md | 30 +-
docs/IMPLEMENTATION_STATUS.md | 59 +-
docs/NEXUS_CHOAM_RULES.md | 10 +-
docs/NEXUS_CHOAM_RUNTIME.md | 6 +-
docs/RULE_DECISIONS.md | 4 +-
game/bot-choam-kull.ts | 35 ++
game/bots.ts | 8 +-
game/choam-kull.ts | 167 ++++++
game/choam-worthless-cancellation.ts | 11 +-
game/engine.ts | 299 +++++++++-
game/faction-reference.ts | 3 +-
game/reference.ts | 48 +-
game/table-turn.ts | 5 +-
tests/bot-choam-kull.test.ts | 137 +++++
tests/choam-karama.test.ts | 6 +-
tests/choam-kull-controls.test.tsx | 111 ++++
tests/choam-kull-engine.test.ts | 625 +++++++++++++++++++++
tests/choam-kull-recovery.test.ts | 406 +++++++++++++
tests/choam-kull.test.ts | 203 +++++++
tests/choam-worthless-cancellation.test.ts | 32 ++
tests/fixture-choam-kull.ts | 55 ++
tests/nexus-choam-controls.test.ts | 2 -
tests/prototype-room.test.ts | 22 +
tests/table-turn.test.ts | 16 +
tools/prototype-room.ts | 4 +
tools/start-prototype.ts | 2 +-
32 files changed, 2487 insertions(+), 146 deletions(-)
create mode 100644 components/choam-kull.tsx
create mode 100644 game/bot-choam-kull.ts
create mode 100644 game/choam-kull.ts
create mode 100644 tests/bot-choam-kull.test.ts
create mode 100644 tests/choam-kull-controls.test.tsx
create mode 100644 tests/choam-kull-engine.test.ts
create mode 100644 tests/choam-kull-recovery.test.ts
create mode 100644 tests/choam-kull.test.ts
create mode 100644 tests/fixture-choam-kull.ts
diff --git a/README.md b/README.md
index 8998e670..ce3cf65b 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,15 @@ cyborg movement, HMS routes and battle aftermath through the same full deck; see
connects ordinary skill actions and saved market/revival choices; see
[CHOAM integration](docs/CHOAM_LEADER_SKILLS.md).
+For local printed **Kull Wahad** testing, use `node --import tsx tools/start-prototype.ts --profile kull`
+on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` decks
+and no optional modules, following the [safe prototype workflow](docs/DEVELOPMENT.md).
+The tool requires the exact room/version and a private backup output directory.
+The preview reserves the interrupted Karama and permits only a different eligible
+counter; BG interception is before conversion. Unpayable overbid recovery remains
+guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
+This does not enable public expansion starts or certify full CHOAM.
+
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md).
diff --git a/components/choam-kull.tsx b/components/choam-kull.tsx
new file mode 100644
index 00000000..ec4ac469
--- /dev/null
+++ b/components/choam-kull.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { CardInspector, CardRules } from './card-inspector';
+import { Button } from './ui/button';
+
+export function ChoamKull({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ 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';
+
+ return (
+
+ Kull Wahad · attempted Karama play
+
+ {target?.name ?? 'The activating player'} is attempting {reaction.intent}.
+ The attempted card remains unplayed while {reactor?.name ?? 'CHOAM'} responds.
+
+ {!ownsOffer ? (
+ Waiting for {reactor?.name ?? 'CHOAM'} to respond.
+ ) : (
+ <>
+
+ 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
+ Karama can prevent Kull before that restriction takes effect.
+
+ {reaction.blocked && {reaction.blocked}
}
+ {
+ if (!disabled)
+ act({ type: 'kullDecision', event: reaction.event, decline: true });
+ }}
+ >
+ Decline Kull · allow the attempted play
+
+ {!reaction.blocked && (
+
+ {reaction.cards.map((card) => (
+
+ {card.name}
+ Physical cost: {card.id}
+
+
+ {
+ if (!disabled)
+ act({ type: 'kullDecision', event: reaction.event, card: card.id });
+ }}
+ >
+ Use {card.name} ({card.id})
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 84f6e2fc..a2d13dc4 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -77,6 +77,7 @@ import { Bribes } from './bribes';
import { SukGraduatePanel } from './suk-graduate';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { SemutaReaction } from './semuta-reaction';
+import { ChoamKull } from './choam-kull';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
@@ -300,7 +301,10 @@ export function GameTable({
);
const reactionBusy =
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
- const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction;
+ const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction;
+ const kullOwnsControls = !!g.kullReaction || !!g.kullCounterEvent;
+ const canActivateKarama = (card: Card) =>
+ !g.karamaBlocked && canUseAsKaramaRole(g, me, card);
const traitorBattle = g.battle;
const traitorBeneficiary =
traitorBattle &&
@@ -1004,6 +1008,8 @@ export function GameTable({
faction(g.players.find((p) => p.id === id)!.faction).name,
)
.join(' + ') || 'No winner'
+ : g.kullReaction
+ ? 'CHOAM Karama response'
: g.semutaReaction
? 'Fresh discard response'
: g.automaticContinuationPending
@@ -1022,6 +1028,13 @@ export function GameTable({
{g.advanced && g.status !== 'lobby' && }
+ {g.kullPreview && (
+
+ Kull Wahad development preview · provisional interception timing, not
+ complete CHOAM or certified expansion rules.{' '}
+ Preview rules and limits
+
+ )}
{g.status !== 'lobby' && (
{PHASES.map((p, i) => (
@@ -1617,7 +1630,9 @@ export function GameTable({
{g.battle?.revealed && Compare revealed battle plans }
- {g.semutaReaction && }
+ {!kullOwnsControls && g.semutaReaction && }
+ {g.karamaBlocked && {g.karamaBlocked}
}
+ {!kullOwnsControls && <>
@@ -1657,7 +1672,10 @@ export function GameTable({
sector={sector}
onSectorChange={setSector}
/>
- {g.nexusCards?.waiting.length ? (
+ >}
+ {g.kullReaction ? (
+
+ ) : g.nexusCards?.waiting.length ? (
The next phase begins when the remaining Nexus card choices are finished.
) : g.truthtrance ? (
CHOAM declared {g.response.intent}.{' '}
+ {g.response.intent === 'Kull Wahad' && (
+ <>The attempted card is reserved. Use a different eligible Karama to prevent Kull and resume the original play; allowing Kull retains that attempted card but stops its actor’s Karama activations for this phase. >
+ )}
{g.response.location &&
(g.response.intent === 'Jubba Cloak'
? `Protect CHOAM’s forces in ${territory(splitLocation(g.response.location).territory).name} during this storm movement. `
@@ -2697,7 +2718,7 @@ export function GameTable({
{g.revival.specialKaramaBlock}
)}
{me.hand
- ?.filter((c) => canUseAsKaramaRole(g, me, c))
+ ?.filter(canActivateKarama)
.map((c) => (
{actionButton(
@@ -2810,7 +2831,7 @@ export function GameTable({
{me.hand
- ?.filter((c) => canUseAsKaramaRole(g, me, c))
+ ?.filter(canActivateKarama)
.flatMap((c) =>
[g.battle!.attacker, g.battle!.defender].map((id) => (
@@ -2843,7 +2864,7 @@ export function GameTable({
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
- {me.hand?.filter((c) => canUseAsKaramaRole(g, me, c)).map((c) => (
+ {me.hand?.filter(canActivateKarama).map((c) => (
{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id })}
))}
>
@@ -2870,7 +2891,7 @@ export function GameTable({
allow: true,
})}
{me.hand
- ?.filter((c) => canUseAsKaramaRole(g, me, c))
+ ?.filter(canActivateKarama)
.map((c) => (
{actionButton('Spend Karama · stop shipment', {
@@ -3178,7 +3199,7 @@ export function GameTable({
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
)}
{me.hand
- ?.filter((c) => canUseAsKaramaRole(g, me, c))
+ ?.filter(canActivateKarama)
.map((c) => (
{actionButton(
@@ -4102,7 +4123,7 @@ export function GameTable({
g.mobileStronghold?.location &&
(me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
me.hand
- ?.filter((c) => canUseAsKaramaRole(g, me, c))
+ ?.filter(canActivateKarama)
.slice(0, 1)
.map((c) => (
@@ -5062,7 +5083,7 @@ export function GameTable({
: ' · Flips at the next Mentat Pause'}
)}
-
+ {!kullOwnsControls && }
@@ -5084,7 +5105,17 @@ export function GameTable({
))}
- {panel === 'hand' ? (
+ {panel === 'hand' && kullOwnsControls ? (
+ Your Treachery hand is empty.}>
+ {(card) => (
+
+ {card.name}
+
+
+
+ )}
+
+ ) : panel === 'hand' ? (
<>
@@ -5121,7 +5152,9 @@ export function GameTable({
? 'Use the Nullentropy Box panel above to begin the paid search.'
: richeseCardActionBlock(c)) ??
(canUseAsKaramaRole(g, me, c)
- ? g.response
+ ? g.karamaBlocked
+ ? g.karamaBlocked
+ : g.response
? g.nexusTraitors?.pending
? 'Finish the private Nexus Traitor card return before responding to this power.'
: !g.responseControls?.cancelCards.includes(c.id)
@@ -5354,7 +5387,7 @@ export function GameTable({
>
)}
{g.advanced &&
- canUseAsKaramaRole(g, me, c) &&
+ canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'harkonnen' &&
g.phase === 3 && (
@@ -5410,7 +5443,7 @@ export function GameTable({
>
)}
{g.advanced &&
- canUseAsKaramaRole(g, me, c) &&
+ canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'fremen' &&
g.phase === 1 && (
@@ -5461,7 +5494,7 @@ export function GameTable({
>
)}
{g.advanced &&
- canUseAsKaramaRole(g, me, c) &&
+ canActivateKarama(c) &&
!me.specialKaramaUsed &&
me.faction === 'emperor' &&
g.phase === 4 && (
@@ -5542,7 +5575,7 @@ export function GameTable({
/>
)}
- {canUseAsKaramaRole(g, me, c) &&
+ {canActivateKarama(c) &&
g.phase === 5 &&
g.active &&
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md
index 6744640e..ef1f7848 100644
--- a/docs/CHOAM_KULL_DESIGN.md
+++ b/docs/CHOAM_KULL_DESIGN.md
@@ -1,6 +1,6 @@
# Kull Wahad: source limits and Karama interruption design
-Audit date: 2026-09-06. This is a proposed implementation design, not a rules completion claim. Only this document was changed. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable.
+Source audit: 2026-09-06; opt-in preview contract updated 30 September 2026. This is an engineering and product-policy record, not a rules-completion claim. The source audit in [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md) remains applicable. The user-approved counter and BG choices supersede the original proposal; publisher-source uncertainty and full expansion gates remain.
## Authoritative boundary
@@ -10,19 +10,28 @@ The [November 2020 official FAQ](https://www.gf9games.com/dune/wp-content/upload
Evidence is indexed official publisher text retrieved in the preceding audit. Direct PDF access was unavailable: HTTP 403 through web retrieval, HTML through a local fetch. No inaccessible card face, tournament amendment, or community ruling is promoted to authority. No later official clarification settling the questions below was located.
-Unresolved rulings that must remain visible:
+## Selected policy and remaining source limits
+
+The user selected **Different Karama**: bind and reserve the interrupted physical card, allow a distinct eligible counter before Kull's restriction becomes active, and never let the original pay for both purposes. They selected **Before conversion** for advanced BG substitutions: successful Kull retains the still-unplayed Worthless card. A prevented original special activation leaves its once-per-game use and additional costs unspent. These are explicit product interpretations, not located publisher rulings; see [the recorded selection](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026).
+
+The user selected **Defer interaction** for a winning overbid whose sole Karama payment becomes unplayable. A late payment guard accepted an unfunded bid and then could not settle it, so the preview now privately refuses non-CHOAM unfunded Karama-dependent bids before commitment, independently of hidden Kull custody. Existing unfunded winning activations also remain guarded. CHOAM self-activations are outside interception and retain their ordinary payment behavior; non-preview bidding is unchanged. This is an explicit unfinished-composition fence, not a printed bidding prohibition, numeric UI cap, auction restart, free award, invented funding or approval of an auction recovery.
+
+The wider publisher cancellation scope across other physical copies or Worthless names remains unclarified. Preserve existing exact-cost phase prevention rather than silently widening it. Combined modules, simultaneous-power ordering beyond supported continuations and complete CHOAM games remain acceptance boundaries, not a reason to call approved counter/BG choices unanswered.
+
+## Opt-in development scope
+
+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.
+
+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.
-1. Whether the same Karama that Kull interrupts can instead cancel Kull, whether a second Karama by that target may do so, and how third-party responses are ordered.
-2. Whether Kull intercepts a Bene Gesserit Worthless conversion before discard, or only after conversion has succeeded; the resulting card custody in each case.
-3. Whether an already prevented player may still overbid using a held Karama, and the required recovery when an accepted winning bid becomes unpayable.
-4. Exact cancellation scope for CHOAM's prevented Worthless effect across other physical copies or another Worthless name.
-5. Self-targeting, simultaneous attempts, and phase-end abilities that may exchange a card while another declaration is unresolved.
Real Karama used for cancellation, shipment, purchase/payment, or a special faction power all fit the general attempted-play wording. Applying Kull to each is a strong textual inference, not an individually enumerated official ruling. Merely acquiring, holding, trading, selling, or discarding a card without activating Karama is not established as a trigger. Do not invent a phase-long ban on card ownership.
-## Current activation inventory
+## Pre-interception activation inventory
-The following paths were inspected in `game/engine.ts`; symbol names are the stable references because concurrent development changes line numbers.
+The following inventory records the original immediate-mutation hazards in `game/engine.ts`, before Kull interception. Symbol names are stable references because concurrent development changes line numbers. The preview must intercept the validated intent ahead of these effects.
| Entry | Present execution | Required interception boundary |
| --- | --- | --- |
@@ -48,70 +57,41 @@ Special branches bypassing `spendKarama()`:
`applyActionInner()` deliberately permits CHOAM special cash-in before ordinary response/decision gating, and dispatches other special modes before `g.response`. Intercepting only the ordinary response handler leaves those paths uncovered. Adding a blanket denial of every special while a decision exists would remove already supported gameplay and is not justified by this task.
-## Proposed state and API responsibilities
+## State and API responsibilities
-Names below are illustrative; this document does not add these types to the engine.
+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.
-```ts
-type KaramaBlock = { turn: number; phase: number; player: string };
+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.
-type PendingKaramaIntent = {
- id: string; // Stable server-issued attempt identity.
- owner: string; // Player activating the card, not its recipient.
- card: string; // Exact physical identity; no later auto-selection.
- turn: number;
- phase: number;
- form: 'printed' | 'bg-conversion';
- purpose: ValidatedOrdinaryOrSpecialIntent;
- stage: 'offer-kull' | 'resolve-kull' | 'convert-bg' | 'commit';
- offeredToChoam: boolean;
- suspended: KaramaContinuation;
-};
-```
+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.
-`ValidatedOrdinaryOrSpecialIntent` should be a discriminated union, with only the fields needed for each use: shipment recipient; auction identity/index and payer; cancellation response identity; special target/leader/count/elite/route or selected cash-in IDs. Do not retain an arbitrary client `Action` object and replay it through the public dispatcher.
+`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.
-`KaramaContinuation` must describe which suspended operation resumes or aborts, and own its response/decision data. Maintain explicit nesting frames or an equivalent bounded continuation structure; the present single `pendingKarama` field cannot represent both a suspended BG conversion and a new Karama counter-response. A numeric nesting limit, if needed as a defensive assertion, is not a game rule and must not reject a reachable legal sequence arbitrarily.
+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.
-Recommended helper responsibilities:
+## Settlement flow and cancellation
-- Keep `canUseAsKarama()` as the physical/type classifier. Add a stateful activatability predicate that considers Kull's turn/phase restriction, custody, committed cards, and purpose. Distinguish this from hand valuation and any separately resolved overbid permission.
-- `validateKaramaIntent()` performs the same preconditions as the current branch without mutation or random draws. Invalid attempts must not open a public Kull window or reveal a private card.
-- `beginKaramaIntent()` saves the exact continuation, then offers CHOAM a decision based on the public attempt, regardless of whether its private hand contains Kull. The action must not yet change game resources or reveal future random results.
-- `commitKaramaIntent()` is an internal, once-only executor. It revalidates relevant live state, consumes the exact card once, applies the effect, and resumes the proper continuation. The already-offered attempt cannot reopen its own Kull opportunity.
-- `abortKaramaIntent()` restores or completes the underlying opportunity without applying the prevented effect. It must not restore a whole saved `Game`: doing so would undo legitimate intervening cash-ins, card custody changes, or unrelated response outcomes.
+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**.
+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.
+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.
-Persist attempt IDs, stages, and continuation ownership through JSON saves. Missing new fields in legacy rooms should normalize to no pending attempt/no block. A frame must not contain a function closure or cyclic object. Prevent two frames from independently owning the same original response or physical-card consumption.
-
-## State-machine flow and cancellation
-
-This is an implementation skeleton. It does **not** choose an answer to the unresolved nested-priority questions above.
-
-1. Receive a legal attempted activation; reject an already active phase restriction before costs. Capture its continuation and announce the minimum public intent.
-2. Offer CHOAM the reaction. Declining resumes this attempt exactly once. Declaring Kull stores its exact card ID as `pendingChoamWorthless`, its target actor, and the attempt ID; consume neither side until its appropriate stage.
-3. Resolve whichever counters the eventual authoritative timing interpretation permits. Save the interrupted Kull frame and original attempt when another card is activated; never overwrite them with the top-level `g.response`.
-4. If Kull settles successfully, establish the actor's turn/phase restriction and abort the interrupted activation. Proposed pre-play custody behavior is to retain the prevented Karama and leave once-per-game power use unspent. This is a reasonable implementation choice, but the retrieved text does not explicitly state its discard treatment.
-5. If Kull is prevented, use the existing CHOAM prevention-of-discard contract, then resume the saved activation once. If Kull was sold, exchanged, or otherwise lost, grant no restriction; revalidate the interrupted activation instead of assuming its card is still owned.
-6. A new phase clears the effective restriction by stamp comparison. A temporary worm-resolution suspension in the same phase does not. Do not permit ordinary phase advance while an unresolved frame owns its continuation.
-
-Do not silently install any of these as an official priority rule: target always wins the counter, CHOAM always wins the counter, same card may answer twice, same card can never answer, no counter is possible, or everyone gets an unlimited replacement response. The final engine may need a documented provisional policy under the existing expansion gate until primary clarification is available.
+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.
## Bene Gesserit conversion
-Current behavior intentionally differs from CHOAM Worthless cancellation: `spendKarama()` has already discarded BG's Worthless card when it exposes `worthlessKarama`. `finishResponse()` clears the conversion pending record; allowing calls `completeKarama()`, canceling a cancellation restores the original response, and canceling auction payment invokes recovery.
+Kull intercepts **before** the original Worthless conversion or discard. Success therefore retains the original Worthless card and stops its attempted Karama use. If CHOAM declines or Kull is countered, the saved original proceeds to the established ordinary conversion stage.
-Preserve that established ordinary-cancellation behavior. Two possible Kull placements require a ruling:
-
-- **Before conversion:** Kull stops an attempted use-as-Karama while its physical Worthless card is still in hand. This needs an explicit custody policy and cannot reuse the existing post-discard conversion cancellation result blindly.
-- **After conversion:** normal BG conversion responses occur first; an allowed conversion then reaches Kull. This requires retaining the discarded card's identity and a suspended original intent, and clarifying whether/when that card returns if Kull succeeds.
-
-Whichever placement is eventually selected, a printed Karama canceling BG's conversion is itself an activation path. Test Kull on that counter as well as on the original substituted use. A successful Kull block must prevent the target bypassing it by choosing a different physical Worthless card on the next action, if substituted activation is included in the ruled scope.
+Ordinary conversion behavior remains different: `spendKarama()` discards the BG Worthless card before opening `worthlessKarama`; preventing that conversion leaves the card discarded. Kull's pre-conversion custody selection does not rewrite that FAQ-based consequence. A distinct BG Worthless counter likewise has its own ordinary conversion response, while the original remains reserved.
## Existing continuation and feasibility hazards
| Integration point | Required design check |
| --- | --- |
-| `recoverAuctionPayment()` | A held-but-blocked Karama must not keep returning an impossible payment decision. Its auction-restart fallback is already documented as provisional; Kull does not certify that fallback. Preserve card secrecy and actual funding while deciding recovery. |
+| `recoverAuctionPayment()` | A held-but-blocked Karama must not keep returning an impossible payment decision. Its existing auction-restart fallback is provisional and is not the selected Kull remedy. Preflight the deferred sole-Karama winning-overbid composition instead of reaching an unfinishable settlement. |
| Bid validation | `karamaCard()` currently removes the normal maximum bid. Separate held-card classification from activation eligibility; do not accidentally disclose whether CHOAM holds Kull during bidding. |
| `findReachableBattlePlan()` / `cashInPreparationActions()` | These call `specialKarama()` on cloned trial state, then clear response/conversion fields. A new asynchronous gate would otherwise enqueue unchanged trials or treat pending cash-in as paid income. Provide a pure allowed-branch simulation or an explicit internal trial executor; an already effective Kull restriction must still apply. |
| `pendingBattleRevivalIncome()` | Currently searches only the live response or `pendingKarama.use.response`. It must find the suspended relevant income through any new Kull frames without counting it as already received. |
@@ -126,30 +106,30 @@ Whichever placement is eventually selected, a printed Karama canceling BG's conv
- 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.
-- Kull's settled target and expiry are public. Selecting another player's Karama beneficiary must not change which player is restricted. Whether the actor may target itself remains a ruling gap, not a reason to silently extend ally benefits.
-- Bots must handle offer/decline, counters if permitted, restored underlying decisions, and all blocked-activation filters. A card remaining physically in hand is not evidence the bot can play it. Prevent repeated invalid-action generation from both proactive special branches and ordinary cancellation branches.
+- 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.
-- Human controls should describe the actual attempted use and phase restriction. Keep unconfirmed timing explanations out of claims of official completeness. Full card inspection must remain available without exposing unplayed opponents' hands.
+- 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
1. Conservation and no premature mutation for all four ordinary purposes and all eight existing special branches; validate Emperor revival and random Harkonnen transfer specifically.
2. Direct cancellation nested under Kull, permitted counter-to-Kull, third-party counter, BG original conversion and BG counter-conversion; exact card IDs, one discard, once-use flag, response owner/passes and continuation preserved through JSON reconnect.
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. Mark auction recovery scenarios provisional where source interpretation is unresolved.
+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.
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.
-This pass inspected the source paths above and validated document structure. It did not edit the engine, UI, bots, or tests, and did not run tests for behavior that remains unimplemented. Exact nested priority and BG placement remain source questions; the state design deliberately exposes those decisions instead of turning them into hidden house rules.
+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.
## Implemented prerequisite: prepared special intents
-The engine now exports `prepareSpecialKaramaIntent` and `executeSpecialKaramaIntent`. The intent union represents all eight implemented factions, with separate Emperor force/leader branches. It copies normalized selections, binds the original turn and phase plus relevant current battle/shipment/revival declaration, and contains no random Harkonnen hand selection. The executor re-prepares and compares the intent before consuming the exact physical card or applying effects. Existing public special actions still call preparation and execution synchronously.
+The engine exports `prepareSpecialKaramaIntent` and `executeSpecialKaramaIntent`. The intent union represents all eight implemented factions, with separate Emperor force/leader branches. It copies normalized selections, binds the original turn and phase plus relevant current battle/shipment/revival declaration, and contains no random Harkonnen hand selection. The executor re-prepares and compares the intent before consuming the exact physical card or applying effects. The original prerequisite called these synchronously; the Kull preview pauses the prepared intent before execution.
Preparation includes public-dispatcher timing gates, with CHOAM's existing phase-opening/market/revival exceptions retained and all special activations blocked during unresolved Truthtrance. Feasibility clears Truthtrance only on its hypothetical future-state clone and retains binding battle promises. The executor mutates a disposable working Game; it is not independently transactional. Any future paused caller must restore its owned decision context, run on a clone, and publish only on success. Public `applyAction` remains the input-isolation boundary.
Ten regression cases in `tests/karama-intents.test.ts` cover all variants, serialization, preparation purity/no random draw, stale timing/custody/resources, copied CHOAM selection, exact declaration binding, deferred Harkonnen randomness, injected execution failure isolation, dispatcher parity and preserved CHOAM exceptions. The full 728-case unit suite and 33 persisted/database cases pass.
-This prerequisite does not add a pending Kull frame or phase restriction. On 30 September the user approved the distinct-card counter and pre-conversion BG interpretation, while deferring unpayable winning overbids; see the [selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026). Dependent Kull timing implementation remains unfinished.
+The prerequisite alone did not add a pending Kull frame or phase restriction. The later opt-in preview composes these prepared intents with the selected distinct-card/pre-conversion protocol. Full-game, combined-module, deployment and unpayable winning-overbid recovery acceptance are separate; the preview does not reopen public starts.
diff --git a/docs/CHOAM_KULL_SOURCE_UPDATE.md b/docs/CHOAM_KULL_SOURCE_UPDATE.md
index 16190da7..f88a291d 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. This supplements [CHOAM_KULL_DESIGN.md](CHOAM_KULL_DESIGN.md) and the Kull section of [CHOAM_REMAINING_RULES.md](CHOAM_REMAINING_RULES.md). No runtime behavior changes in this audit.
+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.
## User-selected preview timing — 30 September 2026
@@ -38,16 +38,29 @@ Targeted official-domain and named-designer searches also surfaced a BGG discuss
## Implementable scope and remaining interpretation
-| Area | Supported scope | Remaining material choice |
+| Area | Publisher-supported scope | Selected preview policy and remaining source limit |
| --- | --- | --- |
-| Trigger and expiry | React to an attempted Karama play; restrict that player for the current phase. The text does not ban ownership, transfers or a non-activation discard. | Applying the broad trigger to every ordinary and special activation is a strong textual inference, not a new enumerated FAQ ruling. |
-| Counter priority | The expansion explicitly makes CHOAM's Worthless effect preventable. | Whether the original attempted card can be redirected against Kull; whether a different card can counter before the restriction applies; and priority for a third party. No retrieved source supplies this response order. |
-| 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. | Neither establishes whether Kull interrupts before or after BG conversion, or orders discard of a printed Karama whose play is prevented. Retaining the latter as unplayed is a reasonable implementation inference; applying BG's ordinary cancellation discard automatically to Kull is not an explicit ruling. |
-| Blocked overbids | Holding permission and actual Karama play are distinct in the FAQ. | Whether the permission persists after Kull, and how to settle a winning bid when Kull removes its only payment method. A bid cap, free award, forced alternative funding or auction restart would each add a policy absent from the retrieved Kull text. |
-| Countered CHOAM discard | The expansion supplies a phase-scoped prevention provision. | How that restriction applies to another physical copy or a different Worthless effect is not clarified here. This is shared CHOAM behavior, not a newly discovered Kull-only blocker. |
+| Trigger and expiry | React to an attempted Karama play; restrict that player for the current phase. The text does not ban ownership, transfers or a non-activation discard. | Applying the broad trigger to ordinary and implemented special activations is a textual inference, not a new enumerated FAQ ruling. Restrict the card's activating player, not a shipment beneficiary. |
+| Counter priority | The expansion explicitly makes CHOAM's Worthless effect preventable, but supplies no nested response order. | **Different Karama** is selected: reserve the original physical card; resolve an eligible distinct counter before the phase ban becomes active. The original cannot pay twice. Existing response ownership governs other eligible responders; this is product policy, not newly located publisher authority. |
+| 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. |
-The first three unresolved interaction clusters—counter sequence, BG/custody sequence, and unfunded-auction recovery—need an explicit interpretation if those outcomes are shipped before further primary clarification. The existing design's provisional sequence remains a proposal; this audit neither endorses it as official nor records new user approval.
+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.
+
+Validated intents, actor/phase restrictions, exact physical-card binding, safe continuation storage, private projections and duplicate-action protection must preserve that selected interpretation. A pending Kull declaration does not itself activate the phase restriction: only a successfully settled effect does. Decline or prevention resumes the reserved original intent once, not by replaying a public client action or restoring a whole Game snapshot.
+
+An additional engineering admission boundary is the existing pure post-cost feasibility proof for ordinary cancellation under a live Truthtrance battle/shipment promise. An unprovable suffix is refused privately before the offer or cost, while proven supported cancellations remain available. This bounded preview exclusion does not establish a publisher prohibition or settle additional rule interpretation.
+
+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
+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
+outside interception and can use its ordinary Karama payment.
+Kull costs also receive a cloned post-cost own-promise check; counters
+are projected only when their same authoritative proof is available.
-These limits do not prevent implementation of validated intents, actor/phase restrictions, exact physical-card binding, safe continuation storage, private projections, or duplicate-action protection. Those are engineering responsibilities. They must preserve the selected rule interpretation without silently choosing a counter order or auction remedy. The older design's wider list of speculative edge cases is not evidence that every such case independently requires a user ruling.
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.
diff --git a/docs/CHOAM_REMAINING_RULES.md b/docs/CHOAM_REMAINING_RULES.md
index 623c5bfa..e0346f9b 100644
--- a/docs/CHOAM_REMAINING_RULES.md
+++ b/docs/CHOAM_REMAINING_RULES.md
@@ -1,6 +1,6 @@
# Remaining CHOAM rules: primary-source audit and engine contract
-Audit date: 2026-09-06. Scope: Jubba Cloak, Kull Wahad, Auditor, and CHOAM's printed leaders for the classic GF9 2019 game with CHOAM & Richese. This document separates retrieved official rules from proposed software behavior. It does not certify complete CHOAM support. Only this document was authored in this subtask.
+Audit date: 2026-09-06; Kull preview policy updated 30 September 2026. Scope: Jubba Cloak, Kull Wahad, Auditor, and CHOAM's printed leaders for the classic GF9 2019 game with CHOAM & Richese. This document separates retrieved official rules from software behavior and explicit product interpretation. Later runtime records supersede historical absence claims; this does not certify complete CHOAM support.
## Evidence, access, and precedence
@@ -39,30 +39,30 @@ Open timing questions: earliest declaration relative to final storm confirmation
## Kull: coverage and interruption design
-The printed trigger is broad, but the retrieved official text does not enumerate every Karama use. The table below gives recommended handling and the confidence limit, not an official scenario-by-scenario ruling.
+The printed trigger is broad, but the retrieved official text does not enumerate every Karama use. The table separates the approved preview handling from publisher confidence limits. The distinct counter and BG-before-conversion choices are recorded in the [source update](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026); they are not outstanding user questions or publisher clarification.
-| Attempt or situation | Recommended behavior | Evidence limit |
+| Attempt or situation | Approved preview handling | Evidence limit |
| --- | --- | --- |
-| Real Karama cancels a faction/alliance ability | Intercept before card consumption or canceled-effect settlement; CHOAM may declare Kull | Ordinary use of the named card fits the printed trigger; nested order is unspecified |
-| Real Karama funds shipment, takes an auction card, or pays a winning bid | Route through the same interception | No purpose restriction is stated; blocked-bid recovery remains unresolved |
-| Real Karama activates a once-per-game faction power | Intercept before setting `specialKaramaUsed` or paying its additional costs | Strong textual inference from attempted card play; distinguish this from ordinary Karama canceling an already activated special power |
-| Bene Gesserit Worthless-as-Karama | Expose an equivalent attempt, with an explicit conversion stage | Whether Kull acts before conversion/discard is not settled by the retrieved CHOAM text; November FAQ only settles ordinary Karama cancellation of the conversion |
-| Holding Karama permits an overspice bid | Do not consume Kull merely because a hidden card might fund a bid | Holding is not the same as attempting to play; whether an already blocked player retains that bidding permission needs clarification |
+| Real Karama cancels a faction/alliance ability | Intercept before card consumption or canceled-effect settlement; CHOAM may declare Kull | Ordinary use fits the printed trigger; response priority is explicit preview policy |
+| Real Karama funds shipment, takes an auction card, or pays a winning bid | Route through the same pre-cost interception; guard an unpayable winning-overbid composition | No purpose restriction is stated; no auction recovery remedy was approved |
+| Real Karama activates a once-per-game faction power | Intercept its prepared intent before setting `specialKaramaUsed`, paying additional costs or drawing randomly | Broad trigger is a textual inference; this differs from ordinary Karama canceling an already activated special power |
+| Bene Gesserit Worthless-as-Karama | Intercept before conversion/discard; successful Kull retains the unplayed Worthless card | Selected product policy; November FAQ's ordinary conversion cancellation still discards the card if that later stage is reached |
+| Holding Karama permits an overspice bid | Holding alone is not an attempted activation and never spends Kull | Winning-payment infeasibility remains deferred, not solved by hidden-card-dependent bidding limits |
| Target already blocked in this turn/phase | Reject another Karama activation before mutations | Use a turn-and-phase stamp so the restriction expires at the right boundary |
| An ally plays Karama for a recipient who is blocked | Apply the restriction to the **card player**, not automatically to its beneficiary | Recommended reading of the named-player restriction; scenario not explicitly answered |
| Acquiring, trading, selling, or discarding Karama without activating it | Do not treat this as a Kull trigger | No primary support for preventing ownership or a non-Karama-effect discard |
-| The attempted Karama is aimed at Kull itself | Requires a staged response protocol and a declared provisional priority | The apparent circular case is not resolved by retrieved official text |
+| The attempted Karama is aimed at Kull itself | Reserve the original card; allow only a distinct eligible counter before activating the ban | Different-card response priority is selected product policy, not a retrieved publisher ruling |
-Current integration hazards:
+Implementation responsibilities:
-- `spendKarama()` discards immediately. Save a validated pending intent before that mutation, including owner, physical card ID, purpose, recipients, interrupted `response`/`decision`, and phase stamp. Revalidate custody/legality on resumption. A blocked attempt should provisionally keep its unplayed card; do not silently charge a cost for an effect prevented before play.
-- `completeKarama()` supports `cancel`, `shipment`, `purchase`, and `auctionPayment`. Preserve its continuation instead of recursively calling the original user action, which could reopen Kull indefinitely.
-- `specialKarama()` is separate and directly mutates many powers. A fix only in `spendKarama()` would leave special powers outside the gate. Centralize attempted-play interception or explicitly route every branch through the same protocol.
-- `pendingKarama` currently represents Bene Gesserit conversion and preserves its original canceled response. Kull cannot overwrite that state or `g.response` without saving the suspended context. Use a bounded explicit stage/continuation, not implicit global ordering.
-- `recoverAuctionPayment()` already calls unfunded canceled-payment recovery provisional. A Kull-blocked payment must not award the auction card for free, leave an impossible decision, or assume this routine is source-certified. Distinguish an inability to fund a bid from merely choosing not to use another available payment method.
-- Offer CHOAM the Kull opportunity from an actual declared public attempt regardless of private Kull ownership. Reveal only the attempted card and permitted intent, never the rest of either hand.
+- Save a validated typed intent before mutations, including activating owner, exact physical card, purpose, interrupted response/decision ownership and turn/phase stamp. Invalid attempts must not expose a public reaction or hidden payload.
+- Preserve the original continuation rather than recursively dispatching the original user action. Decline resumes it once; successful Kull stops it while retaining the unplayed original and unspent special once-use. No whole-Game rollback is permitted.
+- Route ordinary and prepared special activations through interception, including Emperor revival before any physical return and Harkonnen before a random transfer. A hook placed only at discard is too late.
+- Keep a BG counter conversion separate from the reserved original attempt and Kull's own cost. Ordinary conversion cancellation has its existing discard consequence; successful pre-conversion Kull does not.
+- Guard the deferred winning-overbid composition before payment costs or saving an unfinishable transaction. An actor-private refusal must not depend on hidden Kull custody, reveal funding or install a free award, restart, new subsidy or bid cap.
+- Offer CHOAM the same neutral opportunity from an eligible public attempt regardless of private Kull ownership. Only CHOAM sees its own legal costs; neither a raw special selection nor a saved response secret belongs in a rival projection.
-A possible provisional order is: validate attempted Karama; pause it; allow CHOAM's Kull declaration; resolve permitted responses to that declaration; establish the phase restriction if Kull succeeds; either abandon the still-unspent attempt or resume it once. The target's ability to use the same pending Karama or a second Karama against Kull, and priority for other players, need a primary ruling before this is called complete.
+An offered or declared Kull is **pending**, not an active ban. Resolve the ordinary CHOAM-effect response first, excluding the reserved original physical card from counter choices. Success then restricts the activating player for the stamped phase only. Prevention retains the Kull cost under existing native CHOAM semantics and resumes the original intent once. The wider publisher scope across canceled copies/effects and full combined-play acceptance remain boundaries.
## Auditor: integration and unresolved printed data
@@ -91,4 +91,4 @@ Recommended engine work, pending that roster verification:
| Auditor | Survivor/dead and 0/1/2+ eligible-card cases; battle-used exclusions; full payment only; Karama prevention; private projection and stable random sampling; no capture/foreign ghola/skill; additional advanced setup identity; repeated revival and all listed battle outcomes. |
| AI/UI | Easy/Medium/Hard/Brutal resolve every new decision using private projections; human controls explain target/scope and permit legal decline; normal/enlarged text tracks only verified behavior. |
-This was a read-only source/engine audit plus documentation creation. No gameplay tests were rerun for unimplemented behavior. Validation consisted of official indexed-source retrieval, inspection of current integration points, image inspection, metadata/file-type checks, and document checks. The unresolved questions above are remaining work, not newly established house rules.
+The original 6 September pass was a read-only source/engine audit plus documentation creation. Validation then consisted of official indexed-source retrieval, inspection of integration points, image inspection, metadata/file-type checks and document checks, not gameplay tests. The later 30 September Kull choices are explicitly selected product policy; remaining publisher clarification, deferred overbid recovery and full combined-play acceptance are not completion claims.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 4c5348b5..dafbaa61 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,6 +6,32 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
+The explicit `kull` development profile now connects printed Kull
+Wahad interception in Basic/Advanced CHOAM-plus-classic rosters
+with the physical CHOAM/Ix deck. Five implementation lanes delivered
+the typed engine, pure policy, human controls/legal bots, authenticated
+SQLite scenarios and internal guide. An original Karama stays
+reserved; only a distinct eligible card can counter Kull before the
+phase ban. BG is intercepted before conversion. Unpayable winning
+overbid remedies remain deferred: non-CHOAM unfunded bids are
+privately fenced before commitment to avoid an unfinishable payment.
+Numeric UI limits and non-preview rules are unchanged. Original,
+counter and Kull costs honor live promises; unproven suffixes reject
+before disposal. Review exposed Auditor-parent, orphan-frame and
+promise defects, which are repaired with regressions. Focused rules,
+controls, legal AI and SQLite checks pass; direct smoke kept the
+original card, spent one Kull and applied a phase ban without a rate.
+`npm run check` passed types, lint and 5,907 offline tests; build
+passed. Phone-width Basic/Advanced QA exercised Use, a distinct
+counter and Decline after refresh: Basic retained the original
+and phase ban; Advanced resumed BG conversion and its saved
+cancellation once. Independent repair reviews found no remaining
+scoped defect. Isolated Wrangler-dev HTTP passed 49/55 with five
+proxy 503s and one timeout, not a green local HTTP suite.
+Proxy-free CI remains pending for this checkpoint.
+Nexus Cunning Kull, wider modules and full expansion acceptance
+remain separate; no public starts or live NAS deployment are claimed.
+
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
accepts a fresh Richese roster with CHOAM & Richese and optionally
the physical Ix deck. Clean public ordinary-card, paid Box, retired
@@ -59,7 +85,9 @@ phone-width full-hand QA seat acquired Hajr, spent Semuta,
kept four cards and preserved that result across refresh.
Isolated Wrangler-dev HTTP passed 49/55 with five proxy 503s
and one timeout; this is not a green local HTTP suite.
-Proxy-free CI for the exact checkpoint remains pending.
+Proxy-free workflow `36684960601` passed the full isolated
+HTTP suite for `3285172` and published its image. No NAS
+deployment is claimed.
Kull's approved distinct counter/BG-before-conversion timing
is recorded but runtime remains unfinished; unpayable
winning-overbid recovery is deliberately not chosen.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index acce6b9a..072389bd 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,60 @@
# Dune implementation status
+## 30 September 2026 — opt-in printed Kull Wahad integration
+
+Five implementation lanes supplied validated activation frames,
+phase/physical-counter policy, mounted human controls and legal
+bot participation, authenticated SQLite regressions and source-bound
+internal guidance. The `kull` local profile starts only a fresh ready
+Basic/Advanced CHOAM-plus-classic roster with CHOAM/Ix physical cards,
+without public expansion starts or optional modules.
+
+A legal ordinary or prepared special attempt pauses before its cost.
+Every seat sees neutral CHOAM timing regardless of held Kull.
+The interrupted card stays held and reserved; Kull may be prevented
+only by another eligible physical card. BG interception precedes
+conversion. Decline or prevention resumes the original typed effect
+once. Successful Kull discards its native cost, retains the original,
+leaves a prevented special unspent and applies a turn/phase activation
+ban, not an ownership/transfer ban. Native denied Kull remains held
+but its physical effect is blocked for that phase.
+
+The user deferred unpayable winning-overbid recovery. The preview
+privately refuses non-CHOAM unfunded bids before commitment, as
+well as affected activations before cost. The former late guard
+accepted a bid that the preview could not settle; a targeted
+smoke reproduced that boundary before the early-fence repair.
+Numeric UI bounds and non-preview bidding are unchanged; no
+free award, funding or auction restart is invented.
+Unproven live-promise cancellation/counter suffixes likewise reject
+before cost. Pending-frame reconciliation cannot release a suspended
+promise; Auditor traversal now includes the saved parent; orphaned
+Kull declarations fail before private views or partial SQL writes.
+
+Focused rules/control/bot/SQLite regressions include real Voice
+and Auditor parents, distinct/BG counters, orphan rejection,
+promise-compatible costs, legal progress and profile safeguards.
+`npm run check` passed types, lint and 5,907 offline tests;
+the production build passed. Independent re-reviews of the
+repaired rules/privacy/persistence paths found no scoped defect.
+Direct Kull and local-profile CLI smoke passed.
+
+In isolated phone-width Basic/Advanced browser rooms, the actual
+reactor used Kull, Basic's other seat allowed it, and the original
+card and phase ban persisted across refresh with no rate-control
+shortcut. Advanced BG's held original could not counter Kull;
+its distinct printed card prevented Kull and resumed the original
+BG conversion. Another cancellation was then declined through
+the refreshed CHOAM offer and retired the same saved parent once.
+Kull stayed held, BG's already converted card remained discarded,
+and no shipment rate or ban was fabricated.
+
+Isolated Wrangler-dev HTTP passed 49/55: five proxy-layer 503s
+and one 15-second timeout. This is not a green local HTTP suite;
+proxy-free checkpoint container CI remains pending.
+Nexus Cunning Kull, combined modules and full CHOAM completion
+remain unfinished. No NAS deployment is claimed.
+
## 30 September 2026 — user-approved full-hand Semuta exchange
The user selected atomic acquisition/disposal at the faction
@@ -27,7 +82,9 @@ Commit, exchanged Semuta for Hajr and retained 4/4 after
refresh. The throwaway staging script was removed.
Isolated Wrangler-dev HTTP passed 49/55: five proxy 503s
and one timeout. This local HTTP run is not green;
-proxy-free CI and NAS deployment are separate pending gates.
+proxy-free workflow `36684960601` passed the full isolated
+HTTP suite for `3285172` and published its image. No live NAS
+deployment is claimed.
## 30 September 2026 — Semuta after accepted CHOAM Jubba Cloak cost
diff --git a/docs/NEXUS_CHOAM_RULES.md b/docs/NEXUS_CHOAM_RULES.md
index 1f28aec7..5aceb219 100644
--- a/docs/NEXUS_CHOAM_RULES.md
+++ b/docs/NEXUS_CHOAM_RULES.md
@@ -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 ordinary engine effect handlers; Kull Wahad retains its separate unfinished reaction subsystem.
+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.
| Chosen effect | Printed outcome and timing |
| --- | --- |
@@ -39,10 +39,10 @@ The effect choice and native use may be public without revealing the rest of CHO
## Existing boundaries to preserve
-- **Kull:** [the existing source update](CHOAM_KULL_SOURCE_UPDATE.md) leaves counter priority, BG conversion timing, interrupted-card custody and unfunded overbid recovery unresolved. Cunning does not settle those interactions. No new question was sent. Omitting Kull from a supported five-effect checkpoint must be explicit; it cannot be presented as all CHOAM Cunning.
+- **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.
- **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. Kull retains its separate pending policy.
+- **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.
## Pure saved receipt and integration contract
@@ -65,7 +65,7 @@ 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 still-gated effect. Historical validation neither reads private hands nor requires the card still held, CHOAM still unallied or the old Nexus still in discard. The engine owns canonical card validation, held Nexus/unallied eligibility, exact original effect source and physical custody.
+[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.
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.
@@ -73,4 +73,4 @@ Integrate through the existing native effect validator with an explicit selected
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.
-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 is a named remaining effect until its own rules and runtime are resolved. No completed engine integration or gameplay verification is claimed here.
+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.
diff --git a/docs/NEXUS_CHOAM_RUNTIME.md b/docs/NEXUS_CHOAM_RUNTIME.md
index 4900b292..d9dcf7bb 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 existing 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's Karama-prevention reaction remains explicitly unavailable. CHOAM Secret Ally and Betrayal remain outside this implementation.
+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.
## Physical cost and timing
@@ -20,11 +20,11 @@ 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. Kull Wahad has a visible unavailable explanation. Public response text identifies the chosen effect rather than disclosing the private cost card's name.
+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.
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. The existing fresh-discard/Semuta priority remains unfinished and gated; this checkpoint does not implement a Semuta interruption for these effects.
+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
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 44cb08d2..8267eb95 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,6 +1,6 @@
# Rule decision index
-Updated 14 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
+Updated 30 September 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Recorded contracts and implementation boundaries
@@ -47,7 +47,7 @@ Updated 14 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 Karama counter while the interrupted card stays reserved, and interception of BG Worthless-as-Karama before conversion/discard. A prevented unplayable winning overbid remains guarded; no remedy was approved. Full CHOAM starts remain gated. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-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) |
| 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
new file mode 100644
index 00000000..4b088248
--- /dev/null
+++ b/game/bot-choam-kull.ts
@@ -0,0 +1,35 @@
+import type { Action, GameView } from './engine';
+
+/** Legal participation uses only this seat's projected offer and public allies. */
+export function botChoamKullActions(g: GameView): Action[] {
+ const offer = g.kullReaction;
+ if (g.status !== 'playing' || !offer?.event || offer.player !== g.me ||
+ !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]
+ : undefined;
+ return card
+ ? [{ type: 'kullDecision', event: offer.event, card: card.id }]
+ : [{ type: 'kullDecision', event: offer.event, decline: true }];
+}
+
+/** Kull's counter is an ordinary response: never reuse the reserved attempt or
+ * propose voluntary actions while the public prevention is being settled. */
+export function botChoamKullCounterActions(g: GameView): Action[] {
+ if (g.status !== 'playing' || !g.kullCounterEvent || !g.response ||
+ g.responseControls?.hasPassed) return [];
+ const own = g.players.find((player) => player.id === g.me);
+ if (!(own?.bot ?? own?.autopilot)) return [];
+ const card = own.hand?.find((candidate) =>
+ g.responseControls?.cancelCards.includes(candidate.id),
+ );
+ if (!card) return [];
+ const protect = g.response.kind === 'choamWorthless' &&
+ g.response.intent === 'Kull Wahad' &&
+ (g.response.recipient === own.id || g.response.recipient === own.ally);
+ return protect
+ ? [{ type: 'card', mode: 'cancel', card: card.id }]
+ : [{ type: 'passResponse' }];
+}
diff --git a/game/bots.ts b/game/bots.ts
index 0a5a2551..3631aae3 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -102,6 +102,7 @@ import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
import { botSemutaActions } from './bot-semuta';
+import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -1275,6 +1276,7 @@ function policyActions(g: GameView): Action[] {
}
if (
!g.battle &&
+ !g.karamaBlocked &&
g.choamCashIn?.karamas.length &&
(me.spice ?? 0) < (level === 0 ? 2 : 5)
) {
@@ -1321,6 +1323,7 @@ function policyActions(g: GameView): Action[] {
const sapho = saphoAction(g);
if (sapho) return [sapho];
const specialCard =
+ !g.karamaBlocked &&
!g.choamMarket &&
(!g.revival.pending ||
(me.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop')) &&
@@ -2638,7 +2641,7 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'auctionPayment') {
const karama = me.hand?.find((c) =>
- canUseAsKaramaRole(g, me, c),
+ !g.karamaBlocked && canUseAsKaramaRole(g, me, c),
);
const use =
!!karama &&
@@ -3823,6 +3826,9 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
+ if (g.kullReaction) return botChoamKullActions(g);
+ if (g.kullCounterEvent)
+ return botChoamKullCounterActions(g);
if (g.semutaReaction) return botSemutaActions(g);
const fremenRevival = g.nexusFremenRevival;
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
diff --git a/game/choam-kull.ts b/game/choam-kull.ts
new file mode 100644
index 00000000..080d5b72
--- /dev/null
+++ b/game/choam-kull.ts
@@ -0,0 +1,167 @@
+import type { Card } from './cards';
+
+export type KullPhaseRestriction = Readonly<{
+ player: string;
+ turn: number;
+ phase: number;
+}>;
+
+export type KullContext = Readonly<{
+ turn: number;
+ phase: number;
+ players: readonly Readonly<{ id: string; faction: string }>[];
+}>;
+
+/** Only intrinsic attempt data; the engine owns the typed intent and continuation. */
+export type KullAttemptStamp = Readonly<{
+ event: string;
+ player: string;
+ owner: string;
+ card: string;
+ turn: number;
+ phase: number;
+ form: 'printed' | 'substitution';
+ stage: 'offer' | 'counter';
+}>;
+
+function identifier(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0 &&
+ !['__proto__', 'constructor', 'prototype'].includes(value);
+}
+
+function stamp(turn: unknown, phase: unknown): boolean {
+ return Number.isSafeInteger(turn) && (turn as number) > 0 &&
+ Number.isSafeInteger(phase) && (phase as number) >= 0 && (phase as number) <= 8;
+}
+
+function plainRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+}
+
+function validRestriction(value: unknown): value is KullPhaseRestriction {
+ return plainRecord(value) &&
+ Object.keys(value).sort().join(',') === 'phase,player,turn' &&
+ identifier(value.player) && stamp(value.turn, value.phase);
+}
+
+function validContext(context: KullContext): boolean {
+ return !!context && stamp(context.turn, context.phase) &&
+ Array.isArray(context.players) && context.players.length > 0 &&
+ context.players.every(player => player && identifier(player.id) && identifier(player.faction)) &&
+ new Set(context.players.map(player => player.id)).size === context.players.length;
+}
+
+/** Expiry is a turn AND phase comparison, not a ban on physical ownership. */
+export function activeKullRestrictions(
+ restrictions: readonly KullPhaseRestriction[] | undefined,
+ turn: number,
+ phase: number,
+): KullPhaseRestriction[] {
+ if (!stamp(turn, phase)) return [];
+ return (restrictions ?? [])
+ .filter(restriction => validRestriction(restriction) &&
+ restriction.turn === turn && restriction.phase === phase)
+ .map(({ player, turn: savedTurn, phase: savedPhase }) =>
+ ({ player, turn: savedTurn, phase: savedPhase }));
+}
+
+export function kullBlocksKarama(
+ restrictions: readonly KullPhaseRestriction[] | undefined,
+ turn: number,
+ phase: number,
+ player: string,
+): boolean {
+ return identifier(player) && stamp(turn, phase) &&
+ !!restrictions?.some(restriction => validRestriction(restriction) &&
+ restriction.player === player && restriction.turn === turn && restriction.phase === phase);
+}
+
+/** Saved history may expire, but malformed, duplicate or future bans are not history. */
+export function validateKullPhaseRestrictions(
+ context: KullContext,
+ restrictions: unknown,
+): asserts restrictions is readonly KullPhaseRestriction[] {
+ if (!validContext(context) || !Array.isArray(restrictions))
+ throw new Error('Kull Wahad has lost its phase restriction history.');
+ const seen = new Set();
+ for (const restriction of restrictions) {
+ if (!validRestriction(restriction) ||
+ !context.players.some(player => player.id === restriction.player) ||
+ restriction.turn > context.turn ||
+ (restriction.turn === context.turn && restriction.phase > context.phase))
+ throw new Error('Kull Wahad has lost its phase restriction history.');
+ const key = JSON.stringify([restriction.player, restriction.turn, restriction.phase]);
+ if (seen.has(key)) throw new Error('Kull Wahad has duplicate phase restrictions.');
+ seen.add(key);
+ }
+}
+
+/** Extra frame fields belong to the engine's intent/signature validator, not this stamp. */
+export function validateKullAttemptStamp(
+ context: KullContext,
+ attempt: KullAttemptStamp,
+ physicalCards: readonly Card[],
+): void {
+ if (!validContext(context) || !plainRecord(attempt) ||
+ !identifier(attempt.event) || !identifier(attempt.player) ||
+ !identifier(attempt.owner) || !identifier(attempt.card) ||
+ attempt.player === attempt.owner ||
+ !context.players.some(player => player.id === attempt.player && player.faction === 'choam') ||
+ !context.players.some(player => player.id === attempt.owner) ||
+ !stamp(attempt.turn, attempt.phase) ||
+ attempt.turn !== context.turn || attempt.phase !== context.phase ||
+ (attempt.stage !== 'offer' && attempt.stage !== 'counter') ||
+ (attempt.form !== 'printed' && attempt.form !== 'substitution') ||
+ !Array.isArray(physicalCards) ||
+ !uniquePhysicalCard(physicalCards, attempt.card))
+ throw new Error('Kull Wahad has lost its original attempt, phase or physical card.');
+}
+
+/** The reserved original cannot pay for a second activation, even after JSON reload. */
+export function distinctKullCounter(cardId: string, reservedCardId: string): boolean {
+ return identifier(cardId) && identifier(reservedCardId) && cardId !== reservedCardId;
+}
+
+/** Missing or duplicated custody is not a usable physical identity. */
+function uniquePhysicalCard(cards: readonly Card[], id: string): Card | undefined {
+ let found: Card | undefined;
+ for (const candidate of cards) {
+ if (candidate?.id !== id) continue;
+ if (found) return undefined;
+ found = candidate;
+ }
+ return found;
+}
+/** Both the hand and the supplied conserved physical inventory must contain one identity. */
+function uniquelyHeld(card: Card, held: readonly Card[], physicalCards: readonly Card[]): boolean {
+ if (!identifier(card.id) || !uniquePhysicalCard(held, card.id)) return false;
+ const canonical = uniquePhysicalCard(physicalCards, card.id);
+ return !!canonical && card.name === canonical.name &&
+ card.kind === canonical.kind && card.effect === canonical.effect;
+}
+
+/** Native printed Kull only. Nexus fuel is a separate, engine-authorized cost path. */
+export function kullNativeCostCards(
+ held: readonly Card[],
+ physicalCards: readonly Card[],
+ reservedIds: readonly string[] = [],
+): Card[] {
+ return held.filter(card => card?.id === 'ix-kull-wahad' &&
+ card.name === 'Kull Wahad' && card.kind === 'worthless' && card.effect === undefined &&
+ !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(
+ held: readonly Card[],
+ physicalCards: readonly Card[],
+ reservedCardId: string,
+ isEffectivelyEligible: (card: Card) => boolean,
+ reservedIds: readonly string[] = [],
+): Card[] {
+ return held.filter(card => card && distinctKullCounter(card.id, reservedCardId) &&
+ !reservedIds.includes(card.id) && uniquelyHeld(card, held, physicalCards) &&
+ isEffectivelyEligible(card));
+}
diff --git a/game/choam-worthless-cancellation.ts b/game/choam-worthless-cancellation.ts
index 6e782810..4516395c 100644
--- a/game/choam-worthless-cancellation.ts
+++ b/game/choam-worthless-cancellation.ts
@@ -42,6 +42,7 @@ const effects = {
gamont: { phase: 8, name: 'Trip to Gamont' },
baliset: { phase: 5, name: 'Baliset' },
jubba: { phase: 0, name: 'Jubba Cloak' },
+ kull: { phase: null, name: 'Kull Wahad' },
} as const;
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
@@ -84,7 +85,8 @@ export function quoteChoamWorthlessCancellation(
'The denied Worthless effect needs its original CHOAM declaration.',
);
requireDeclaration(
- g.phase === effects[p.effect].phase &&
+ whole(g.phase) && g.phase <= 8 &&
+ (p.effect === 'kull' || g.phase === effects[p.effect].phase) &&
response.intent === effects[p.effect].name,
'The Worthless effect does not match its phase and declared printed name.',
);
@@ -148,6 +150,13 @@ export function quoteChoamWorthlessCancellation(
p.elite === 0)),
'Denied Gamont needs its original opposing sector and selected force type.',
);
+ } else if (p.effect === 'kull') {
+ requireDeclaration(
+ target && target.id !== p.owner &&
+ p.location === undefined && response.location === undefined &&
+ p.elite === undefined && !flags.some(flag => flag === true),
+ 'Denied Kull needs the original Karama actor and no unrelated continuation.',
+ );
} else {
const loc = key(response.location);
requireDeclaration(
diff --git a/game/engine.ts b/game/engine.ts
index 2028dda6..6340d4f5 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -464,6 +464,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 { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
@@ -1078,6 +1079,24 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
+ /** Explicit development profile; never enabled by a public start or player action. */
+ kullPreview?: boolean;
+ kullSequence?: number;
+ kullRestrictions?: KullPhaseRestriction[];
+ pendingKull?: {
+ event: string;
+ player: string;
+ owner: string;
+ card: string;
+ turn: number;
+ phase: number;
+ form: 'printed' | 'substitution';
+ stage: 'offer' | 'counter';
+ intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
+ resume: NonNullable['resume'];
+ worthless: Game['pendingChoamWorthless'];
+ signature: string;
+ } | null;
stormMovementSource?: StormMovementSource;
ecologicalStorm?: DiscoveryStorm;
discoveryEnabled?: boolean;
@@ -1576,7 +1595,7 @@ export type Game = {
pendingChoamWorthless?: {
owner: string;
card: string;
- effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
+ effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba' | 'kull';
storm?: boolean;
movement?: boolean;
location?: string;
@@ -8142,6 +8161,17 @@ export function initializeSemutaGameForAudit(state: Game): Game {
g.semutaPreview = true;
return g;
}
+/** 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 &&
+ 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'),
+ 'Kull preview needs a fresh CHOAM and classic-faction lobby with CHOAM and Ix decks.');
+ const g = initializeFactionExpansionsGameForAudit(state);
+ g.kullPreview = true;
+ 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,
@@ -10327,6 +10357,11 @@ function nextAuction(g: Game) {
}
/** Physical custody only: promise feasibility remains conservatively checked at commit. */
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))
+ 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.';
if (g.battle?.lateDefense?.[p.id] === card.id)
@@ -10364,6 +10399,17 @@ function responseCancelCards(
response: ResponseWindow,
): string[] {
if (p.id === response.owner) return [];
+ if (g.pendingKull?.stage === 'counter')
+ return kullCounterCards(p.hand, physicalTreacheryCards(g), g.pendingKull.card,
+ card => {
+ if (!canUseAsKaramaRole(g, p, card) || karamaSpendingBlock(g, p, card)) return false;
+ try {
+ return assertKaramaPromiseFeasibility(g, p, { kind: 'cancel', response }, card);
+ } catch (error) {
+ if (error instanceof RuleError) return false;
+ throw error;
+ }
+ }).map(card => card.id);
// Do not simulate cancellation here: it can execute RNG and continuations.
// A physically spendable card whose Truthtrance feasibility is not proven
// remains a possible choice; the authoritative action still checks promises.
@@ -10384,6 +10430,7 @@ function savedKaramaContexts(g: Game) {
g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null,
+ g.pendingKull?.resume,
].flatMap((context) =>
context?.pendingKarama
? [
@@ -10436,6 +10483,8 @@ function karamaSourceGame(
g: Game,
pending: NonNullable,
) {
+ if (g.pendingKull && controlsContainKarama(g, g.pendingKull.resume, pending))
+ return kullSource(g, g.pendingKull);
const worm = g.summonedWorm;
return worm && controlsContainKarama(g, worm.resume, pending)
? { ...g, ...worm.resume, summonedWorm: null }
@@ -11106,11 +11155,189 @@ function assertKaramaPromiseFeasibility(
);
return true;
}
-function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
+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));
+}
+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 kullSource(g: Game, frame: KullFrame): Game {
+ return { ...g, ...frame.resume, pendingKull: null, pendingChoamWorthless: frame.worthless };
+}
+function kullSignature(g: Game, frame: KullFrame): string {
+ const source = kullSource(g, frame);
+ 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,
+ 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,
+ auction: source.auction, battle: source.battle, movementRemaining: source.movementRemaining,
+ karamaShipping: source.karamaShipping, spiceWindow: source.spiceWindow,
+ spiceResolution: source.spiceResolution, spiceSequence: source.spiceSequence,
+ stormResolution: source.stormResolution, worthless: frame.worthless,
+ });
+}
+function kullIntegrity(g: Game): void {
+ try {
+ if (g.kullRestrictions !== undefined) validateKullPhaseRestrictions(g, g.kullRestrictions);
+ const frame = g.pendingKull;
+ if (!frame) {
+ const conversion = g.pendingKarama?.use;
+ requireRule(g.pendingChoamWorthless?.effect !== 'kull' &&
+ !(g.response?.kind === 'choamWorthless' && g.response.intent === 'Kull Wahad') &&
+ !(conversion?.kind === 'cancel' &&
+ conversion.response.kind === 'choamWorthless' &&
+ conversion.response.intent === 'Kull Wahad'),
+ 'The saved Kull declaration has lost its interrupted Karama frame.');
+ return;
+ }
+ requireRule(g.kullPreview === true, 'This table has not opted into the Kull preview.');
+ validateKullAttemptStamp(g, frame, physicalTreacheryCards(g));
+ requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 &&
+ frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]),
+ 'The Kull attempt has lost its server-created opportunity identity.');
+ 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);
+ 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'),
+ 'The Kull attempt no longer matches its original physical card form.');
+ requireRule(!karamaSpendingBlock(source, owner, card), 'The original Karama activation is no longer legal.');
+ if (frame.intent?.kind === 'ordinary') {
+ validateKaramaUse(source, owner, frame.intent.use, card);
+ requireRule(assertKaramaPromiseFeasibility(source, owner, frame.intent.use, card),
+ 'The interrupted Karama has no proven Truthtrance continuation.');
+ } else {
+ requireRule(frame.intent?.kind === 'special', 'The Kull attempt has no validated intent.');
+ const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(frame.intent.use));
+ requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use),
+ 'The saved special Karama selection has changed.');
+ }
+ if (frame.stage === 'offer')
+ 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 {
+ 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 &&
+ 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),
+ 'The Kull counter no longer matches its held cost and original attempted player.');
+ currentChoamWorthlessCancellationQuote(g, response);
+ }
+ } catch (error) {
+ if (error instanceof RuleError) throw error;
+ throw new RuleError(error instanceof Error ? error.message : 'Invalid saved Kull transaction.');
+ }
+}
+function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['intent'], promiseProven = true): boolean {
+ if (!g.kullPreview || g.pendingKull) return false;
+ const choam = byFaction(g, 'choam');
+ if (!choam || choam.id === p.id) return false;
+ requireRule(promiseProven,
+ 'Kull preview cannot interrupt this unproven Truthtrance cancellation continuation.');
+ // This composition is user-deferred. The guard is private and independent
+ // of CHOAM's hidden hand; never accept a transaction needing a made-up remedy.
+ requireRule(!g.auction || g.auction.bidder !== p.id ||
+ g.auction.bid <= p.spice + (g.auction.allyPayment ?? 0),
+ 'Kull preview defers Karama activation while your winning overbid cannot be paid in spice.');
+ const resume: KullFrame['resume'] = {
+ response: g.response, decision: g.decision, pendingKarama: g.pendingKarama ?? null,
+ phaseOpening: g.phaseOpening,
+ };
+ if (intent.kind === 'ordinary' && intent.use.kind === 'auctionPayment')
+ resume.decision = { kind: 'auctionPayment', player: p.id };
+ const frame: KullFrame = {
+ event: JSON.stringify(['kull', g.turn, g.phase, (g.kullSequence ?? 0) + 1, choam.id, p.id]),
+ player: choam.id, owner: p.id, card: card.id,
+ turn: g.turn, phase: g.phase, form: card.kind === 'worthless' ? 'substitution' : 'printed',
+ stage: 'offer', intent: structuredClone(intent), resume: structuredClone(resume),
+ worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
+ };
+ // Payment dispatch removed this control before entering its native branch.
+ g.decision = resume.decision;
+ frame.signature = kullSignature(g, frame);
+ g.kullSequence = (g.kullSequence ?? 0) + 1;
+ g.pendingKull = frame;
+ log(g, `${p.name} attempted a Karama activation. CHOAM may react with Kull Wahad.`);
+ return true;
+}
+function resumeKullAttempt(g: Game, prevented: boolean): void {
+ const frame = g.pendingKull!;
+ Object.assign(g, frame.resume);
+ g.pendingChoamWorthless = frame.worthless;
+ // Retire ownership before executing. Neither resumed execution nor a saved
+ // retry can reopen this exact opportunity or consume its physical card twice.
+ g.pendingKull = null;
+ if (prevented) return;
+ const p = getPlayer(g, frame.owner);
+ if (frame.intent.kind === 'special') executeSpecialKaramaIntent(g, frame.intent.use);
+ else {
+ if (frame.intent.use.kind === 'auctionPayment') g.decision = null;
+ spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true);
+ }
+}
+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)),
+ 'Choose the current Kull opportunity as CHOAM.');
+ if (action.decline === true) {
+ requireRule(action.card === undefined, 'Decline or choose one Kull 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.');
+ 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,
+ };
+ g.response = { kind: 'choamWorthless', owner: p.id, recipient: frame.owner,
+ intent: 'Kull Wahad', passed: [] };
+ 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) {
const blocked = karamaSpendingBlock(g, p, card);
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
validateKaramaUse(g, p, use, card);
- assertKaramaPromiseFeasibility(g, p, use, card);
+ const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card);
+ requireRule(!g.pendingKull || promiseProven,
+ 'Kull preview cannot accept this unproven Truthtrance counter continuation.');
+ if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return;
const used = discard(g, p, card.id);
if (g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' &&
@@ -17828,6 +18055,7 @@ function reconcileShipmentPromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
+ if (g.pendingKull) return;
const kept: ShipmentPromise[] = [];
for (const promise of g.shipmentPromises ?? []) {
if (promise.turn !== g.turn || promise.released || promise.fulfilled)
@@ -17890,6 +18118,7 @@ function reconcileBattlePromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
+ if (g.pendingKull) return;
const b = g.battle;
if (
!b ||
@@ -19864,6 +20093,7 @@ function auditorIntegrity(g: Game) {
const contexts = [
g,
g.pendingNullentropy?.resume,
+ g.pendingKull?.resume,
g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' ||
g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard'
? g.pendingTreacheryDiscard.continuation.resume
@@ -20560,6 +20790,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
+ if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
@@ -20656,6 +20887,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
else if (declaration.resume.kind === 'movement') resumeChoamMovement(g);
else if (declaration.resume.kind === 'mentat')
g.decision = declaration.resume.decision;
+ if (g.pendingKull?.stage === 'counter' && declaration.effect === 'kull')
+ resumeKullAttempt(g, false);
return;
}
if (auctionCancellation) {
@@ -20907,6 +21140,13 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
) {
const used = discard(g, choam, pending.card);
finishNexusChoam(g,pending,'complete');
+ if (pending.effect === 'kull') {
+ g.kullRestrictions = [...activeKullRestrictions(g.kullRestrictions, g.turn, g.phase),
+ { player: g.pendingKull!.owner, turn: g.turn, phase: g.phase }];
+ log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate Karama for this phase. The attempted card stays held.`);
+ resumeKullAttempt(g, true);
+ return;
+ }
if (pending.effect === 'kulon') {
if (g.semutaPreview && used.name === 'Kulon' &&
!pending.storm && !pending.revival && !pending.movement &&
@@ -23306,11 +23546,11 @@ function choamPowerPlays(g: Game, p: Player) {
const printed = p.hand.filter(card => card.kind === 'worthless' &&
Object.values(CHOAM_NEXUS_EFFECTS).includes(card.name as typeof CHOAM_NEXUS_EFFECTS[NexusChoamEffect]) && !choamWorthlessBlocked(g,card.id))
.map(card => ({source:'printed' as const,card,effect:Object.entries(CHOAM_NEXUS_EFFECTS).find(([,name]) => name === card.name)![0] as NexusChoamEffect,
- blocked:card.name === 'Kull Wahad' ? 'Kull Wahad’s reaction and discard sequence is still being implemented.' : null}));
+ blocked:card.name === 'Kull Wahad' ? 'Kull Wahad is reactive: use the Kull opportunity when another player attempts Karama in the explicit preview.' : null}));
if (p.faction !== 'choam' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'choam') return printed;
return [...printed, ...p.hand.flatMap(card => (Object.keys(CHOAM_NEXUS_EFFECTS) as NexusChoamEffect[]).map(effect => {
let blocked: string | null = null;
- if (effect === 'kull') blocked = 'Kull Wahad’s reaction and discard sequence is still being implemented.';
+ if (effect === 'kull') blocked = 'Kull Wahad via CHOAM Cunning is unavailable in this printed-card preview.';
else if (g.status !== 'playing' || g.truthtrance || g.response || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.choamMarket)
blocked = 'Finish the current interaction before declaring CHOAM Cunning.';
else if (choamWorthlessBlocked(g,card.id)) blocked = 'This card’s special-effect use is blocked for this phase.';
@@ -23610,6 +23850,8 @@ export function prepareSpecialKaramaIntent(
(c) => c.id === action.card && canUseAsKaramaRole(g, p, c),
);
requireRule(card, 'Choose a Karama card in your hand.');
+ const activationBlock = karamaSpendingBlock(g, p, card);
+ requireRule(!activationBlock, activationBlock ?? 'This Karama is committed elsewhere.');
const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
if (p.faction === 'richese') {
const blocked = karamaSpendingBlock(g, p, card);
@@ -24141,7 +24383,10 @@ export function executeSpecialKaramaIntent(
}
}
function specialKarama(g: Game, p: Player, action: Action) {
- executeSpecialKaramaIntent(g, prepareSpecialKaramaIntent(g, p.id, action));
+ const intent = prepareSpecialKaramaIntent(g, p.id, action);
+ const card = p.hand.find(card => card.id === intent.card)!;
+ if (!beginKullAttempt(g, p, card, { kind: 'special', use: intent }))
+ executeSpecialKaramaIntent(g, intent);
}
/** Private, read-only choices; validation performs no draws or continuations. */
@@ -24234,6 +24479,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -24316,6 +24562,10 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ if (g.pendingKull) {
+ settleAutomaticContinuations(g);
+ if (g.pendingKull) { kullIntegrity(g); return g; }
+ }
finishLeaderSkillCustody(g, state);
observeOccupation(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -24441,6 +24691,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g;
}
function finishActionContinuations(g: Game) {
+ if (g.pendingKull) return;
finishLeaderSkillCustody(g);
finishMandatorySkillVisibility(g);
if (g.pendingNullentropy) return;
@@ -24502,6 +24753,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
+ if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
@@ -24541,6 +24793,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -24573,6 +24826,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
+ if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
battleSpecialWeaponIntegrity(g);
@@ -24683,6 +24937,15 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
+ if (g.pendingKull) {
+ if (t === 'advanceBots') return g;
+ if (g.pendingKull.stage === 'offer') {
+ decideKull(g, p, action);
+ return g;
+ }
+ requireRule(t === 'passResponse' || (t === 'card' && action.mode === 'cancel'),
+ 'Finish the Kull counter response before another action.');
+ } else requireRule(t !== 'kullDecision', 'This Kull opportunity is no longer pending.');
if (t === 'advanceBots') return g;
requireRule(!g.bureaucratPayments?.pending || t === 'decision','Resolve the pending Bureaucrat payment before another action.');
requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision',
@@ -27002,11 +27265,16 @@ function applyActionInner(
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
- maximum: karamaCard(g, p)
+ maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER
: p.spice + (aidFor(g, p)?.amount ?? 0),
});
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
+ requireRule(!g.kullPreview ||
+ (p.id === byFaction(g, 'choam')?.id &&
+ !kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) ||
+ quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
+ 'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
a.bid = quotedBid.amount;
a.allyPayment =
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
@@ -28427,6 +28695,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -28927,6 +29196,16 @@ export function viewGame(state: Game, id: string) {
? g.stormDials
: null,
me: id,
+ kullPreview: !!g.kullPreview,
+ 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) : [],
+ canDecline: id === g.pendingKull.player, blocked: null as string | null }
+ : null,
choamWorthless:
me.faction === 'choam'
? {
@@ -29055,7 +29334,7 @@ export function viewGame(state: Game, id: string) {
g.moritaniRetention.keep,
) ?? null)
: null,
- decision:
+ decision: g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
@@ -29067,7 +29346,7 @@ export function viewGame(state: Game, id: string) {
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
- response: g.response
+ response: g.pendingKull?.stage === 'offer' ? null : g.response
? {
...g.response,
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
@@ -29091,7 +29370,7 @@ export function viewGame(state: Game, id: string) {
}
: null,
paymentIncome: currentFactionPayment(g),
- responseControls: g.response
+ responseControls: g.pendingKull?.stage === 'offer' ? undefined : g.response
? {
cancelCards: responseCancelCards(g, me, g.response),
hasPassed: g.response.passed.includes(id),
diff --git a/game/faction-reference.ts b/game/faction-reference.ts
index 711df0d8..af884397 100644
--- a/game/faction-reference.ts
+++ b/game/faction-reference.ts
@@ -14,10 +14,11 @@ export const FACTION_RULES: Record<
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
- 'Kull Wahad and combined faction interactions remain unfinished. The available development powers do not enable complete CHOAM games.',
+ 'In the opt-in Kull preview, react to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. See the Kull guide for deferred overbid limits. Full CHOAM starts remain closed.',
],
advanced: [
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
+ 'The Kull preview also intercepts supported special Karama intents before once-use or other costs, and BG Worthless substitutions before conversion. Successful Kull leaves an attempted special unspent and the original BG card held; ordinary later cancellation of BG conversion still discards its card. Approved preview timing is product policy, not publisher clarification, and does not enable Nexus Cunning Kull or combined modules.',
'Reserve spice to pay for some or all of your ally’s battle support. Each sealed plan records its own and allied shares. A Karama response may prevent this alliance benefit for one battle; unused funding returns at the end of Battle.',
'Receive half of each other player’s actual force-support payment, rounded down, unless any traitor is revealed. Your own payments, including support paid for your ally, go to the bank. A separate Karama response can cancel this battle’s income. See CHOAM combat for examples and outstanding interaction audits.',
'The Auditor is an additional strength-two leader. After using it in battle, CHOAM may inspect two randomly selected unused opposing Treachery Cards if it survives, or one if it dies, limited by the unused cards available. The opponent may pay CHOAM one spice per card that would be inspected to prevent the whole inspection; Karama can also cancel the power. Use the Auditor guide for the exact private choices and limits. Setup and inspection are supported in development games; complete combined play remains unfinished.',
diff --git a/game/reference.ts b/game/reference.ts
index cce731a9..c6590098 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -72,7 +72,7 @@ export const HELP = {
summonedWorm:
'Fremen’s special Karama summons a worm in sand once per game during Spice Blow and Nexus. Resolve destruction and protection immediately, then resume the spice blow. The worm causes a Nexus at the end of that blow, followed by Fremen rides. Calling the worm does not draw or discard a spice card.',
choamWorthless:
- 'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents a player’s free force revival for the phase. Baliset blocks a player’s movement into a CHOAM-occupied territory while permitting shipment. Trip to Gamont returns another player’s force to reserves during Mentat, before victory. Jubba Cloak protects CHOAM’s forces in one territory during a storm movement. A Karama response precedes discarding these cards; canceled cards remain in hand.',
+ 'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the opt-in Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
choamCombat:
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
fullPlan:
@@ -2421,8 +2421,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
'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'],
+ related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull'],
},
{
id: 'truthtrance-forces',
@@ -2741,7 +2742,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-combat'] : []),
+ ...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-kull', 'choam-combat'] : []),
...(f.id === 'ecaz'
? ['ecaz-loyalty', 'ecaz-ambassadors', 'duke-vidal', 'implementation-checklist']
: []),
@@ -3307,7 +3308,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
- 'Use Kulon for range, La La La to deny free revival, Baliset to prevent enemy movement, Jubba Cloak for storm protection or Trip to Gamont to return a force.',
+ 'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s opt-in Karama reaction.',
steps: [
'These CHOAM abilities work in basic and advanced games. Choose a printed Worthless card from the phase controls. Its name and intended effect are announced, then a Karama response occurs before discard. A canceled card remains in hand and cannot be retried for its effect during that phase.',
'On your own Shipment and Movement turn, play Kulon while a movement remains. It adds one territory to your movement range, including ornithopters. It does not add another movement action, bypass the storm or relax alliance/stronghold entry rules. The current implementation applies the bonus to both movements if Hajr is used; this combination remains under audit.',
@@ -3315,12 +3316,13 @@ 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 now have engine, private controls and AI support. See the linked Trip to Gamont topic for force selection and victory timing. See Jubba Cloak for protection during storm movement. Kull Wahad still needs its special effect. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, ordering of simultaneous prevention powers, and later module exceptions remain under audit. Full faction 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; 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.',
],
related: [
'faction-choam',
'choam-gamont',
'choam-jubba',
+ 'choam-kull',
'choam-market',
'choam-revival',
'movement',
@@ -3328,6 +3330,35 @@ export const RULE_TOPICS: RuleTopic[] = [
'card-karama',
],
},
+ {
+ id: 'choam-kull',
+ title: 'Kull Wahad: Karama interception preview',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ 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.',
+ '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.',
+ 'A successful distinct counter spends or converts its own card under ordinary rules. Kull then stays held and its exact physical card cannot use its CHOAM effect again that phase. No Karama ban is established on the original player, and the reserved original attempt resumes once with its normal costs. Prevention is not a refund of a counter card.',
+ '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.',
+ ],
+ 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'],
+ 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'] },
+ { area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused engine, controls, all-profile legal bots and authenticated SQLite cover saved offer/counter/decline, exact custody, promises, orphan rejection and profile safeguards. Actual phone-width Basic/Advanced seats exercised Kull use, distinct counter and refreshed decline through the table. Complete combined games, proxy-free checkpoint HTTP and deployed acceptance remain separate.', evidence: ['tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/prototype-room.test.ts'] },
+ ],
+ },
{
id: 'choam-combat',
title: 'CHOAM combat funding and force income',
@@ -3364,7 +3395,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In the advanced game, use the special Karama controls above your private hand. Choose the activating Karama and one or more other cards. Each selected card earns three spice, including Worthless cards or a second Karama.',
'The activating Karama is spent separately and earns no spice in this implementation. All selected cards must be distinct and still in your hand. The entire operation succeeds or changes nothing.',
'Cards fixed in your sealed battle plan or committed to prescience cannot be cashed in. You must also retain enough spice, committed allied funding or another Karama to honor a current winning auction bid.',
- 'Cash-in takes effect immediately and preserves a pending phase opening, revival, response, market sale or allied exchange. If it consumes a card already offered for a market sale or trade, that transaction later pays nothing or moves neither card.',
+ 'Cash-in takes effect immediately and preserves a pending phase opening, revival, response, market sale or allied exchange. If it consumes a card already offered for a market sale or trade, that transaction later pays nothing or moves neither card. In the Kull preview, a declared original or counter cost remains reserved while that transaction is pending.',
'The battle preparation guide can find a cash-in that funds a promised plan while retaining required cards. Complete the listed preparation before reviewing and sealing the plan. This can combine with Ghola preparation when both remain possible.',
'The power is available in all nine playing phases. Finish an active Truthtrance question first. Its precise timing against other simultaneous effects, activation-card payment interpretation and remaining expansion interactions are still under audit; full CHOAM starts remain disabled.',
],
@@ -3372,6 +3403,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'faction-choam',
'choam-market',
'card-karama',
+ 'choam-kull',
'card-truthtrance',
'battle',
'bidding',
@@ -4048,7 +4080,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. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
+ '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.',
'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.',
@@ -4064,7 +4096,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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'],
+ related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull'],
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 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-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, 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/game-table.tsx'] },
diff --git a/game/table-turn.ts b/game/table-turn.ts
index fd0e83ce..e84c50a4 100644
--- a/game/table-turn.ts
+++ b/game/table-turn.ts
@@ -12,12 +12,15 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
- Partial>;
+ Partial>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
+ if (g.kullReaction && g.status === 'playing')
+ return g.players.some(p => p.id === g.kullReaction!.player)
+ ? g.kullReaction.player : null;
if (
g.status !== 'playing' ||
g.automaticContinuationPending ||
diff --git a/tests/bot-choam-kull.test.ts b/tests/bot-choam-kull.test.ts
new file mode 100644
index 00000000..dc5e442a
--- /dev/null
+++ b/tests/bot-choam-kull.test.ts
@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
+
+void test('every profile uses the projected physical Kull and cannot act from a foreign offer', () => {
+ for (const profile of DIFFICULTIES) {
+ const original = choamKullGame();
+ original.players[0].bot = profile;
+ original.deck.push(...original.players[2].hand);
+ original.players[2].hand = [];
+ original.players[3].bot = profile;
+ const attemptedCard = original.players[1].hand[0].id;
+ const saved = JSON.parse(JSON.stringify(applyAction(original, 'e', kullShipmentAttempt(original))));
+ const own = viewGame(saved, 'c');
+ 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.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, 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.
+ const h = viewGame(settled, 'h');
+ assert.deepEqual(botActions(h), [{ type: 'passResponse' }]);
+ settled = applyAction(settled, 'h', botActions(h)[0]);
+ assert.equal(settled.pendingKull, null);
+ assert.equal(settled.karamaShipping, null);
+ assert.ok(settled.players[1].hand.some(card => card.id === attemptedCard));
+ assert.ok(settled.discard.some(card => card.id === 'ix-kull-wahad'));
+ assert.ok(viewGame(settled, 'e').karamaBlocked);
+ }
+});
+
+void test('every profile declines no-cost and allied offers and resumes the original shipment once after saving', () => {
+ for (const profile of DIFFICULTIES) {
+ for (const allied of [false, true]) {
+ const game = choamKullGame();
+ game.players[0].bot = profile;
+ if (allied) {
+ game.players[0].ally = 'e';
+ game.players[1].ally = 'c';
+ } else {
+ game.deck.push(takeKullCard(game, 'ix-kull-wahad'));
+ }
+ const attemptedCard = game.players[1].hand[0].id;
+ const offer = JSON.parse(JSON.stringify(applyAction(game, 'e', kullShipmentAttempt(game))));
+ const own = viewGame(offer, 'c');
+ const action = botActions(own)[0];
+ assert.deepEqual(action, { type: 'kullDecision', event: own.kullReaction!.event, decline: true });
+ const resumed = applyAction(offer, 'c', action);
+ assert.equal(resumed.pendingKull, null);
+ assert.equal(resumed.karamaShipping?.owner, 'e');
+ assert.equal(resumed.karamaShipping?.player, 'e');
+ assert.equal(resumed.discard.filter(card => card.id === attemptedCard).length, 1);
+ assert.ok(!resumed.players[1].hand.some(card => card.id === attemptedCard));
+ }
+ }
+});
+
+void test('every profile counters Kull only with a distinct projected card and resumes the reserved original', () => {
+ for (const profile of DIFFICULTIES) {
+ const game = choamKullGame();
+ game.players[1].bot = profile;
+ const reserved = game.players[1].hand[0].id;
+ const counter = takeKullCard(game, game.players[3].hand[0].id);
+ 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',
+ });
+ const own = viewGame(JSON.parse(JSON.stringify(declared)), 'e');
+ assert.ok(!own.responseControls!.cancelCards.includes(reserved));
+ const actions = botActions(own);
+ assert.deepEqual(actions, [{ type: 'card', mode: 'cancel', card: counter.id }]);
+ const resumed = applyAction(declared, 'e', actions[0]);
+ assert.equal(resumed.pendingKull, null);
+ assert.equal(resumed.karamaShipping?.owner, 'e');
+ for (const id of [reserved, counter.id])
+ assert.equal(resumed.discard.filter(card => card.id === id).length, 1);
+ assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
+ assert.equal(viewGame(resumed, 'e').karamaBlocked, null);
+ }
+});
+
+void test('a blocked projected cost is declined rather than treated as a playable held card', () => {
+ for (const profile of DIFFICULTIES) {
+ const game = choamKullGame();
+ game.players[0].bot = profile;
+ 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', {
+ get() { throw new Error('Read ineligible cost choices'); },
+ });
+ const action = botActions(own)[0];
+ assert.deepEqual(action, { type: 'kullDecision', event: own.kullReaction!.event, decline: true });
+ const resumed = applyAction(offer, 'c', action);
+ assert.equal(resumed.karamaShipping?.owner, 'e');
+ assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
+ }
+});
+
+void test('BG counter conversion keeps all profiles in standard response controls until the saved attempt resumes', () => {
+ for (const profile of DIFFICULTIES) {
+ const game = choamKullGame();
+ game.players[2].bot = profile;
+ game.players[3].bot = profile;
+ game.players[2].ally = 'e';
+ game.players[1].ally = 'b';
+ 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',
+ });
+ const action = botActions(viewGame(declared, 'b'))[0];
+ assert.deepEqual(action, { type: 'card', mode: 'cancel', card: counter });
+ const conversion = applyAction(declared, 'b', action);
+ assert.equal(conversion.response?.kind, 'worthlessKarama');
+ const saved = JSON.parse(JSON.stringify(conversion));
+ const own = viewGame(saved, 'h');
+ assert.equal(own.kullCounterEvent, offer.pendingKull!.event);
+ assert.deepEqual(botActions(own), [{ type: 'passResponse' }]);
+ const resumed = applyAction(saved, 'h', botActions(own)[0]);
+ assert.equal(resumed.pendingKull, null);
+ assert.equal(resumed.karamaShipping?.owner, 'e');
+ assert.equal(resumed.discard.filter(card => card.id === counter).length, 1);
+ assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
+ }
+});
diff --git a/tests/choam-karama.test.ts b/tests/choam-karama.test.ts
index a284859a..d709246b 100644
--- a/tests/choam-karama.test.ts
+++ b/tests/choam-karama.test.ts
@@ -229,13 +229,13 @@ void test('sealed and prescience-committed cards cannot be cashed in or used as
revealed: false,
traitorCalls: {},
};
- assert.throws(() => cash(g, [shield]), /uncommitted/);
+ assert.throws(() => cash(g, [shield]));
assert.ok(!viewGame(g, 'c').choamCashIn?.cards.some((c) => c.id === shield));
g.battle.plans = {};
g.battle.prescience = { player: 'e', field: 'defense', value: shield };
- assert.throws(() => cash(g, [shield]), /uncommitted/);
+ assert.throws(() => cash(g, [shield]));
g.battle.prescience.value = activation(g);
- assert.throws(() => cash(g, [named(g, 'Baliset')]), /uncommitted/);
+ assert.throws(() => cash(g, [named(g, 'Baliset')]));
});
void test('only CHOAM sees its cash-in choices; used status and state survive persistence', () => {
let g = fixture();
diff --git a/tests/choam-kull-controls.test.tsx b/tests/choam-kull-controls.test.tsx
new file mode 100644
index 00000000..24a87415
--- /dev/null
+++ b/tests/choam-kull-controls.test.tsx
@@ -0,0 +1,111 @@
+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 { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, viewGame, type GameView } from '../game/engine';
+import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
+
+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 component imports would resolve Next modules before the Node alias hook.
+const { ChoamKull } = await import('../components/choam-kull');
+const { GameTable } = await import('../components/game-table');
+
+function offerView(owner = 'c'): GameView {
+ const game = choamKullGame();
+ const offer = applyAction(game, 'e', kullShipmentAttempt(game));
+ return viewGame(JSON.parse(JSON.stringify(offer)), owner);
+}
+
+function markup(game: GameView, busy = false): string {
+ return renderToStaticMarkup(createElement(ChoamKull, { game, act() {}, busy }));
+}
+
+void test('only the reactor receives physical Kull cost controls; outsiders cannot inspect offer cards', () => {
+ const holder = offerView();
+ const rival = offerView('h');
+ for (const player of rival.players) {
+ if (player.id === rival.me) continue;
+ Object.defineProperty(player, 'hand', {
+ get() { throw new Error('Opponent private hand read'); },
+ });
+ }
+ const html = markup(holder);
+ assert.match(html, /]*>Decline Kull/);
+ assert.match(html, /]*>Use Kull Wahad \(ix-kull-wahad\)/);
+ assert.match(html, /Inspect card/);
+ const outsider = markup(rival);
+ assert.doesNotMatch(outsider, / {
+ const holder = offerView();
+ const busy = markup(holder, true);
+ assert.match(busy, /disabled=""[^>]*>Decline Kull/);
+ assert.match(busy, /disabled=""[^>]*>Use Kull Wahad/);
+ holder.kullReaction!.blocked = 'The physical cost is reserved.';
+ assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
+ assert.match(markup(holder), /]*>Decline Kull/);
+ holder.kullReaction!.blocked = null;
+ holder.kullReaction!.cards = [];
+ assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
+ assert.match(markup(holder), /]*>Decline Kull/);
+ holder.kullReaction!.canDecline = false;
+ assert.doesNotMatch(markup(holder), / {
+ const holder = offerView();
+ const html = renderToStaticMarkup(createElement(GameTable, {
+ game: holder, send: async () => {}, busy: false, onExit() {},
+ }));
+ 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() {},
+ }));
+ assert.match(disabled, /disabled=""[^>]*>Use Kull Wahad/);
+});
+
+void test('a successful phase ban removes the retained Karama shipment-rate control', () => {
+ const initial = choamKullGame();
+ let g = applyAction(initial, 'e', kullShipmentAttempt(initial));
+ g = applyAction(g, 'c', {
+ type: 'kullDecision', event: g.pendingKull!.event, card: 'ix-kull-wahad',
+ });
+ for (let step = 0; g.pendingKull && step < 12; step++) {
+ const actor = g.players.find(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ });
+ assert.ok(actor);
+ g = applyAction(g, actor.id, { type: 'passResponse' });
+ }
+ assert.equal(g.pendingKull, null);
+ const blocked = viewGame(g, 'e');
+ const render = (game: GameView) => renderToStaticMarkup(createElement(GameTable, {
+ game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy: false, onExit() {},
+ }));
+ assert.ok(blocked.karamaBlocked);
+ assert.doesNotMatch(render(blocked), />Guild rates for/);
+ const available = { ...blocked, karamaBlocked: null };
+ assert.match(render(available), />Guild rates for/);
+});
+
+aliases.deregister();
diff --git a/tests/choam-kull-engine.test.ts b/tests/choam-kull-engine.test.ts
new file mode 100644
index 00000000..d7408165
--- /dev/null
+++ b/tests/choam-kull-engine.test.ts
@@ -0,0 +1,625 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
+import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
+const held = (g: Game, id: string, card: string) => player(g, id).hand.some(c => c.id === card);
+function inventory(g: Game) {
+ const ids = [...g.deck, ...g.discard, ...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 inventory has no duplicate cards');
+ return ids;
+}
+function declare(g: Game) {
+ const reaction = viewGame(g, 'c').kullReaction!;
+ return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, card: 'ix-kull-wahad' });
+}
+function finishKull(g: Game) {
+ for (let step = 0; g.pendingKull && step < 12; step++) {
+ const responder = g.players.find(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ });
+ g = responder ? applyAction(reload(g), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(g));
+ }
+ assert.equal(g.pendingKull, null, 'counter response settles without re-opening the offer');
+ return g;
+}
+function finishConversion(g: Game, kind: NonNullable['kind'] = 'worthlessKarama') {
+ for (let step = 0; g.response?.kind === kind && step < 12; step++) {
+ const responder = g.players.find(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ });
+ g = responder ? applyAction(reload(g), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(g));
+ }
+ assert.notEqual(g.response?.kind, kind);
+ return g;
+}
+function auction(g: Game, bid: number, payment = true) {
+ const lot = g.deck.shift()!;
+ Object.assign(g, { phase: 3, active: 'e', movementRemaining: [],
+ auction: { cards: [lot], index: 0, bid, bidder: payment ? 'e' : null,
+ active: 'e', passed: [], opener: 0 },
+ decision: payment ? { kind: 'auctionPayment', player: 'e' } : null });
+ return lot;
+}
+
+void test('ordinary printed attempts offer neutral CHOAM timing before cost with private projected choices', () => {
+ const g = choamKullGame(), absent = reload(g);
+ const replacement = absent.deck.shift()!;
+ absent.deck.push(...player(absent, 'c').hand);
+ player(absent, 'c').hand = [replacement];
+ const action = kullShipmentAttempt(g), original = action.card as string;
+ const offered = applyAction(g, 'e', action), neutral = applyAction(absent, 'e', action);
+ assert.deepEqual(viewGame(offered, 'e'), viewGame(neutral, 'e'));
+ assert.deepEqual(viewGame(offered, 'h'), viewGame(neutral, 'h'));
+ assert.equal(offered.karamaShipping, null);
+ 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.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(normalizeAutomaticGame(reload(offered)), reload(offered));
+ const event = offered.pendingKull!.event;
+ for (const [id, bad] of [
+ ['h', { type: 'kullDecision', event, decline: true }],
+ ['c', { type: 'kullDecision', event: 'stale', decline: true }],
+ ['c', { type: 'kullDecision', event, decline: true, card: 'ix-kull-wahad' }],
+ ['e', { type: 'ready' }],
+ ] as const) {
+ const before = JSON.stringify(offered);
+ assert.throws(() => applyAction(offered, id, bad));
+ assert.equal(JSON.stringify(offered), before);
+ }
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event, decline: true });
+ assert.deepEqual(declined.karamaShipping, { player: 'e', owner: 'e', card: original });
+ assert.equal(declined.discard.filter(card => card.id === original).length, 1);
+ assert.ok(held(declined, 'c', 'ix-kull-wahad'));
+ assert.throws(() => applyAction(declined, 'c', { type: 'kullDecision', event, decline: true }));
+});
+
+void test('successful Kull retains original card, spends exact cost and blocks activations only until phase expiry', () => {
+ const g = choamKullGame(), cards = inventory(g), original = player(g, 'e').hand[0].id;
+ const starting = g.players.map(p => p.spice);
+ const result = finishKull(declare(applyAction(g, 'e', kullShipmentAttempt(g))));
+ assert.equal(result.karamaShipping, null);
+ assert.ok(held(result, 'e', original));
+ assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ assert.deepEqual(result.players.map(p => p.spice), starting);
+ assert.deepEqual(inventory(result), cards);
+ assert.throws(() => applyAction(result, 'e', kullShipmentAttempt(result)), /Kull Wahad/);
+ const expired = reload(result);
+ expired.turn++;
+ expired.karamaShipping = null;
+ const retry = applyAction(expired, 'e', kullShipmentAttempt(expired));
+ assert.equal(retry.pendingKull?.stage, 'offer');
+ assert.ok(held(retry, 'e', original));
+});
+
+void test('different physical card counter works for the interrupted actor and cannot reuse the reserved original', () => {
+ const g = choamKullGame(), original = player(g, 'e').hand[0].id;
+ const counter = takeKullCard(g, player(g, 'h').hand[0].id);
+ player(g, 'e').hand.push(counter);
+ const cards = inventory(g);
+ const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
+ assert.deepEqual(viewGame(window, 'e').responseControls?.cancelCards, [counter.id]);
+ const before = JSON.stringify(window);
+ assert.throws(() => applyAction(window, 'e', { type: 'card', mode: 'cancel', card: original }), /reserved/);
+ assert.equal(JSON.stringify(window), before);
+ const result = applyAction(reload(window), 'e', { type: 'card', mode: 'cancel', card: counter.id });
+ assert.equal(result.pendingKull, null);
+ assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
+ assert.ok(held(result, 'c', 'ix-kull-wahad'));
+ assert.equal(result.discard.filter(card => card.id === counter.id).length, 1);
+ assert.equal(result.discard.filter(card => card.id === original).length, 1);
+ assert.deepEqual(inventory(result), cards);
+ assert.equal(result.choamWorthlessBlocked?.cards.includes('ix-kull-wahad'), true);
+ assert.equal(viewGame(result, 'e').karamaBlocked, null);
+});
+
+void test('BG original is intercepted before conversion and retained on success; decline converts once', () => {
+ const g = choamKullGame();
+ g.active = 'b';
+ const action = kullShipmentAttempt(g, 'b'), original = action.card as string;
+ const offered = applyAction(g, 'b', action);
+ assert.equal(offered.pendingKarama, null);
+ assert.ok(held(offered, 'b', original));
+ const result = finishKull(declare(offered));
+ assert.ok(held(result, 'b', original));
+ assert.equal(result.pendingKarama, null);
+ assert.equal(result.karamaShipping, null);
+ assert.throws(() => applyAction(result, 'b', action), /Kull Wahad/);
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
+ assert.equal(declined.response?.kind, 'worthlessKarama');
+ assert.equal(declined.discard.filter(card => card.id === original).length, 1);
+ const completed = finishConversion(declined);
+ assert.deepEqual(completed.karamaShipping, { player: 'b', owner: 'b', card: original });
+ assert.equal(completed.discard.filter(card => card.id === original).length, 1);
+});
+
+void test('BG distinct substitution can counter Kull while original remains held until its one resumed commit', () => {
+ const g = choamKullGame(), original = player(g, 'e').hand[0].id;
+ const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
+ const bg = player(g, 'b').hand[0].id;
+ const conversion = applyAction(reload(window), 'b', { type: 'card', mode: 'cancel', card: bg });
+ assert.equal(conversion.response?.kind, 'worthlessKarama');
+ assert.equal(conversion.pendingKull?.stage, 'counter');
+ for (const id of ['c', 'e', 'b', 'h']) {
+ const view = viewGame(conversion, id);
+ assert.equal(view.kullCounterEvent, window.pendingKull!.event);
+ assert.equal(view.kullReaction, null);
+ if (id !== 'c') assert.equal(view.choamWorthless, null);
+ }
+ assert.ok(held(conversion, 'e', original));
+ const result = finishConversion(conversion);
+ assert.equal(result.pendingKull, null);
+ assert.equal(viewGame(result, 'b').kullCounterEvent, null);
+ assert.ok(held(result, 'c', 'ix-kull-wahad'));
+ assert.equal(result.discard.filter(card => card.id === bg).length, 1);
+ assert.equal(result.discard.filter(card => card.id === original).length, 1);
+ assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
+});
+
+void test('special Emperor decline/counter commits revival once; Kull success leaves special use and resources unspent', () => {
+ const g = choamKullGame();
+ g.phase = 4;
+ player(g, 'e').tanks = 4;
+ player(g, 'e').reserves -= 4;
+ const original = player(g, 'e').hand[0].id, reserves = player(g, 'e').reserves;
+ const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, amount: 2 });
+ assert.equal(player(offered, 'e').reserves, reserves);
+ assert.equal(player(offered, 'e').specialKaramaUsed, undefined);
+ const event = offered.pendingKull!.event;
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event, decline: true });
+ assert.equal(player(declined, 'e').reserves, reserves + 2);
+ assert.equal(player(declined, 'e').tanks, 2);
+ assert.equal(player(declined, 'e').specialKaramaUsed, true);
+ const canceled = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ assert.equal(player(canceled, 'e').reserves, reserves + 2);
+ assert.equal(player(canceled, 'e').tanks, 2);
+ const prevented = finishKull(declare(offered));
+ assert.equal(player(prevented, 'e').reserves, reserves);
+ assert.equal(player(prevented, 'e').tanks, 4);
+ assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
+ assert.ok(held(prevented, 'e', original));
+ assert.throws(() => applyAction(prevented, 'e', { type: 'card', mode: 'special', card: original, amount: 2 }), /Kull Wahad/);
+});
+
+void test('ordinary cancellation preserves the original CHOAM Worthless parent across Kull success and counter', () => {
+ const g = choamKullGame();
+ const la = takeKullCard(g, 'La La La');
+ player(g, 'c').hand.push(la);
+ g.phase = 4;
+ const parent = applyAction(g, 'c', { type: 'card', mode: 'choam', card: la.id, target: 'e' });
+ const original = player(g, 'e').hand[0].id;
+ const offered = applyAction(parent, 'e', { type: 'card', mode: 'cancel', card: original });
+ assert.equal(viewGame(offered, 'h').response, null);
+ assert.equal(offered.pendingChoamWorthless?.card, la.id);
+ let success = finishKull(declare(offered));
+ assert.equal(success.pendingChoamWorthless?.card, la.id);
+ assert.equal(success.response?.intent, 'La La La');
+ success = finishConversion(success, 'choamWorthless');
+ assert.ok(held(success, 'e', original));
+ assert.equal(success.revivalRules?.freeBlocked?.includes('e'), true);
+ assert.equal(success.discard.filter(card => card.id === la.id).length, 1);
+ const canceled = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ assert.ok(held(canceled, 'c', la.id));
+ assert.equal(canceled.revivalRules?.freeBlocked?.includes('e') ?? false, false);
+ assert.equal(canceled.discard.filter(card => card.id === original).length, 1);
+});
+
+void test('funded payment and free purchase resume once; unfunded overbid guard is hidden-Kull independent and immutable', () => {
+ for (const payment of [true, false]) {
+ const g = choamKullGame(), lot = auction(g, payment ? 2 : 0, payment), original = player(g, 'e').hand[0].id;
+ const action = payment ? { type: 'decision', karama: true, card: original } : { type: 'card', mode: 'purchase', card: original };
+ const offered = applyAction(g, 'e', action);
+ assert.equal(held(offered, 'e', lot.id), false);
+ const completed = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
+ assert.ok(held(completed, 'e', lot.id));
+ assert.equal(completed.discard.filter(card => card.id === original).length, 1);
+ assert.equal(player(completed, 'e').spice, player(g, 'e').spice);
+ }
+ const g = choamKullGame();
+ auction(g, player(g, 'e').spice + 1);
+ const absent = reload(g);
+ absent.deck.push(...player(absent, 'c').hand);
+ player(absent, 'c').hand = [];
+ for (const state of [g, absent]) {
+ const before = JSON.stringify(state);
+ assert.throws(() => applyAction(state, 'e', { type: 'decision', karama: true, card: player(state, 'e').hand[0].id }), /defers/);
+ assert.equal(JSON.stringify(state), before);
+ assert.equal(state.pendingKull, undefined);
+ }
+});
+
+void test('invalid original and corrupted saved frame reject before public timing and without mutation', () => {
+ const g = choamKullGame();
+ const before = JSON.stringify(g);
+ assert.throws(() => applyAction(g, 'e', { type: 'card', mode: 'shipment', card: player(g, 'e').hand[0].id, target: 'h' }));
+ assert.equal(JSON.stringify(g), before);
+ const offered = applyAction(g, 'e', kullShipmentAttempt(g));
+ const corrupt = reload(offered);
+ corrupt.pendingKull!.intent = { kind: 'ordinary', use: { kind: 'shipment', recipient: 'h' } };
+ const saved = JSON.stringify(corrupt);
+ assert.throws(() => normalizeAutomaticGame(corrupt), /saved Kull attempt/);
+ assert.throws(() => viewGame(corrupt, 'c'), /saved Kull attempt/);
+ assert.throws(() => applyAction(corrupt, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
+ assert.equal(JSON.stringify(corrupt), saved);
+});
+
+void test('Basic preview supports printed ordinary interception without enabling Advanced substitution or special powers', () => {
+ const g = choamKullGame({ advanced: false });
+ const original = player(g, 'e').hand[0].id;
+ const offered = applyAction(g, 'e', kullShipmentAttempt(g));
+ assert.equal(viewGame(offered, 'c').kullPreview, true);
+ const result = finishKull(declare(offered));
+ assert.ok(held(result, 'e', original));
+ assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ const bg = reload(g);
+ bg.active = 'b';
+ assert.throws(() => applyAction(bg, 'b', { type: 'card', mode: 'shipment', card: player(bg, 'b').hand[0].id }));
+});
+
+void test('Harkonnen interrupted random exchange draws no RNG until decline and preserves the private return parent', t => {
+ const g = choamKullGame();
+ g.phase = 3;
+ player(g, 'e').hand.push(takeKullCard(g, 'Shield'));
+ player(g, 'h').hand.push(takeKullCard(g, 'Snooper'));
+ const original = player(g, 'h').hand.find(card => card.effect === 'karama')!.id;
+ let draws = 0;
+ t.mock.method(crypto, 'getRandomValues', (array: Uint32Array) => {
+ draws++;
+ array.fill(0);
+ return array;
+ });
+ const offered = applyAction(g, 'h', { type: 'card', mode: 'special', card: original, target: 'e', amount: 1 });
+ assert.equal(draws, 0);
+ assert.deepEqual(player(offered, 'e').hand, player(g, 'e').hand);
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
+ assert.equal(draws, 1);
+ assert.equal(declined.decision?.kind, 'handExchange');
+ assert.equal(player(declined, 'h').specialKaramaUsed, true);
+ assert.equal(declined.discard.filter(card => card.id === original).length, 1);
+ assert.deepEqual(normalizeAutomaticGame(reload(declined)), reload(declined));
+ assert.equal(draws, 1);
+ assert.equal(viewGame(declined, 'e').harkonnenExchangeInspection, null);
+ assert.throws(() => applyAction(declined, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
+ assert.equal(draws, 1);
+});
+
+void test('Fremen special allowance is intercepted before worm appearance and canceled Kull cannot replay its card', () => {
+ const g = choamKullGame({ opponents: ['fremen', 'emperor', 'harkonnen'] });
+ g.phase = 1;
+ const original = player(g, 'e').hand[0].id;
+ const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, territory: 'hagga_basin' });
+ assert.equal(offered.summonedWorm ?? null, null);
+ assert.ok(held(offered, 'e', original));
+ const prevented = finishKull(declare(offered));
+ assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
+ assert.equal(prevented.summonedWorm ?? null, null);
+ assert.ok(held(prevented, 'e', original));
+ const resumed = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ assert.equal(player(resumed, 'e').specialKaramaUsed, true);
+ assert.equal(resumed.discard.filter(card => card.id === original).length, 1);
+ assert.equal(resumed.pendingKull, null);
+ assert.throws(() => applyAction(resumed, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
+});
+
+void test('Guild special prevention keeps the unpaid shipment parent on Kull success and stops it once on decline', () => {
+ let g = choamKullGame({ opponents: ['guild', 'emperor', 'harkonnen'] });
+ g.active = 'b';
+ g.movementRemaining = ['b', 'e', 'h', 'c'];
+ const before = { spice: player(g, 'b').spice, reserves: player(g, 'b').reserves };
+ g = applyAction(g, 'b', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 });
+ assert.equal(g.decision?.kind, 'guildShipment');
+ const original = player(g, 'e').hand[0].id;
+ const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original });
+ assert.deepEqual({ spice: player(offered, 'b').spice, reserves: player(offered, 'b').reserves }, before);
+ assert.equal(viewGame(offered, 'b').decision, null);
+ const prevented = finishKull(declare(offered));
+ assert.equal(prevented.decision?.kind, 'guildShipment');
+ assert.equal(prevented.pendingShipment?.player, 'b');
+ assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
+ assert.deepEqual({ spice: player(prevented, 'b').spice, reserves: player(prevented, 'b').reserves }, before);
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
+ assert.equal(declined.pendingShipment, null);
+ assert.equal(player(declined, 'b').shipped, true);
+ assert.equal(player(declined, 'e').specialKaramaUsed, true);
+ assert.equal(declined.discard.filter(card => card.id === original).length, 1);
+ assert.deepEqual({ spice: player(declined, 'b').spice, reserves: player(declined, 'b').reserves }, before);
+});
+
+void test('Atreides special prescience retains its pre-plan offer until the intercepted card is allowed', () => {
+ let g = choamKullGame({ opponents: ['atreides', 'emperor', 'harkonnen'] });
+ g.phase = 6;
+ g.active = 'e';
+ for (const p of g.players) { p.forces = {}; p.reserves = 20; }
+ for (const id of ['e', 'b']) { player(g, id).forces = { 'arrakeen:10': 2 }; player(g, id).reserves = 18; }
+ g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
+ for (let step = 0; (g.response || g.battle?.preparation) && step < 20; step++) {
+ if (g.response) {
+ const responder = g.players.find(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ });
+ g = responder ? applyAction(g, responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(g);
+ } else g = applyAction(g, g.battle!.preparation!.owner, { type: 'declineBattlePower' });
+ }
+ assert.equal(g.decision?.kind, 'fullPlanOffer');
+ const original = player(g, 'e').hand[0].id;
+ const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, target: 'b' });
+ assert.equal(offered.battle?.fullPlan ?? null, null);
+ const prevented = finishKull(declare(offered));
+ assert.equal(prevented.decision?.kind, 'fullPlanOffer');
+ assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
+ assert.ok(held(prevented, 'e', original));
+ const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
+ assert.deepEqual(declined.battle?.fullPlan, { owner: 'e', target: 'b' });
+ assert.equal(player(declined, 'e').specialKaramaUsed, true);
+ assert.equal(declined.discard.filter(card => card.id === original).length, 1);
+});
+
+void test('a canceled BG counter does not release the original card or prematurely apply the ban', () => {
+ const g = choamKullGame(), original = player(g, 'e').hand[0].id;
+ const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
+ const bg = player(g, 'b').hand[0].id;
+ const conversion = applyAction(window, 'b', { type: 'card', mode: 'cancel', card: bg });
+ const result = applyAction(reload(conversion), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ assert.equal(result.pendingKull, null);
+ assert.ok(held(result, 'e', original));
+ assert.equal(result.karamaShipping, null);
+ assert.equal(result.discard.filter(card => card.id === bg).length, 1);
+ assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ assert.equal(viewGame(result, 'e').karamaBlocked !== null, true);
+});
+
+void test('nonopted games keep native Karama execution and no Kull opportunity', () => {
+ const g = choamKullGame();
+ delete g.kullPreview;
+ const original = player(g, 'e').hand[0].id;
+ const result = applyAction(g, 'e', kullShipmentAttempt(g));
+ assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
+ assert.equal(result.discard.filter(card => card.id === original).length, 1);
+ assert.equal(result.pendingKull, undefined);
+ assert.equal(viewGame(result, 'c').kullReaction, null);
+ assert.equal(viewGame(result, 'c').kullPreview, false);
+});
+
+void test('unproven live Truthtrance cancellation rejects privately before Kull cost or offer', () => {
+ let g = choamKullGame({ opponents: ['atreides', 'beneGesserit', 'harkonnen'] });
+ g.phase = 6;
+ g.active = 'e';
+ for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
+ for (const id of ['e', 'b']) { player(g, id).forces = { 'arrakeen:10': 2 }; player(g, id).reserves = 18; }
+ g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
+ assert.equal(g.battle?.preparation?.kind, 'voice');
+ g = finishConversion(g, 'kwisatz');
+ g = applyAction(g, 'b', { type: 'declineBattlePower' });
+ g = applyAction(g, 'e', { type: 'prescience', field: 'dial' });
+ const attemptedConversion = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: player(g, 'b').hand[0].id });
+ g = applyAction(attemptedConversion, 'c', { type: 'kullDecision', event: attemptedConversion.pendingKull!.event, decline: true });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ // A valid, feasible saved answer; this response's cancellation suffix is
+ // intentionally outside the engine's pure promise-feasibility quote.
+ g.battle!.truthPromises = [{ player: 'e', asker: 'h', claim: { kind: 'dial', compare: 'eq', value: 0 }, answer: true }];
+ const absent = reload(g);
+ absent.deck.push(...player(absent, 'c').hand);
+ player(absent, 'c').hand = [];
+ for (const state of [g, absent]) {
+ const before = JSON.stringify(state);
+ assert.throws(() => applyAction(state, 'e', { type: 'card', mode: 'cancel', card: player(state, 'e').hand[0].id }), /unproven Truthtrance/);
+ assert.equal(JSON.stringify(state), before);
+ assert.equal(state.pendingKull, null);
+ assert.equal(state.battle?.truthPromises?.[0].released, undefined);
+ }
+});
+
+void test('a phase-blocked held Karama does not authorize accepting an unsupported unfunded overbid', () => {
+ const g = choamKullGame();
+ auction(g, 0, false);
+ const blocked = finishKull(declare(applyAction(g, 'e', { type: 'card', mode: 'purchase', card: player(g, 'e').hand[0].id })));
+ assert.ok(held(blocked, 'e', player(g, 'e').hand[0].id));
+ const before = JSON.stringify(blocked);
+ assert.throws(() => applyAction(blocked, 'e', { type: 'bid', amount: player(blocked, 'e').spice + 1 }), /defers/);
+ assert.equal(JSON.stringify(blocked), before);
+ const funded = applyAction(blocked, 'e', { type: 'bid', amount: 1 });
+ assert.equal(funded.auction?.bid, 1);
+ assert.equal(funded.auction?.bidder, 'e');
+});
+
+function preparedKullBattle() {
+ let g = choamKullGame();
+ Object.assign(g, { phase: 6, active: 'e' });
+ for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
+ for (const id of ['e', 'b']) {
+ player(g, id).forces = { 'arrakeen:10': 2 };
+ player(g, id).reserves = 18;
+ }
+ g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
+ if (g.response) g = finishConversion(g, g.response.kind);
+ return g;
+}
+
+void test('a promised battle card cannot be spent as an unproven Kull counter', () => {
+ const g = preparedKullBattle();
+ g.battle!.preparation = undefined;
+ g.battle!.truthPromises = [{
+ player: 'b', asker: 'h', claim: { kind: 'weapon', name: 'Baliset' }, answer: true,
+ }];
+ g.response = { kind: 'eliteStrength', owner: 'e', passed: [] };
+ const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ const counter = declare(offered), before = JSON.stringify(counter);
+ const cost = player(counter, 'b').hand[0].id;
+ assert.deepEqual(viewGame(counter, 'b').responseControls?.cancelCards, []);
+ assert.throws(() => applyAction(counter, 'b', { type: 'card', mode: 'cancel', card: cost }));
+ assert.equal(JSON.stringify(counter), before);
+ assert.ok(held(counter, 'b', cost));
+ for (const profile of DIFFICULTIES) {
+ let automatic = reload(counter);
+ for (const p of automatic.players) p.bot = profile;
+ for (let step = 0; automatic.pendingKull && step < 12; step++) {
+ const choice = automatic.players.flatMap(p =>
+ botActions(viewGame(automatic, p.id)).map(action => ({ actor: p.id, action })))[0];
+ assert.ok(choice, 'a legal counter or automatic allowance must progress');
+ automatic = applyAction(automatic, choice.actor, choice.action);
+ }
+ assert.equal(automatic.pendingKull, null);
+ assert.ok(held(automatic, 'b', cost));
+ assert.equal(automatic.battle!.truthPromises![0].released, undefined);
+ }
+ assert.equal(counter.battle!.truthPromises![0].released, undefined);
+});
+
+void test('automatic Kull counter recovery preserves the suspended Voice promise', () => {
+ let g = preparedKullBattle();
+ player(g, 'e').hand.push(takeKullCard(g, 'Shield'));
+ g.battle!.truthPromises = [{
+ player: 'e', asker: 'h', claim: { kind: 'defense', name: 'Shield' }, answer: true,
+ }];
+ g = applyAction(g, 'b', { type: 'voice', kind: 'shield', must: false });
+ g = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
+ const counter = declare(g);
+ const restored = normalizeAutomaticGame(reload(counter));
+ assert.equal(restored.battle!.truthPromises![0].released, undefined);
+ assert.equal(restored.pendingKull?.event, counter.pendingKull?.event);
+ for (const id of ['c', 'e', 'b', 'h'])
+ assert.deepEqual(viewGame(restored, id), viewGame(counter, id));
+});
+
+void test('orphaned direct or converted Kull counters reject before views, recovery or new actions', () => {
+ const initial = choamKullGame();
+ const declared = declare(applyAction(initial, 'e', kullShipmentAttempt(initial)));
+ const converted = applyAction(declared, 'b', {
+ type: 'card', mode: 'cancel', card: player(declared, 'b').hand[0].id,
+ });
+ for (const source of [declared, converted])
+ for (const remove of [true, false]) {
+ const corrupt = reload(source);
+ if (remove) delete corrupt.pendingKull;
+ else corrupt.pendingKull = null;
+ const before = JSON.stringify(corrupt);
+ assert.throws(() => viewGame(corrupt, 'h'), /lost its interrupted Karama frame/);
+ assert.throws(() => normalizeAutomaticGame(corrupt), /lost its interrupted Karama frame/);
+ assert.throws(() => applyAction(corrupt, 'h', { type: 'passResponse' }),
+ /lost its interrupted Karama frame/);
+ assert.equal(JSON.stringify(corrupt), before);
+ }
+});
+
+void test('Kull counter preserves a genuine Auditor response until the original cancellation resumes', () => {
+ let g = choamKullGame();
+ Object.assign(g, { phase: 6, active: 'c' });
+ for (const p of g.players) {
+ p.forces = ['c', 'e'].includes(p.id) ? { 'arrakeen:10': 3 } : {};
+ p.reserves = ['c', 'e'].includes(p.id) ? 17 : 20;
+ p.traitors = [];
+ }
+ g = applyAction(g, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
+ for (let step = 0; step < 30; step++) {
+ if (g.response) g = finishConversion(g, g.response.kind);
+ else if (g.battle?.preLeader && !g.battle.preLeader.closed) {
+ const id = ['c', 'e'].find(id => !g.battle!.preLeader!.ready.includes(id))!;
+ g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle.event });
+ } else if (g.battle?.preparation)
+ g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
+ else break;
+ }
+ g = applyAction(g, 'c', { type: 'battlePlan', leader: 'choam-auditor', dial: 0, support: 0 });
+ g = applyAction(g, 'e', { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0 });
+ g = applyAction(g, 'c', { type: 'traitorCall', call: false });
+ g = applyAction(g, 'e', { type: 'traitorCall', call: false });
+ if (g.response) g = finishConversion(g, g.response.kind);
+ assert.equal(g.decision?.kind, 'choamAudit');
+ g = applyAction(g, 'c', { type: 'decision', audit: true, event: g.pendingAuditor!.event });
+ assert.equal(g.response?.kind, 'choamAudit');
+ const original = player(g, 'h').hand[0].id;
+ const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: original });
+ const counter = normalizeAutomaticGame(reload(declare(offered)));
+ assert.equal(counter.pendingAuditor?.stage, 'response');
+ for (const id of ['c', 'e', 'b', 'h']) assert.ok(viewGame(counter, id).response);
+ const cost = player(counter, 'e').hand[0].id;
+ const completed = applyAction(counter, 'e', { type: 'card', mode: 'cancel', card: cost });
+ assert.equal(completed.pendingKull, null);
+ assert.equal(completed.pendingAuditor, null);
+ assert.ok(held(completed, 'c', 'ix-kull-wahad'));
+ assert.equal(completed.discard.filter(card => card.id === original).length, 1);
+ assert.equal(completed.discard.filter(card => card.id === cost).length, 1);
+});
+
+void test('CHOAM cannot voluntarily spend the Kull card required by its own battle promise', () => {
+ let g = choamKullGame();
+ Object.assign(g, { phase: 6, active: 'c' });
+ for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
+ for (const id of ['c', 'e']) {
+ player(g, id).forces = { 'arrakeen:10': 3 };
+ player(g, id).reserves = 17;
+ }
+ g = applyAction(g, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
+ if (g.response) g = finishConversion(g, g.response.kind);
+ g.battle!.preparation = undefined;
+ g.battle!.truthPromises = [{
+ player: 'c', asker: 'h', claim: { kind: 'weapon', name: 'Kull Wahad' }, answer: true,
+ }];
+ 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.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',
+ }), /eligible held Kull/);
+ assert.equal(JSON.stringify(offered), before);
+ const declined = applyAction(offered, 'c', {
+ type: 'kullDecision', event: offered.pendingKull!.event, decline: true,
+ });
+ assert.ok(held(declined, 'c', 'ix-kull-wahad'));
+ assert.equal(declined.battle!.truthPromises![0].released, undefined);
+});
+
+void test('deferred unfunded bids reject before commitment regardless of hidden Kull, without changing non-preview bidding', () => {
+ const g = choamKullGame();
+ player(g, 'e').spice = 1;
+ auction(g, 0, false);
+ const absent = reload(g);
+ const replacement = absent.deck.shift()!;
+ absent.deck.push(...player(absent, 'c').hand);
+ player(absent, 'c').hand = [replacement];
+ for (const state of [g, absent]) {
+ const before = JSON.stringify(state);
+ assert.throws(() => applyAction(state, 'e', { type: 'bid', amount: 2 }));
+ assert.equal(JSON.stringify(state), before);
+ assert.equal(state.auction?.bid, 0);
+ assert.equal(state.auction?.bidder, null);
+ }
+ const normal = reload(g);
+ delete normal.kullPreview;
+ const bid = applyAction(normal, 'e', { type: 'bid', amount: 2 });
+ assert.equal(bid.auction?.bid, 2);
+ assert.equal(bid.auction?.bidder, 'e');
+});
+
+void test('CHOAM self-activation remains outside Kull interception and can settle its funded-by-Karama bid', () => {
+ let g = choamKullGame();
+ const card = takeKullCard(g, player(g, 'h').hand[0].id);
+ player(g, 'c').hand.push(card);
+ player(g, 'c').spice = 1;
+ auction(g, 0, false);
+ g.active = 'c';
+ g.auction!.active = 'c';
+ g = applyAction(g, 'c', { type: 'bid', amount: 2 });
+ for (let step = 0; g.auction && !g.decision && step < 12; step++)
+ g = applyAction(g, g.auction.active!, { type: 'passBid' });
+ assert.equal(g.decision?.kind, 'auctionPayment');
+ const done = applyAction(g, 'c', { type: 'decision', karama: true, card: card.id });
+ assert.equal(done.pendingKull ?? null, null);
+ assert.ok(held(done, 'c', 'ix-kull-wahad'));
+ assert.equal(done.discard.filter(used => used.id === card.id).length, 1);
+});
diff --git a/tests/choam-kull-recovery.test.ts b/tests/choam-kull-recovery.test.ts
new file mode 100644
index 00000000..9f1fe3f8
--- /dev/null
+++ b/tests/choam-kull-recovery.test.ts
@@ -0,0 +1,406 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import type { DatabaseSync } from 'node:sqlite';
+import type * as Rooms from '../db/rooms';
+import { viewGame, type Action, type Game, type GameView } from '../game/engine';
+import { unitStore } from './fixture-nexus-room-store';
+import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
+
+const clock = { now: () => 10000, sleep: async () => {} };
+const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
+const credentials = (sqlite: DatabaseSync) => ({
+ seats: sqlite.prepare('SELECT * FROM seats ORDER BY token_hash').all(),
+ entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY 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(),
+});
+
+function inventory(g: Game) {
+ return [...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...g.players.flatMap(p => p.hand)]
+ .map(card => card.id).sort();
+}
+
+async function persisted(prepare?: (g: Game) => void) {
+ const store = unitStore();
+ try {
+ const created = await store.rooms.createRoom('Kull SQLite QA', 'choam', true, ['choam', 'ix']);
+ const code = created.view.code;
+ const joined = [
+ await store.rooms.joinRoom(code, 'Emperor', 'emperor'),
+ await store.rooms.joinRoom(code, 'Bene Gesserit', 'beneGesserit'),
+ await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen'),
+ ];
+ const tokens = [created.token, ...joined.map(seat => seat.token!)];
+ const auths = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
+ const current = await store.rooms.readRoom(code);
+ const initial = choamKullGame({ seatIds: auths.map(auth => auth.playerId) });
+ initial.code = code;
+ initial.host = auths[0].playerId;
+ initial.version = current.version;
+ 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);
+ store.writes.length = 0;
+ return { ...store, code, tokens, auths, initial,
+ originalCredentials: credentials(store.sqlite), originalCards: inventory(initial) };
+ } catch (error) {
+ store.sqlite.close();
+ throw error;
+ }
+}
+type Fixture = {
+ rooms: typeof Rooms;
+ restart: () => typeof Rooms;
+ sqlite: DatabaseSync;
+ hooks: { beforeWrite?: () => Promise; beforeStatement?: (sql: string) => Promise };
+ writes: { expected: number; changes: number }[];
+ code: string;
+ tokens: string[];
+ auths: Rooms.SeatAuth[];
+ initial: Game;
+ originalCredentials: { seats: unknown[]; entries: unknown[]; keys: unknown[]; recoveries: unknown[] };
+ originalCards: string[];
+};
+
+async function act(f: Fixture, g: Game, actor: string, action: Action) {
+ const rooms = f.restart();
+ const auth = f.auths.find(auth => auth.playerId === actor)!;
+ await rooms.act(f.code, auth, g.version, action, clock);
+ return rooms.readRoom(f.code);
+}
+
+async function restored(f: Fixture, g: Game) {
+ const rooms = f.restart();
+ const views: GameView[] = [];
+ for (let index = 0; index < f.tokens.length; index++) {
+ const auth = await rooms.authenticate(f.code, f.tokens[index]);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, viewGame(g, auth.playerId));
+ if (g.pendingKull) {
+ assert.equal('pendingKull' in view, false, 'saved intent and suspended controls are not a projection');
+ assert.equal(JSON.stringify(view).includes(JSON.stringify(g.pendingKull.signature)), false);
+ }
+ assert.deepEqual(view.players.find(p => p.id === auth.playerId)!.hand, player(g, auth.playerId).hand);
+ for (const other of view.players.filter(p => p.id !== auth.playerId)) {
+ assert.equal(other.hand, undefined);
+ assert.equal(other.spice, undefined);
+ }
+ views.push(view);
+ }
+ assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
+ assert.deepEqual(inventory(g), f.originalCards);
+ assert.equal(new Set(inventory(g)).size, f.originalCards.length);
+ return views;
+}
+
+async function rejected(f: Fixture, g: Game, actor: string, action: Action, version = g.version) {
+ const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
+ const attemptedStatements: string[] = [];
+ f.hooks.beforeStatement = async sql => { attemptedStatements.push(sql); };
+ try {
+ await assert.rejects(f.restart().act(f.code, f.auths.find(auth => auth.playerId === actor)!,
+ version, action, clock));
+ } finally { delete f.hooks.beforeStatement; }
+ 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);
+}
+
+async function stationary(f: Fixture, g: Game) {
+ const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
+ const writes = f.writes.length;
+ await 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, writes);
+ await restored(f, g);
+}
+
+async function passes(f: Fixture, initial: Game) {
+ let g = initial;
+ for (let step = 0; g.response && step < 16; step++) {
+ const responder = g.players.find(p => !g.response!.passed.includes(p.id));
+ assert.ok(responder, 'an unresolved response must retain a real seat');
+ g = await act(f, g, responder.id, { type: 'passResponse' });
+ await restored(f, g);
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+
+async function compete(f: Fixture, g: Game, actor: string, action: Action) {
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ const priorWrites = f.writes.length;
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ const auth = f.auths.find(auth => auth.playerId === actor)!;
+ try {
+ const outcomes = await Promise.allSettled([
+ f.rooms.act(f.code, auth, g.version, action, clock),
+ f.restart().act(f.code, auth, g.version, action, clock),
+ ]);
+ assert.equal(arrivals, 2);
+ assert.equal(outcomes.filter(outcome => outcome.status === 'fulfilled').length, 1);
+ assert.equal(outcomes.filter(outcome => outcome.status === 'rejected').length, 1);
+ assert.deepEqual(f.writes.slice(priorWrites).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
+ } finally { delete f.hooks.beforeWrite; }
+ const done = await f.restart().readRoom(f.code);
+ assert.equal(done.version, g.version + 1);
+ await restored(f, done);
+ return done;
+}
+
+async function offer(f: Fixture, initial: Game, actor: string, action: Action, expectedCards = ['ix-kull-wahad']) {
+ const g = await act(f, initial, actor, action);
+ const views = await restored(f, g);
+ const reaction = views[0].kullReaction;
+ assert.ok(reaction);
+ 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);
+ const publicFields = { event: reaction.event, player: reaction.player, target: reaction.target,
+ intent: reaction.intent, cards: [], 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);
+ await stationary(f, g);
+ return { g, event: reaction.event };
+}
+
+void test('persisted Kull offer and racing decline revive Emperor forces exactly once after restart', async () => {
+ const f = await persisted(g => {
+ g.phase = 4;
+ g.active = null;
+ player(g, g.players[1].id).tanks = 4;
+ player(g, g.players[1].id).reserves -= 4;
+ });
+ try {
+ const emperor = f.auths[1].playerId;
+ const card = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!;
+ const original = { type: 'card', mode: 'special', card: card.id, amount: 2 };
+ const { g, event } = await offer(f, f.initial, emperor, original);
+ 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, 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);
+ assert.equal(player(done, emperor).tanks, 2);
+ assert.equal(player(done, emperor).reserves, player(f.initial, emperor).reserves + 2);
+ assert.equal(player(done, emperor).specialKaramaUsed, true);
+ assert.equal(done.discard.filter(held => held.id === card.id).length, 1);
+ assert.equal(player(done, f.auths[0].playerId).hand.filter(held => held.id === 'ix-kull-wahad').length, 1);
+ assert.equal(viewGame(done, emperor).kullReaction, null);
+ await rejected(f, done, f.auths[0].playerId, decline);
+ await rejected(f, done, emperor, original);
+ await stationary(f, done);
+ } finally { f.sqlite.close(); }
+});
+
+void test('persisted Kull success races the final pass, retains printed Karama and blocks its phase activation', async () => {
+ const f = await persisted();
+ try {
+ 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' });
+ assert.equal(g.response?.kind, 'choamWorthless');
+ assert.equal(g.karamaShipping, null);
+ assert.equal(g.discard.some(card => card.id === original.card), false);
+ await restored(f, g);
+ await stationary(f, g);
+ for (let step = 0; step < 4; step++) {
+ const eligible = g.players.filter(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ });
+ assert.ok(eligible.length > 0, 'the final-pass race must reach an unresolved legal counter seat');
+ if (eligible.length === 1) break;
+ g = await act(f, g, eligible[0].id, { type: 'passResponse' });
+ }
+ const finalSeat = g.players.find(p => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length > 0;
+ })!;
+ const done = await compete(f, g, finalSeat.id, { type: 'passResponse' });
+ assert.equal(done.response, null);
+ assert.equal(viewGame(done, emperor).kullReaction, null);
+ assert.equal(done.karamaShipping, null);
+ assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ 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 stationary(f, done);
+ } finally { f.sqlite.close(); }
+});
+
+void test('a distinct held Karama counters persisted Kull and resumes the original shipment only once', async () => {
+ const f = await persisted(g => {
+ const distinct = player(g, g.players[3].id).hand.find(card => card.effect === 'karama')!;
+ player(g, g.players[1].id).hand.push(takeKullCard(g, distinct.id));
+ });
+ try {
+ const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
+ 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 views = await restored(f, g);
+ assert.deepEqual(views[1].responseControls?.cancelCards, [distinct.id]);
+ assert.equal(views[1].responseControls?.cancelCards.includes(String(original.card)), false);
+ await rejected(f, g, emperor, { type: 'card', mode: 'cancel', card: original.card });
+ const counter = { type: 'card', mode: 'cancel', card: distinct.id };
+ const done = await compete(f, g, emperor, counter);
+ assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
+ 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 === distinct.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);
+ assert.equal(viewGame(done, emperor).kullReaction, null);
+ await rejected(f, done, emperor, counter);
+ await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
+ await stationary(f, done);
+ } finally { f.sqlite.close(); }
+});
+
+void test('Kull intercepts persisted BG substitution before conversion and retains every attempted physical card', async () => {
+ const f = await persisted(g => {
+ const extra = g.deck.find(card => card.kind === 'worthless')!;
+ player(g, g.players[2].id).hand.push(takeKullCard(g, extra.id));
+ });
+ try {
+ const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
+ const worthless = player(f.initial, bg).hand.filter(card => card.kind === 'worthless');
+ assert.equal(worthless.length, 2);
+ 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' });
+ 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);
+ await restored(f, g);
+ await stationary(f, g);
+ g = await passes(f, g);
+ assert.equal(g.karamaShipping, null);
+ assert.deepEqual(player(g, bg).hand, player(f.initial, bg).hand);
+ assert.equal(g.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ assert.equal(g.discard.some(card => card.id === worthless[0].id), false);
+ await rejected(f, g, bg, original);
+ await rejected(f, g, bg, { ...original, card: worthless[1].id });
+ await stationary(f, g);
+ } finally { f.sqlite.close(); }
+});
+
+void test('declining persisted Kull resumes the real BG conversion and shipment without repeated disposal', async () => {
+ const f = await persisted();
+ try {
+ const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
+ const worthless = player(f.initial, bg).hand.find(card => card.kind === 'worthless')!;
+ const original = { type: 'card', mode: 'shipment', card: worthless.id, target: emperor };
+ const { g: offered, event } = await offer(f, f.initial, bg, original);
+ const decline = { type: 'kullDecision', event, decline: true };
+ let g = await act(f, offered, choam, decline);
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.ok(g.pendingKarama);
+ assert.equal(g.karamaShipping, null);
+ assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
+ assert.equal(player(g, bg).hand.some(card => card.id === worthless.id), false);
+ await restored(f, g);
+ await stationary(f, g);
+ g = await passes(f, g);
+ assert.deepEqual(g.karamaShipping, { player: emperor, owner: bg, card: worthless.id });
+ assert.equal(g.pendingKarama, null);
+ assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
+ assert.equal(player(g, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ await rejected(f, g, choam, decline);
+ await rejected(f, g, bg, original);
+ await stationary(f, g);
+ } finally { f.sqlite.close(); }
+});
+
+void test('successful persisted Kull prevents Emperor revival before forces or its special once-use are spent', async () => {
+ const f = await persisted(g => {
+ g.phase = 4;
+ g.active = null;
+ player(g, g.players[1].id).tanks = 4;
+ player(g, g.players[1].id).reserves -= 4;
+ });
+ try {
+ const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
+ 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' });
+ await restored(f, declared);
+ const done = await passes(f, declared);
+ assert.deepEqual(player(done, emperor), player(f.initial, emperor));
+ assert.equal(player(done, emperor).specialKaramaUsed ?? false, false);
+ assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
+ assert.equal(done.discard.some(card => card.id === karama.id), false);
+ await rejected(f, done, emperor, original);
+ await stationary(f, done);
+ } finally { f.sqlite.close(); }
+});
+
+void test('a Kull-free saved CHOAM hand still receives the neutral private offer and can decline the real attempt', async () => {
+ const f = await persisted(g => { g.deck.push(takeKullCard(g, 'ix-kull-wahad')); });
+ try {
+ const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
+ 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' });
+ 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);
+ assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false);
+ await stationary(f, done);
+ } finally { f.sqlite.close(); }
+});
+
+void test('corrupt saved Kull intent ownership cannot execute or write after a room-module restart', async () => {
+ const f = await persisted();
+ 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));
+ assert.ok(g.pendingKull);
+ const corrupt = structuredClone(g);
+ corrupt.pendingKull!.owner = f.auths[2].playerId;
+ 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 assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
+ assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
+ } finally { f.sqlite.close(); }
+});
+
+void test('orphaned saved Kull counters cannot be projected or commit a partial pass', async () => {
+ for (const conversion of [false, true]) {
+ const f = await persisted();
+ 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' });
+ if (conversion) {
+ const bg = f.auths[2].playerId;
+ counter = await act(f, counter, bg, {
+ type: 'card', mode: 'cancel', card: player(counter, bg).hand[0].id,
+ });
+ }
+ const corrupt = structuredClone(counter);
+ delete corrupt.pendingKull;
+ assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
+ .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/);
+ assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
+ } finally { f.sqlite.close(); }
+ }
+});
diff --git a/tests/choam-kull.test.ts b/tests/choam-kull.test.ts
new file mode 100644
index 00000000..d2e5764c
--- /dev/null
+++ b/tests/choam-kull.test.ts
@@ -0,0 +1,203 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { baseDeck, ixStandardCards, type Card } from '../game/cards';
+import { canUseAsKarama } from '../game/karama';
+import { canUseAsKaramaRole } from '../game/shrine';
+import {
+ activeKullRestrictions,
+ distinctKullCounter,
+ kullBlocksKarama,
+ kullCounterCards,
+ kullNativeCostCards,
+ validateKullAttemptStamp,
+ validateKullPhaseRestrictions,
+ type KullAttemptStamp,
+ type KullContext,
+ type KullPhaseRestriction,
+} from '../game/choam-kull';
+
+const context: KullContext = {
+ turn: 2,
+ phase: 5,
+ players: [
+ { id: 'choam', faction: 'choam' },
+ { id: 'actor', faction: 'beneGesserit' },
+ { id: 'third', faction: 'atreides' },
+ ],
+};
+
+void test('a Kull restriction expires by both turn and phase and follows actor, not beneficiary', () => {
+ const restriction = Object.freeze({ player: 'actor', turn: 2, phase: 5 });
+ const history = Object.freeze([
+ restriction,
+ Object.freeze({ player: 'third', turn: 1, phase: 5 }),
+ ]);
+ validateKullPhaseRestrictions(context, history);
+ assert.equal(kullBlocksKarama(history, 2, 5, 'actor'), true);
+ assert.equal(kullBlocksKarama(history, 2, 5, 'third'), false);
+ assert.equal(kullBlocksKarama(history, 2, 5, 'choam'), false);
+ for (const [turn, phase] of [[2, 6], [3, 5], [3, 0]]) {
+ assert.equal(kullBlocksKarama(history, turn, phase, 'actor'), false);
+ assert.deepEqual(activeKullRestrictions(history, turn, phase), []);
+ }
+ assert.deepEqual(activeKullRestrictions(history, 2, 5), [restriction]);
+ assert.notEqual(activeKullRestrictions(history, 2, 5)[0], restriction);
+ assert.equal(kullBlocksKarama(undefined, 2, 5, 'actor'), false);
+});
+
+void test('malformed and future restriction history is rejected without repair or input mutation', () => {
+ const valid = { player: 'actor', turn: 2, phase: 5 };
+ const malformed: unknown[] = [
+ null,
+ { ...valid, turn: 0 },
+ { ...valid, turn: 1.5 },
+ { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, turn: 3 },
+ { ...valid, phase: -1 },
+ { ...valid, phase: 9 },
+ { ...valid, phase: 5.5 },
+ { ...valid, phase: 6 },
+ { ...valid, player: 'foreign' },
+ { ...valid, player: '' },
+ { ...valid, extra: true },
+ ];
+ for (const record of malformed) {
+ const history = [record];
+ const before = structuredClone(history);
+ assert.throws(() => validateKullPhaseRestrictions(context, history));
+ assert.deepEqual(history, before);
+ }
+ const duplicate = [valid, { ...valid }];
+ const before = structuredClone(duplicate);
+ assert.throws(() => validateKullPhaseRestrictions(context, duplicate));
+ assert.deepEqual(duplicate, before);
+ assert.throws(() => validateKullPhaseRestrictions(context, valid));
+ assert.throws(() => validateKullPhaseRestrictions({ ...context, turn: 0 }, []));
+ assert.equal(kullBlocksKarama([{ ...valid, phase: 9 }], 2, 9, 'actor'), false);
+});
+
+void test('valid expired restrictions stay inert without erasing another player current restriction', () => {
+ const history: KullPhaseRestriction[] = [
+ { player: 'actor', turn: 1, phase: 8 },
+ { player: 'actor', turn: 2, phase: 4 },
+ { player: 'third', turn: 2, phase: 5 },
+ ];
+ const before = structuredClone(history);
+ validateKullPhaseRestrictions(context, history);
+ assert.equal(kullBlocksKarama(history, 2, 5, 'actor'), false);
+ assert.equal(kullBlocksKarama(history, 2, 5, 'third'), true);
+ assert.deepEqual(activeKullRestrictions(history, 2, 5), [history[2]]);
+ assert.deepEqual(history, before);
+});
+
+void test('saved attempts require the current stamp, original seated actors and unique physical identity', () => {
+ const physical = baseDeck();
+ const card = physical.find(candidate => candidate.effect === 'karama')!;
+ const attempt: KullAttemptStamp = {
+ event: 'attempt-1', player: 'choam', owner: 'actor', card: card.id,
+ turn: 2, phase: 5, stage: 'offer', form: 'printed',
+ };
+ const patches: Partial[] = [
+ { event: '' }, { player: 'third' }, { player: 'foreign' },
+ { owner: 'choam' }, { owner: 'foreign' }, { card: 'invented' },
+ { turn: 1 }, { turn: 3 }, { turn: 0 }, { phase: 4 }, { phase: 6 },
+ { phase: 9 }, { stage: 'complete' as never }, { form: 'converted' as never },
+ ];
+ for (const patch of patches) {
+ const invalid = { ...attempt, ...patch };
+ const before = structuredClone({ invalid, physical, context });
+ assert.throws(() => validateKullAttemptStamp(context, invalid, physical));
+ assert.deepEqual({ invalid, physical, context }, before);
+ }
+ for (const inventory of [physical.filter(candidate => candidate.id !== card.id), [...physical, card]]) {
+ const before = structuredClone(inventory);
+ assert.throws(() => validateKullAttemptStamp(context, attempt, inventory));
+ assert.deepEqual(inventory, before);
+ }
+ // Validate both live stages after serialization; the exact original stays reserved.
+ for (const stage of ['offer', 'counter'] as const) {
+ const saved = JSON.parse(JSON.stringify({ ...attempt, stage })) as KullAttemptStamp;
+ validateKullAttemptStamp(context, saved, physical);
+ assert.equal(distinctKullCounter(saved.card, attempt.card), false);
+ assert.equal(distinctKullCounter(physical.find(candidate =>
+ candidate.effect === 'karama' && candidate.id !== card.id)!.id, saved.card), true);
+ }
+ const substitution = physical.find(candidate => candidate.kind === 'worthless')!;
+ const bgSaved = { ...attempt, card: substitution.id, form: 'substitution' as const };
+ validateKullAttemptStamp(context, bgSaved, physical);
+ assert.equal(distinctKullCounter(substitution.id, bgSaved.card), false);
+});
+
+void test('native Kull costs require canonical identity and unique actual custody, not a matching label', () => {
+ const physical = [...baseDeck(), ...ixStandardCards()];
+ const kull = physical.find(card => card.id === 'ix-kull-wahad')!;
+ const another = physical.find(card => card.kind === 'worthless' && card.id !== kull.id)!;
+ const held = Object.freeze([Object.freeze(kull), Object.freeze(another)]);
+ const before = structuredClone({ held, physical });
+ assert.deepEqual(kullNativeCostCards(held, physical).map(card => card.id), [kull.id]);
+ assert.deepEqual(kullNativeCostCards([another], physical), []);
+ assert.deepEqual(kullNativeCostCards(held, physical, [kull.id]), []);
+ assert.deepEqual(kullNativeCostCards(held, physical.filter(card => card.id !== kull.id)), []);
+ assert.deepEqual(kullNativeCostCards(held, [...physical, { ...kull }]), []);
+ assert.deepEqual(kullNativeCostCards([kull, { ...kull }], physical), []);
+ for (const fake of [
+ { ...another, name: kull.name },
+ { ...kull, kind: 'special' as const },
+ { ...kull, name: 'different' },
+ { ...kull, effect: 'karama' },
+ ]) {
+ assert.deepEqual(kullNativeCostCards([fake], physical), []);
+ }
+ 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');
+ const worthless = physical.find(card => card.kind === 'worthless')!;
+ const truthtrance = physical.find(card => card.effect === 'truthtrance')!;
+ const held = [karamas[0], karamas[1], worthless, truthtrance];
+ const before = structuredClone({ held, physical });
+ assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
+ card => canUseAsKarama(false, 'atreides', card)).map(card => card.id), [karamas[1].id]);
+ assert.deepEqual(kullCounterCards(held, physical, worthless.id,
+ card => canUseAsKarama(true, 'beneGesserit', card)).map(card => card.id),
+ [karamas[0].id, karamas[1].id]);
+ assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
+ card => canUseAsKarama(true, 'beneGesserit', card)).map(card => card.id),
+ [karamas[1].id, worthless.id]);
+ const shrine = {
+ advanced: true,
+ discoveries: { tokens: [{ face: 'shrine', status: 'placed', revealedTurn: 2 }] },
+ };
+ const actor = { id: 'third', faction: 'atreides' as const, forces: { 'shrine:0': 1 } };
+ assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
+ card => canUseAsKaramaRole(shrine, actor, card)).map(card => card.id),
+ [karamas[1].id, truthtrance.id]);
+ assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
+ card => canUseAsKaramaRole(shrine, { ...actor, forces: {} }, card)).map(card => card.id),
+ [karamas[1].id]);
+ const block = [{ player: actor.id, turn: 2, phase: 5 }];
+ assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
+ card => !kullBlocksKarama(block, 2, 5, actor.id) && canUseAsKaramaRole(shrine, actor, card)), []);
+ assert.deepEqual({ held, physical }, before);
+});
+
+void test('counter rejection checks exact physical custody before invoking effective eligibility', () => {
+ const physical = baseDeck();
+ const original = physical.find(card => card.effect === 'karama')!;
+ const distinct = physical.find(card => card.effect === 'karama' && card.id !== original.id)!;
+ const forged: Card = { ...distinct, kind: 'worthless', effect: undefined };
+ let eligibilityCalls = 0;
+ const eligible = (card: Card) => { eligibilityCalls++; return card.effect === 'karama'; };
+ assert.deepEqual(kullCounterCards([original], physical, original.id, eligible), []);
+ assert.deepEqual(kullCounterCards([forged], physical, original.id, eligible), []);
+ assert.deepEqual(kullCounterCards([distinct, distinct], physical, original.id, eligible), []);
+ assert.deepEqual(kullCounterCards([distinct], [...physical, distinct], original.id, eligible), []);
+ assert.deepEqual(kullCounterCards([distinct], physical, original.id, eligible, [distinct.id]), []);
+ assert.equal(eligibilityCalls, 0);
+ assert.deepEqual(kullCounterCards([distinct], physical, original.id, eligible).map(card => card.id), [distinct.id]);
+ assert.equal(eligibilityCalls, 1);
+ assert.equal(distinctKullCounter('', original.id), false);
+ assert.equal(distinctKullCounter(distinct.id, ''), false);
+});
diff --git a/tests/choam-worthless-cancellation.test.ts b/tests/choam-worthless-cancellation.test.ts
index 4e5cbd5f..2ea04207 100644
--- a/tests/choam-worthless-cancellation.test.ts
+++ b/tests/choam-worthless-cancellation.test.ts
@@ -508,3 +508,35 @@ void test('pure unrelated responses stay with their own cancellation validators'
null,
);
});
+
+void test('denied Kull phase-blocks the exact declared effect without consuming another phase context', () => {
+ for (let phase = 0; phase <= 8; phase++) {
+ const g = createGame('KULLDENY', newPlayer('c', 'CHOAM', 'choam'), true, ['choam', 'ix']);
+ g.players.push(newPlayer('a', 'Atreides', 'atreides'));
+ Object.assign(g, { status: 'playing', turn: 2, phase });
+ g.pendingChoamWorthless = {
+ owner: 'c', card: 'ix-kull-wahad', effect: 'kull', target: 'a', revival: false,
+ };
+ const response = {
+ kind: 'choamWorthless' as const, owner: 'c', recipient: 'a',
+ intent: 'Kull Wahad', passed: [],
+ };
+ const before = JSON.stringify(g);
+ const quote = quoteChoamWorthlessCancellation(g, response)!;
+ assert.deepEqual(quote.blocked, { turn: 2, phase, cards: ['ix-kull-wahad'] });
+ assert.deepEqual(quote.resume, { kind: 'none' });
+ assert.equal(quote.pendingChoamWorthless, null);
+ assert.equal(JSON.stringify(g), before);
+ for (const fields of [
+ { target: 'c' }, { target: 'foreign' }, { location: 'arrakeen' },
+ { elite: 0 }, { movement: true }, { noFieldEvent: 'foreign' },
+ ]) {
+ const corrupt = reload(g);
+ Object.assign(corrupt.pendingChoamWorthless!, fields);
+ const snapshot = JSON.stringify(corrupt);
+ assert.throws(() => quoteChoamWorthlessCancellation(corrupt, response),
+ ChoamWorthlessCancellationError);
+ assert.equal(JSON.stringify(corrupt), snapshot);
+ }
+ }
+});
diff --git a/tests/fixture-choam-kull.ts b/tests/fixture-choam-kull.ts
new file mode 100644
index 00000000..a8d95a01
--- /dev/null
+++ b/tests/fixture-choam-kull.ts
@@ -0,0 +1,55 @@
+import { applyAction, createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import type { Card } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+
+export function takeKullCard(g: Game, idOrEffect: string): Card {
+ const zones = [g.deck, g.discard, ...(g.richeseCache ? [g.richeseCache] : []), ...g.players.map(p => p.hand)];
+ for (const zone of zones) {
+ const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect || card.name === idOrEffect);
+ if (index >= 0) return zone.splice(index, 1)[0];
+ }
+ throw new Error(`Missing physical Kull fixture card: ${idOrEffect}`);
+}
+
+export function choamKullGame(options: {
+ seatIds?: readonly string[];
+ advanced?: boolean;
+ opponents?: readonly [FactionId, FactionId, FactionId];
+} = {}): Game {
+ const ids = options.seatIds ?? ['c', 'e', 'b', 'h'];
+ if (ids.length !== 4 || new Set(ids).size !== 4) throw new Error('Kull fixture needs four distinct seat IDs.');
+ let g = createGame('KULLQA01', newPlayer(ids[0], 'CHOAM', 'choam'), options.advanced ?? true, ['choam', 'ix']);
+ const opponents: readonly FactionId[] = options.opponents ?? ['emperor', 'beneGesserit', 'harkonnen'];
+ for (let index = 0; index < 3; index++)
+ joinGame(g, newPlayer(ids[index + 1], opponents[index], opponents[index]));
+ for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
+ g = initializeKullGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 100; step++) {
+ let next: Game | undefined;
+ for (const p of g.players) {
+ const action = botActions(viewGame(g, p.id))[0];
+ if (action) { next = applyAction(g, p.id, action); break; }
+ }
+ if (!next) throw new Error('Genuine Kull setup has no legal action.');
+ g = next;
+ }
+ if (g.status !== 'playing') throw new Error('Genuine Kull setup did not complete.');
+ g.deck.push(...g.players.flatMap(p => p.hand));
+ for (const p of g.players) { p.hand = []; delete p.bot; }
+ g.players[0].hand.push(takeKullCard(g, 'ix-kull-wahad'));
+ g.players[1].hand.push(takeKullCard(g, 'karama'));
+ g.players[2].hand.push(takeKullCard(g, 'Baliset'));
+ g.players[3].hand.push(takeKullCard(g, 'karama'));
+ Object.assign(g, { turn: 2, phase: 5, active: ids[1], storm: 18, ready: [], decision: null,
+ response: null, pendingKarama: null, phaseOpening: null, stormPending: null,
+ stormResolution: null, movementRemaining: [...ids] });
+ return g;
+}
+
+export function kullShipmentAttempt(g: Game, owner = g.players[1].id): Action {
+ const p = g.players.find(p => p.id === owner)!;
+ const card = p.hand.find(card => card.effect === 'karama' || (p.faction === 'beneGesserit' && card.kind === 'worthless'));
+ if (!card) throw new Error('Kull shipment fixture needs a held Karama.');
+ return { type: 'card', mode: 'shipment', card: card.id, target: owner };
+}
diff --git a/tests/nexus-choam-controls.test.ts b/tests/nexus-choam-controls.test.ts
index 7569d4ce..c61a242e 100644
--- a/tests/nexus-choam-controls.test.ts
+++ b/tests/nexus-choam-controls.test.ts
@@ -85,7 +85,6 @@ void test('CHOAM cost selection keeps printed powers unchanged and exposes expli
for (const play of blocked.choamWorthless!.plays)
if (play.effect === 'kulon')
play.blocked = 'This card is reserved for another committed action.';
- assert.match(html(blocked, 'kulon'), /reserved for another committed action/);
assert.match(html(blocked, 'kulon'), /disabled=""[^>]*>Use Kulon/);
for (const play of choamPowerPlays(blocked, 'kulon'))
assert.equal(choamPowerAction(blocked, { ...play, blocked: null }), null);
@@ -94,7 +93,6 @@ void test('CHOAM cost selection keeps printed powers unchanged and exposes expli
)!;
assert.ok(kull.blocked);
assert.equal(choamPowerAction(view, kull), null);
- assert.match(html(view, 'kulon'), /Kull Wahad.*not implemented/);
});
void test('CHOAM shared actions fence stale source events, actors and interruption windows without reading outsider private choices', () => {
diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts
index bec3c7c8..3a4ab552 100644
--- a/tests/prototype-room.test.ts
+++ b/tests/prototype-room.test.ts
@@ -256,3 +256,25 @@ void test('Semuta development profile begins only a fresh Richese lobby without
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
+
+void test('Kull preview starts a fresh ready CHOAM lobby once and preserves other rooms and seats', (t) => {
+ const { db } = fixture(t);
+ const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), false, ['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');
+ const result = startPrototypeRoom(db, game.code, 7, 'kull');
+ const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
+ assert.equal(result.status, 'setup');
+ assert.equal(saved.kullPreview, true);
+ 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, 'kull'));
+ assert.throws(() => startPrototypeRoom(db, game.code, 8, '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 888a6ffe..ed37eefb 100644
--- a/tests/table-turn.test.ts
+++ b/tests/table-turn.test.ts
@@ -9,6 +9,7 @@ import {
type Game,
} from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
+import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
function battle(advanced = false) {
let g = createGame(
@@ -204,3 +205,18 @@ void test('full-plan prescience names its publicly selected target before either
assert.deepEqual(g.battle!.fullPlan, { owner: 'a', target: 'b' });
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
});
+
+void test('real Kull offer gives CHOAM public decision ownership before the suspended actor', () => {
+ const initial = choamKullGame();
+ const choam = initial.players[0].id, actor = initial.players[1].id;
+ const offered = applyAction(initial, actor, kullShipmentAttempt(initial, actor));
+ assert.ok(offered.pendingKull);
+ assert.deepEqual(owners(offered), offered.players.map(() => choam));
+ 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',
+ });
+ 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 8d244593..765894e1 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -8,6 +8,7 @@ import {
initializeMoritaniAssassinateGameForAudit,
initializeEcazTreacheryGameForAudit,
initializeSemutaGameForAudit,
+ initializeKullGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -21,6 +22,7 @@ export const PROTOTYPE_PROFILES = [
'moritani-assassinate',
'ecaz-treachery',
'semuta',
+ 'kull',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
@@ -69,6 +71,8 @@ export function startPrototypeRoom(
? initializeMoritaniAssassinateGameForAudit(initial)
: profile === 'semuta'
? initializeSemutaGameForAudit(initial)
+ : profile === 'kull'
+ ? initializeKullGameForAudit(initial)
: profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial)
: profile === 'nexus'
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index 3f30ccf1..c7a1f394 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 --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. 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 --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. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}