From 71009d73e36b5092274402f96fae0c48b34b01a8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:57:25 +1100 Subject: [PATCH] Support Kull during Basic Guild and revival responses --- docs/CHOAM_KULL_DESIGN.md | 16 ++++ docs/CURRENT_STATUS.md | 22 +++-- game/engine.ts | 29 +++++- tests/fixture-kull-guild-semuta.ts | 67 ++++++++++++++ tests/fixture-kull-revival-semuta.ts | 49 ++++++++++ tests/kull-guild-semuta.test.ts | 132 +++++++++++++++++++++++++++ tests/kull-revival-semuta.test.ts | 125 +++++++++++++++++++++++++ 7 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 tests/fixture-kull-guild-semuta.ts create mode 100644 tests/fixture-kull-revival-semuta.ts create mode 100644 tests/kull-guild-semuta.test.ts create mode 100644 tests/kull-revival-semuta.test.ts diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index ec8fc3a0..78d74451 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,21 @@ # Kull Wahad: source limits and Karama interruption design +## Basic Guild and Revival responses — 28 October 2026 + +**Prototyped / Partial.** In the supported Basic CHOAM/Richese preview, a +printed Kull can interrupt a Karama that would cancel a Guild shipment rate or +eligible Revival. Its success or distinct counter offers the freshly discarded +card to Semuta before resuming the original response. Declining Kull preserves +the native Karama route. Pending shipment or Revival remains unpaid and +unresolved until its own continuation; full-price Guild cancellation still +checks affordability. The Guild tests cover reserve, cross-board and return +shipments, and the Revival tests cover the original forces and payment. + +The interaction follows the existing provisional digital ordering and does +not settle the general timing question. Advanced, optional modules and other +nested parents remain outside this admission. See [Guild evidence](../tests/kull-guild-semuta.test.ts) +and [Revival evidence](../tests/kull-revival-semuta.test.ts). + ## Advanced printed Kull in the lobby — 27 October 2026 **Prototyped / Partial.** The host can explicitly select the existing printed diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 595b292e..23aea532 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,15 @@ open mode gates. ## Current checkpoint and work +**Kull during Basic Guild shipment and Revival responses — 28 October. +Prototyped / Partial.** Native CHOAM/Richese Kull interruptions can suspend +eligible Karama responses while their original shipment or Revival remains +pending. Success, decline and distinct counter retain separate Semuta +claim/pass events and resume the original payment and force movement. Eight +related offline files pass 48 cases; types and lint pass. Ordering remains +provisional and unsupported nested parents, optional modules and deployed +acceptance remain open. [Scope](CHOAM_KULL_DESIGN.md#basic-guild-and-revival-responses--28-october-2026). + **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 @@ -36,12 +45,13 @@ parents, optional modules and deployed acceptance remain open. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#completed-box-search-during-basic-bidding--28-october-2026). **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. 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. +`https://git.juiced.computer/admin/Dune`. A repository-scoped deploy key pushes +over verified SSH on the local network; `main` reached `91ce096`. A dedicated +repository-scoped TrueNAS runner is active with its own Docker daemon and a +package-write Actions secret is configured for Gitea's registry. The first +Gitea-native build and image publication are in progress and require observed +success before the migration is certified. Existing NAS deployment and its +persistent storage remain untouched. **Semuta before Basic Moritani ally retention — 28 October. Prototyped / Partial.** In the bare Basic Semuta preview, mandatory loser cards form an diff --git a/game/engine.ts b/game/engine.ts index 2aa2cb58..53265169 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6878,6 +6878,26 @@ type KullSemutaDiscardContinuation = Extract< function kullSemutaParentSupported(g: Game, frame?: NonNullable): boolean { const battle = g.battle; const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined; + const nativeResponse = !!frame && !g.advanced && bareBasicSemutaProfile(g) && + use?.kind === 'cancel' && JSON.stringify(use.response) === JSON.stringify(frame.resume.response) && + !frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening; + let revivalSupported = false; + let guildSupported = false; + if (nativeResponse && frame && use?.kind === 'cancel') { + // Success has just installed this very Kull restriction. Validate the + // suspended source as it stood before that restriction, without changing + // the live game or removing any other player's restriction. + const restriction = kullSettledRestriction(g, frame); + const source = { ...g, kullRestrictions: (g.kullRestrictions ?? []).filter(row => + JSON.stringify(row) !== JSON.stringify(restriction)) }; + try { + revivalSupported = !!g.pendingRevival && semutaRevivalKaramaResponse(source, use.response, frame.owner); + guildSupported = (!!g.pendingShipment || !!g.pendingGuildTransport) && + semutaGuildShipmentKaramaResponse(source, use.response); + } catch (error) { + if (!(error instanceof RuleError)) throw error; + } + } 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 && @@ -6887,8 +6907,9 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; diff --git a/tests/fixture-kull-guild-semuta.ts b/tests/fixture-kull-guild-semuta.ts new file mode 100644 index 00000000..7a00131b --- /dev/null +++ b/tests/fixture-kull-guild-semuta.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { advance, inventory, player } from './fixture-kull-richese-semuta'; + +export { inventory, player }; +export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); +export type GuildRoute = 'reserve' | 'cross' | 'return'; + +/** Genuine Basic public setup and native auction/movement actions. Only the + * still-unopened starting Treachery deal and first Spice card are selected. */ +export function createKullGuildSemutaFixture(options: { route?: GuildRoute; poor?: boolean } = {}) { + const route = options.route ?? 'reserve'; + let game = createGame('KULLGUILDSEMUTA', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); + for (const faction of ['richese', 'emperor', 'guild', '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.kullPreview && game.semutaPreview); + 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); selected.push(game.deck.splice(index, 1)[0]); + } + game.deck.unshift(...selected); + game = advance(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 = advance(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) && + !state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard); + game = advance(game, state => state.phase === 5 && state.active === 'guild' && + !state.response && !state.decision && !state.phaseOpening && !state.pendingTreacheryDiscard); + if (options.poor) { + const excess = player(game, 'guild').spice - 2; assert.ok(excess >= 0); + if (excess) game = act(game, 'guild', { type: 'bribe', target: 'emperor', amount: excess }); + } + const beforeShipment = structuredClone(game); + const destination = TERRITORIES.find(territory => territory.type === 'sand' && + territory.sectors.some(sector => sector !== game.storm) && + game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${territory.id}:`)))); + assert.ok(destination); const sector = destination.sectors.find(value => value !== game.storm)!; + const source = Object.entries(player(game, 'guild').forces) + .find(([key, amount]) => amount >= 2 && Number(key.split(':')[1]) !== game.storm)?.[0]; + assert.ok(source); + const action: Action = route === 'reserve' + ? { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 } + : { type: 'guildShip', from: source, territory: route === 'return' ? 'reserves' : destination.id, + sector: route === 'return' ? 0 : sector, amount: 2, elite: 0, allyPayment: 0 }; + game = act(game, 'guild', action); + if (game.decision?.kind === 'guildShipment') game = act(game, 'guild', { type: 'decision', allow: true }); + for (let count = 0; game.response?.kind === 'guildTransportPermission' && count < game.players.length; count++) { + const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response?.kind, 'guildRate'); + assert.deepEqual(inventory(game), originalCards); + return { game, beforeShipment, originalCards, route, action, source, + target: `${destination.id}:${sector}`, kull, karama, counter, semuta: SEMUTA_DRUG_ID, + holder: 'richese', reactor: 'choam', actor: 'emperor', counterOwner: 'harkonnen', guild: 'guild' }; +} diff --git a/tests/fixture-kull-revival-semuta.ts b/tests/fixture-kull-revival-semuta.ts new file mode 100644 index 00000000..8b385b02 --- /dev/null +++ b/tests/fixture-kull-revival-semuta.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { applyAction, handLimit, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { + createSemutaRevivalBenefitsForcesFixture, + finishSemutaRevivalBenefitsForces, + semutaRevivalBenefitsForcesInventory as inventory, + semutaRevivalBenefitsForcesPlayer as player, +} from './fixture-semuta-revival-benefits-forces'; + +export { inventory, player, finishSemutaRevivalBenefitsForces as finishRevival }; +export const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); + +/** Original public Basic source has a real paid cache Semuta, real shipment, + * Storm casualties and CHOAM's actual three-force pending revival. This + * supplement moves only original physical Semuta, Kull and a distinct Karama + * among zones; no force, purse, quota, phase, response or request is installed. */ +export function createKullRevivalSemutaFixture() { + const source = createSemutaRevivalBenefitsForcesFixture({ kind: 'choamRevival', advanced: false }); + const game = structuredClone(source.game); + assert.ok(game.semutaPreview && game.kullPreview, 'Use the public Basic Kull and Semuta profile.'); + const reactor = source.holder, holder = source.seller, actor = source.spender, counterOwner = source.atreides; + const originalCards = inventory(game); + const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), + ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(row => row.hand)]; + const move = (id: string, destination: string) => { + const zone = zones.find(cards => cards.some(card => card.id === id)); + assert.ok(zone, `The original ${id} must occupy one physical zone.`); + const [card] = zone.splice(zone.findIndex(candidate => candidate.id === id), 1); + const receiver = player(game, destination); + assert.ok(receiver.hand.length < handLimit(receiver), `The staged recipient ${destination} must have room.`); + receiver.hand.push(card); + }; + move(SEMUTA_DRUG_ID, holder); + move('ix-kull-wahad', reactor); + const counter = zones.flat().find(card => card.effect === 'karama' && card.id !== source.cost && + !card.id.startsWith('richese-')); + assert.ok(counter, 'A second actual physical Karama must exist.'); + move(counter.id, counterOwner); + assert.ok(player(game, reactor).hand.some(card => card.id === 'ix-kull-wahad')); + assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(player(game, actor).hand.some(card => card.id === source.cost)); + assert.ok(player(game, counterOwner).hand.some(card => card.id === counter.id)); + assert.deepEqual(inventory(game), originalCards); + assert.deepEqual(game.pendingRevival, source.pending); + assert.deepEqual(game.response, source.originalResponse); + return { game, source, originalCards, reactor, holder, actor, counterOwner, + kull: 'ix-kull-wahad', karama: source.cost, counter: counter.id, semuta: SEMUTA_DRUG_ID }; +} diff --git a/tests/kull-guild-semuta.test.ts b/tests/kull-guild-semuta.test.ts new file mode 100644 index 00000000..11a2f9ae --- /dev/null +++ b/tests/kull-guild-semuta.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { act, createKullGuildSemutaFixture, inventory, player, type GuildRoute } from './fixture-kull-guild-semuta'; + +type Fixture = ReturnType; +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card); + +function originalUnpaid(game: Game, f: Fixture) { + const actual = player(game, f.guild), before = player(f.beforeShipment, f.guild); + assert.equal(actual.spice, before.spice); + assert.equal(actual.reserves, before.reserves); + assert.deepEqual(actual.forces, before.forces); + assert.equal(actual.shipped, false); +} +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'); originalUnpaid(game, f); + 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), + 'The public declared Guild-rate cancellation must offer the held Kull.'); + return game; +} +function declare(game: Game, f: Fixture) { + 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'); originalUnpaid(next, f); return next; +} +function passResponsesToDiscard(game: Game) { + for (let count = 0; count < 12 && !game.pendingTreacheryDiscard; count++) { + assert.ok(game.response); + const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'passResponse' }); + } + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); return game; +} +function react(game: Game, f: Fixture, card: string, claim: boolean) { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + const event = pending.batch.event; + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [card]); + originalUnpaid(game, f); + 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 }); + } 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 seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'semutaPass', event }); + } + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + assert.equal(held(game, f.holder, card), claim); + assert.deepEqual(inventory(game), f.originalCards); + assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event })); + return game; +} +/** Resume only the original response and its fresh discards, not a new shipment. */ +function finishShipment(game: Game, f: Fixture) { + for (let count = 0; count < 32; count++) { + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const reaction = game.pendingTreacheryDiscard.reaction, event = game.pendingTreacheryDiscard.batch.event; + const seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'semutaPass', event }); continue; + } + if (game.response) { + const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'passResponse' }); continue; + } + break; + } + assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.equal(game.response ?? null, null); + assert.equal(game.pendingKull ?? null, null); + assert.deepEqual(inventory(game), f.originalCards); + return game; +} +function settled(game: Game, f: Fixture, rateCanceled: boolean) { + const before = player(f.beforeShipment, f.guild), after = player(game, f.guild); + const half = (f.game.pendingShipment ?? f.game.pendingGuildTransport)!.cost; + const full = f.route === 'return' ? 2 : 4; + const cost = rateCanceled ? full : half; + if (rateCanceled && before.spice < full) { + assert.equal(after.shipped, false); assert.equal(after.spice, before.spice); + assert.equal(after.reserves, before.reserves); assert.deepEqual(after.forces, before.forces); + return; + } + assert.equal(after.shipped, true); assert.equal(after.spice, before.spice - cost); + assert.equal(after.reserves, before.reserves + (f.route === 'reserve' ? -2 : f.route === 'return' ? 2 : 0)); + assert.equal(after.forces[f.source] ?? 0, (before.forces[f.source] ?? 0) - (f.route === 'reserve' ? 0 : 2)); + if (f.route !== 'return') assert.equal(after.forces[f.target] ?? 0, 2); +} + +for (const route of ['reserve', 'cross', 'return'] as GuildRoute[]) for (const claim of [false, true]) { + void test(`Basic Kull stops Guild ${route} rate cancellation and Semuta ${claim ? 'claims' : 'passes'}`, () => { + const f = createKullGuildSemutaFixture({ route }); + let game = passResponsesToDiscard(declare(attempt(f), f)); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'kullSuccessDiscard'); + game = react(game, f, f.kull, claim); + assert.ok(held(game, f.actor, f.karama)); + game = finishShipment(game, f); settled(game, f, false); + }); + void test(`Basic Guild ${route} Kull counter and Semuta ${claim ? 'claim' : 'pass'} resume the original cancellation`, () => { + const f = createKullGuildSemutaFixture({ route }); + let game = act(declare(attempt(f), f), f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter }); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'kullCounterDiscard'); + game = react(game, f, f.counter, claim); + if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim); + assert.equal(held(game, f.actor, f.karama), false); + game = finishShipment(game, f); settled(game, f, true); + }); +} + +void test('Declining Kull continues the original Guild-rate Karama and its separate Semuta offer', () => { + const f = createKullGuildSemutaFixture(); let game = attempt(f); + game = act(game, f.reactor, { type: 'kullDecision', event: game.pendingKull!.event, decline: true }); + assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'karamaShipmentDiscard'); + game = react(game, f, f.karama, true); + assert.ok(held(game, f.reactor, f.kull)); + game = finishShipment(game, f); settled(game, f, true); +}); + +void test('Kull counter cannot make an unaffordable full-price Guild shipment occur', () => { + const f = createKullGuildSemutaFixture({ poor: true }); + let game = act(declare(attempt(f), f), f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter }); + game = react(game, f, f.counter, false); + if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, true); + game = finishShipment(game, f); settled(game, f, true); +}); diff --git a/tests/kull-revival-semuta.test.ts b/tests/kull-revival-semuta.test.ts new file mode 100644 index 00000000..1b907f82 --- /dev/null +++ b/tests/kull-revival-semuta.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { act, createKullRevivalSemutaFixture, finishRevival, inventory, player } from './fixture-kull-revival-semuta'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +type Fixture = ReturnType; +const held = (game: Game, seat: string, card: string) => player(game, seat).hand.some(row => row.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)); + assert.deepEqual(game.pendingRevival, f.source.pending); + 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'); + assert.deepEqual(next.pendingRevival, f.source.pending); + return next; +} + +function passCounter(game: Game) { + for (let count = 0; count < 16 && !game.pendingTreacheryDiscard; count++) { + assert.ok(game.response, 'The native Kull counter remains open.'); + const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'passResponse' }); + } + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + return game; +} + +function react(game: Game, f: Fixture, card: string, claim: boolean) { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + const event = pending.batch.event, hadSemuta = held(game, f.holder, f.semuta); + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [card]); + assert.deepEqual(game.pendingRevival, f.source.pending); + 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 }); + } 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 seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type: 'semutaPass', event }); + } + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + assert.equal(held(game, f.holder, card), 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; +} + +/** Resolve only the original native revival and its Tleilaxu income response. + * No subsequent request, payment, quota or phase transition is fabricated. */ +function assertOriginalReturn(game: Game, f: Fixture, canceled: boolean) { + const prior = player(f.game, f.source.recipient); + const otherPrior = player(f.game, f.source.tleilaxu); + const nativeCost = canceled ? 6 : 3; + game = finishRevival(game); + const current = player(game, f.source.recipient); + assert.equal(game.pendingRevival ?? null, null); + assert.equal(game.response ?? null, null); + assert.equal(current.reserves, prior.reserves + 3); + assert.equal(current.tanks, prior.tanks - 3); + assert.equal(current.revived, prior.revived + 3); + assert.equal(current.freeForcesRevived ?? 0, prior.freeForcesRevived ?? 0); + assert.equal(current.spice, prior.spice - nativeCost); + assert.equal(player(game, f.source.tleilaxu).spice, otherPrior.spice + nativeCost); + assert.equal(game.phase, f.game.phase); + assert.equal(game.turn, f.game.turn); + assert.deepEqual(inventory(game), f.originalCards); + return game; +} + +for (const claim of [false, true]) { + void test(`Basic Kull protects its original native revival and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => { + const f = createKullRevivalSemutaFixture(); + const pending = passCounter(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.equal(game.pendingKull, null); + assert.ok(held(game, f.actor, f.karama)); + assert.ok(viewGame(game, f.actor).karamaBlocked); + assertOriginalReturn(game, f, false); + }); +} + +void test('declining Kull permits the original Revival cancellation and Semuta can claim its spent Karama', () => { + const f = createKullRevivalSemutaFixture(); + let game = attempt(f); + game = act(game, f.reactor, { type: 'kullDecision', event: game.pendingKull!.event, decline: true }); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaRevivalDiscard'); + game = react(game, f, f.karama, true); + assert.equal(game.pendingKull, null); + assert.ok(held(game, f.reactor, f.kull)); + assertOriginalReturn(game, f, true); +}); + +for (const claim of [false, true]) { + void test(`a distinct Kull counter preserves the native Revival request while Semuta ${claim ? 'claims' : 'passes'} it`, () => { + const f = createKullRevivalSemutaFixture(); + 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.actor, f.karama)); + game = react(game, f, f.counter, claim); + assert.ok(held(game, f.reactor, f.kull)); + if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim); + assert.equal(game.pendingKull, null); + assert.equal(held(game, f.actor, f.karama), false); + assertOriginalReturn(game, f, true); + }); +}