diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index 3f023730..ec8fc3a0 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -57,6 +57,35 @@ reaches an actual worm Nexus but does not claim a Kull interruption there. 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. +## Basic Kull and Semuta during battle preparation — 28 October 2026 + +**Prototyped / Partial.** In the supported native Basic CHOAM/Richese printed +Kull and Semuta preview, CHOAM may use a held Kull against a printed Karama +attempt to cancel a declared Voice or Prescience. This covers a clean original +battle before plans are sealed, with no live Truthtrance promise or competing +parent. It retains the actual battle event, combatants, preparation, submitted +readiness and the existing response ownership; no leader, plan or result is +created by the interruption. + +If CHOAM declines, the original Karama continues through its native payment +and prevention path. If CHOAM declares Kull and the other players allow it, +the exact Kull is discarded and offered through the neutral Semuta response; +claim or pass leaves the attempted Karama held and the original battle power +in place. A different legal Karama may instead counter Kull: its physical +discard receives the Semuta opportunity before the original attempt resumes. +The original attempt's own eventual discard remains a separate native event. +Semuta cannot recover its holder's own discard, and no cost is paid or +battle power settled twice. + +The Kull frame binds the original Karama and source response; the new +battle-bound continuation also preserves the battle and its original controls +while the fresh discard is pending. Older non-battle receipts retain their +existing signature and behavior. This priority and the distinct counter are +provisional digital ordering, not a publisher ruling for the combination. +Advanced, battle promises, sealed plans, optional modules and other nested +parents remain outside this particular admission. It does not certify complete +rules, full-game or deployed behavior. + ## Basic Richese and Kull/Semuta — 27 October 2026 **Prototyped / Partial.** The fresh Basic printed-Kull opt-in includes diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2def57ed..595b292e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,16 @@ open mode gates. ## Current checkpoint and work +**Kull during Basic battles with Richese — 28 October. Prototyped / Partial.** +CHOAM may interrupt a Karama attempting to cancel Voice or Prescience before +battle plans. Success, decline and a distinct counter retain the original +battle and its separate Semuta discards. Seven related offline files pass 47 +cases, including genuine setup, shipments, Voice and Prescience, Semuta +claim/pass and continuation to revealed plans. Source setup selects unopened +card and Spice positions. Nested ordering is provisional; other battle +parents, optional modules and deployed acceptance remain open. +[Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026). + **Semuta after Nullentropy Box during Basic auctions — 28 October. Prototyped / Partial.** A completed paid Box search in a clean ordinary, Once Around or Silent cache lot may offer the discarded Box to Semuta. Claim or pass keeps @@ -28,9 +38,9 @@ parents, optional modules and deployed acceptance remain open. **In progress: Gitea migration — 28 October.** The local `origin` now tracks `https://git.juiced.computer/admin/Dune`; its imported `main` is an ancestor of the local checkout. Git push authentication and an Actions runner are not yet -available. The integration owner is preparing a Gitea-native container workflow -that preserves build, isolated verification and gated image publication; registry -selection and runner installation are pending the user's response. Existing NAS +available. A committed Gitea-native container workflow preserves build, +isolated verification and gated image publication; registry selection and +runner installation are pending the user's response. Existing NAS deployment and its persistent storage remain untouched. **Semuta before Basic Moritani ally retention — 28 October. Prototyped / diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 20fb71d0..004030d7 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Kull and Semuta in Basic battle preparation — 28 October 2026 + +The printed Kull power interrupts another player's Karama activation. In the +Basic CHOAM/Richese preview, apply it to clean Voice and Prescience cancellation +before plans, with the already-selected distinct counter and Semuta discard +ordering. That nested serialization remains a provisional software policy, not +a publisher ruling. [Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026). + ## Semuta after a Box search during Basic auctions — 28 October 2026 Semuta has no printed general bidding-phase exclusion. A completed Box search diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index c7f46416..fc6e2d02 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,31 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file. +## Kull and a Basic Voice or Prescience response — 28 October 2026 + +**Prototyped / Partial.** A clean Basic native CHOAM/Richese battle preparation +can suspend a printed Karama cancellation of a declared Voice or Prescience +for Kull and its Semuta response. The existing Kull frame retains the original +card, actor, response and battle event. Its battle-bound fresh-discard receipt +preserves preparation, shared readiness and other live battle fields while +the ordinary Semuta claim or pass is pending. + +A successful Kull offers its own physical discard and leaves the attempted +Karama unspent; the original declared power remains. A distinct Karama counter +offers that counter's own discard and then resumes the original cancellation +once. Declining Kull likewise resumes that cancellation without a Kull cost; +the resumed printed cost follows its own native Semuta opportunity. Claiming +a fresh card affects custody rather than undoing the settled interception or +counter. No suspended response, leader, plan, physical force or payment is +replayed. + +This serialization is provisional, not a publisher-specified nested priority. +The admission is limited to Basic battles with no sealed plans, live battle +promises or competing parent and the existing bare preview profile; Advanced +and other effects or optional-module combinations remain separate. The older +non-battle receipt signature is unchanged. Complete modes and deployed +acceptance remain open. [Scope](CHOAM_KULL_DESIGN.md#basic-kull-and-semuta-during-battle-preparation--28-october-2026). + ## Completed Box search during Basic bidding — 28 October 2026 **Prototyped / Partial.** In the supported bare Basic Semuta profile, a diff --git a/game/engine.ts b/game/engine.ts index d7a99033..2aa2cb58 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1773,6 +1773,8 @@ export type Game = { resume: NonNullable['resume']; declaration?: NonNullable; use?: Extract; + /** New pre-plan battle reactions bind the original live battle. */ + battleBinding?: true; stateSignature: string; } | { @@ -6873,8 +6875,18 @@ type KullSemutaDiscardContinuation = Extract< >; /** These native parents can be suspended without replaying another physical * transaction. More deeply nested response families retain their own gates. */ -function kullSemutaParentSupported(g: Game): boolean { - return !g.truthtrance && !g.battle && !g.pendingRicheseGift && !g.pendingNullentropy && +function kullSemutaParentSupported(g: Game, frame?: NonNullable): boolean { + const battle = g.battle; + const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined; + const battleSupported = !battle || !!frame && !g.advanced && g.phase === 6 && !battle.revealed && + !!battle.event && (!battle.preLeader || battle.preLeader.event === battle.event && + Array.isArray(battle.preLeader.ready) && new Set(battle.preLeader.ready).size === battle.preLeader.ready.length && + battle.preLeader.ready.every(id => id === battle.attacker || id === battle.defender)) && + Object.keys(battle.plans).length === 0 && !battle.truthPromises?.some(promise => !promise.released) && + use?.kind === 'cancel' && ['voice', 'prescience'].includes(use.response.kind) && + JSON.stringify(use.response) === JSON.stringify(frame.resume.response) && + !frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening; + return battleSupported && !g.truthtrance && !g.pendingRicheseGift && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingIxAlly && !g.pendingRevival && !g.pendingShipment && !g.pendingGuildTransport && !g.pendingHomeworldShipment && !g.pendingTerrorEntry && !g.pendingEcazPlacement && !g.pendingMoritaniPlacement && @@ -6890,6 +6902,7 @@ function kullSemutaDiscardSignature(g: Game, c: KullSemutaDiscardContinuation): parent: nullentropyParentSignature(g, c.resume, c.frame.event), active: g.active, order: g.order, auction: g.auction, richeseAuction: g.richeseAuction, restrictions: g.kullRestrictions, discard: g.discard.map(card => card.id), + ...(c.battleBinding ? {battle:g.battle} : {}), players: g.players.map(player => ({id:player.id, hand:player.hand.map(card => card.id), spice:player.spice, reserves:player.reserves, forces:player.forces, tanks:player.tanks})) }); } @@ -9234,7 +9247,8 @@ function semutaOfferSupported( ) { if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard') return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) && - !!byFaction(g, 'richese') && kullSemutaParentSupported(g) && entries.length === 1 && + !!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) && + (!!g.battle === !!continuation.battleBinding) && entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player && entries[0].card.id === continuation.card; // These completed public costs retain the original ordinary lot through the @@ -18743,7 +18757,7 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin } function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] { const original = g.pendingKull ? kullSource(g, g.pendingKull) : g; - if (g.semutaPreview && (!kullSemutaParentSupported(original) || source !== 'printed')) return []; + if (g.semutaPreview && (!kullSemutaParentSupported(original, g.pendingKull ?? undefined) || source !== 'printed')) return []; if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally || (g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam'))) return []; @@ -19025,13 +19039,14 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered if (g.semutaPreview && !g.advanced && g.pendingKull?.stage === 'counter' && use.kind === 'cancel' && use.response.kind === 'choamWorthless' && use.response.intent === 'Kull Wahad') { const declaration = g.pendingChoamWorthless; - requireRule(declaration && kullSemutaParentSupported(kullSource(g, g.pendingKull)), + requireRule(declaration && kullSemutaParentSupported(kullSource(g, g.pendingKull), g.pendingKull), 'This Kull counter cannot suspend its current parent for Semuta.'); const continuation: KullSemutaDiscardContinuation = { kind: 'kullCounterDiscard', player: p.id, card: used.id, frame: structuredClone(g.pendingKull), declaration: structuredClone(declaration), use, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + ...(g.battle ? {battleBinding:true as const} : {}), stateSignature: '', }; g.pendingKull = null; g.pendingChoamWorthless = null; @@ -36116,6 +36131,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl kind: 'kullSuccessDiscard', player: choam.id, card: used.id, frame, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + ...(g.battle ? {battleBinding:true as const} : {}), stateSignature: '', }; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; diff --git a/game/reference.ts b/game/reference.ts index f68c3e7e..42c9fc85 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4854,6 +4854,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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. This printed-only profile still excludes Nexus. The separate Nexus Kull profile adds one bounded Cunning interaction without changing older games; Some nested Richese/Semuta parents, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this printed preview. Nexus cards do not receive blanket immunity to Karama.', + 'In supported Basic CHOAM/Richese battles, Kull may also interrupt a printed Karama attempting to cancel a declared Voice or Prescience before plans are sealed. The same battle and original response remain in place. A successful Kull or a distinct counter offers its discarded card to Semuta before the original battle continues; this nested ordering is provisional.', ], 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; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.', related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], diff --git a/tests/fixture-kull-battle-semuta.ts b/tests/fixture-kull-battle-semuta.ts new file mode 100644 index 00000000..251a6be5 --- /dev/null +++ b/tests/fixture-kull-battle-semuta.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { advance as advanceKullSemuta, inventory, player } from './fixture-kull-richese-semuta'; +import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama'; + +export { inventory, player }; +export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); + +/** Public Basic setup with native CHOAM/Richese. Original unopened starting + * cards and a safe unopened Spice position are selected. Richese purchases the + * physical Semuta for one spice; Emperor and BG or Atreides actually ship into + * the same unoccupied stronghold. No played force, purse, phase or battle is installed. */ +export function createKullBattleSemutaFixture(options: { power?: 'voice' | 'prescience'; preReady?: boolean } = {}) { + const power = options.power ?? 'voice'; + const owner = power === 'voice' ? 'beneGesserit' : 'atreides'; + let game = createGame('KULLBATTLESEMUTA', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); + for (const faction of ['richese', 'emperor', owner, 'harkonnen'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game = act(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: true }); + for (const seat of game.players) game = act(game, seat.id, { type: 'ready' }); + game = act(game, game.host, { type: 'start' }); + assert.equal(game.status, 'setup'); + assert.ok(game.semutaPreview && game.kullPreview); + const originalCards = inventory(game), selected: Game['deck'] = []; + for (const kind of ['kull', 'filler', 'karama', 'filler', 'karama'] as const) { + const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : + kind === 'karama' ? card.effect === 'karama' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad'); + assert.ok(index >= 0, `The original unopened ${kind} must exist.`); + selected.push(game.deck.splice(index, 1)[0]); + } + game.deck.unshift(...selected); + game = advanceKullSemuta(game, state => state.status === 'playing'); + const [kull, , karama, , counter] = selected.map(card => card.id); + assert.ok(player(game, 'choam').hand.some(card => card.id === kull)); + assert.ok(player(game, 'emperor').hand.some(card => card.id === karama)); + assert.ok(player(game, 'harkonnen').hand.some(card => card.id === counter)); + const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); + assert.ok(land >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]); + game = advanceKullSemuta(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) && + !state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard); + assert.ok(player(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID)); + let target: { territory: string; sector: number } | null = null; + for (let count = 0; count < 1000 && game.phase !== 6; count++) { + if (game.phase === 5 && !game.response && !game.decision && !game.phaseOpening && + !game.pendingTreacheryDiscard && !game.pendingKull && game.active) { + const actor = game.active, seat = player(game, actor); + if ((actor === 'emperor' || actor === owner) && !seat.shipped) { + if (!target) { + const territory = TERRITORIES.find(t => t.type === 'stronghold' && t.sectors.some(s => s !== game.storm) && + game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`)))); + assert.ok(territory, 'A safe empty printed stronghold must be available.'); + target = { territory: territory.id, sector: territory.sectors.find(sector => sector !== game.storm)! }; + } + game = act(game, actor, { type: 'ship', territory: target.territory, sector: target.sector, + amount: 1, elite: 0, allyPayment: 0 }); + } else game = act(game, actor, { type: 'endMovement' }); + continue; + } + const next = nextSemutaRicheseCacheKarama(game); + assert.ok(next, `Complete the actual source control at ${game.phase}/${game.decision?.kind}.`); + game = act(game, next.actor, next.action); + } + assert.equal(game.phase, 6); assert.ok(target); + assert.equal(player(game, 'emperor').forces[`${target.territory}:${target.sector}`], 1); + assert.equal(player(game, owner).forces[`${target.territory}:${target.sector}`], 1); + // A native chooser owns this real battle; other seats' unspent choices are + // resolved by the same production policy before making the selection. + for (let count = 0; count < 30 && !game.battle; count++) { + const view = game.active ? viewGame(game, game.active) : null; + const targets = view?.battleChoices?.find(choice => choice.territory === target!.territory && + (choice.chooser ?? choice.attacker) === game.active && + [choice.attacker, choice.defender].includes(game.active!)); + if (targets) { + const opponent = game.active === 'emperor' ? owner : 'emperor'; + game = act(game, game.active!, { type: 'chooseBattle', territory: target.territory, target: opponent }); + break; + } + const next = nextSemutaRicheseCacheKarama(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.ok(game.battle); + for (let count = 0; count < 30 && game.battle?.preparation?.kind !== power; count++) { + const next = nextSemutaRicheseCacheKarama(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.equal(game.battle?.preparation?.kind, power); + if (options.preReady) { + const pre = game.battle!.preLeader; assert.ok(pre && !pre.closed); + game = act(game, 'emperor', { type: 'battlePreparationReady', event: pre.event }); + assert.deepEqual(game.battle!.preLeader!.ready, ['emperor']); + assert.equal(game.battle!.preLeader!.closed, false); + } + game = act(game, owner, power === 'voice' + ? { type: 'voice', kind: 'shield', must: false } + : { type: 'prescience', field: 'dial' }); + assert.equal(game.response?.kind, power); + assert.deepEqual(inventory(game), originalCards); + return { game, originalCards, target, kull, karama, counter, semuta: SEMUTA_DRUG_ID, + holder: 'richese', reactor: 'choam', owner, actor: 'emperor', counterOwner: 'harkonnen', power }; +} diff --git a/tests/kull-battle-semuta.test.ts b/tests/kull-battle-semuta.test.ts new file mode 100644 index 00000000..0fc4f809 --- /dev/null +++ b/tests/kull-battle-semuta.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { act, createKullBattleSemutaFixture, inventory, player } from './fixture-kull-battle-semuta'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +type Fixture = ReturnType; +const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card); + +function attempt(f: Fixture) { + const game = act(reload(f.game), f.actor, { type: 'card', mode: 'cancel', card: f.karama }); + assert.equal(game.pendingKull?.stage, 'offer'); + const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice); + assert.ok(choice.plays.some(row => row.source === 'printed' && row.card.id === f.kull && !row.blocked), + `Kull is missing in the actual Voice frame: ${JSON.stringify({ preLeader: game.battle?.preLeader, + preparation: game.battle?.preparation, frame: game.pendingKull?.intent, resume: game.pendingKull?.resume })}`); + assert.ok(held(game, f.actor, f.karama)); + return game; +} + +function declare(f: Fixture, game: Game) { + const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice); + const next = act(game, f.reactor, { type: 'kullDecision', event: choice.event, source: 'printed', card: f.kull }); + assert.equal(next.pendingKull?.stage, 'counter'); + return next; +} + +function passResponsesUntilDiscard(game: Game) { + for (let count = 0; count < 16 && !game.pendingTreacheryDiscard; count++) { + assert.ok(game.response, 'The native Kull counter must finish before its discard.'); + const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + return game; +} + +function react(game: Game, f: Fixture, expectedCard: string, claim: boolean) { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + const event = pending.batch.event; + const hadSemuta = held(game, f.holder, f.semuta); + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [expectedCard]); + if (claim) { + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + game = act(game, f.holder, { type: 'semutaCommit', event }); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game, f.holder, { type: 'semutaSelect', event, card: expectedCard }); + } else { + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { + const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer'); + const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'semutaPass', event }); + } + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + assert.equal(held(game, f.holder, expectedCard), claim); + assert.equal(held(game, f.holder, f.semuta), claim ? false : hadSemuta); + assert.deepEqual(inventory(game), f.originalCards); + assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event })); + return game; +} + +/** Finish only native responses and readiness, then seal real no-card plans. + * This proves the same battle remains actionable without replaying its setup. */ +function continueBattle(game: Game, f: Fixture, powerApplies: boolean) { + for (let count = 0; game.response && count < 16; count++) { + const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); + assert.ok(game.battle); + assert.equal(game.battle.territory, f.target.territory); + if (f.power === 'voice') assert.equal(!!game.battle.voice, powerApplies); + else assert.equal(!!game.battle.prescience, powerApplies); + for (let count = 0; game.battle?.preparation && count < 4; count++) { + const preparation = game.battle.preparation; + game = act(game, preparation.owner, preparation.kind === 'prescienceAnswer' + ? { type: 'prescienceAnswer', value: 0 } + : { type: 'declineBattlePower' }); + } + const event = game.battle!.event; + for (const actor of [f.actor, f.owner]) { + if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.battle.preLeader.ready.includes(actor)) + game = act(game, actor, { type: 'battlePreparationReady', event }); + } + for (const actor of [f.actor, f.owner]) { + const leader = player(game, actor).leaders.find(row => !row.dead && !row.capturedBy); assert.ok(leader); + game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 }); + } + assert.ok(game.battle?.plans[f.actor] && game.battle.plans[f.owner]); + assert.equal(game.battle.event, event); + for (const actor of [f.actor, f.owner]) game = act(game, actor, { type: 'traitorCall', call: false }); + assert.equal(game.battle?.revealed, true); + assert.deepEqual(inventory(game), f.originalCards); + return game; +} + +for (const claim of [false, true]) { + void test(`Basic Kull stops Prescience Karama with one combatant ready and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => { + const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true }); + assert.deepEqual(f.game.battle!.preLeader!.ready, [f.actor]); + const pending = passResponsesUntilDiscard(declare(f, attempt(f))); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard'); + assert.deepEqual(pending.battle!.preLeader!.ready, [f.actor]); + const game = react(pending, f, f.kull, claim); + assert.ok(held(game, f.actor, f.karama)); + assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]); + continueBattle(game, f, true); + }); +} + +for (const claim of [false, true]) { + void test(`Basic Prescience Kull counter preserves readiness and Semuta ${claim ? 'claims' : 'passes'} the counter`, () => { + const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true }); + const declared = declare(f, attempt(f)); + let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter }); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard'); + assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]); + game = react(game, f, f.counter, claim); + if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim); + assert.equal(game.pendingKull, null); + assert.equal(held(game, f.actor, f.karama), false); + assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]); + continueBattle(game, f, false); + }); +} + +for (const claim of [false, true]) { + void test(`Basic Kull stops battle Karama and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => { + const f = createKullBattleSemutaFixture(); + const pending = passResponsesUntilDiscard(declare(f, attempt(f))); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard'); + assert.ok(held(pending, f.actor, f.karama)); + const game = react(pending, f, f.kull, claim); + assert.ok(held(game, f.actor, f.karama)); + assert.equal(game.pendingKull, null); + assert.ok(viewGame(game, f.actor).karamaBlocked); + continueBattle(game, f, true); + }); +} + +void test('declining Kull permits the original battle Karama and its fresh Semuta reaction', () => { + const f = createKullBattleSemutaFixture(); + let game = attempt(f); + const event = game.pendingKull!.event; + game = act(game, f.reactor, { type: 'kullDecision', event, decline: true }); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaCombatDiscard'); + game = react(game, f, f.karama, true); + assert.ok(held(game, f.reactor, f.kull)); + assert.equal(game.pendingKull, null); + continueBattle(game, f, false); +}); + +for (const claim of [false, true]) { + void test(`a distinct counter stops battle Kull, then Semuta ${claim ? 'claims' : 'passes'} the counter`, () => { + const f = createKullBattleSemutaFixture(); + const declared = declare(f, attempt(f)); + assert.ok(viewGame(declared, f.counterOwner).responseControls?.cancelCards.includes(f.counter)); + let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter }); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard'); + assert.ok(held(game, f.reactor, f.kull)); + game = react(game, f, f.counter, claim); + assert.ok(held(game, f.reactor, f.kull)); + // The original attempted Karama is a separate fresh discard if its native + // combat cost resumes. It cannot reuse the already-spent Semuta card. + if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim); + assert.equal(game.pendingKull, null); + assert.equal(held(game, f.actor, f.karama), false); + continueBattle(game, f, false); + }); +}