Enable Kull during Basic battle preparation
Verify and publish checkpoint container / container (push) Failing after 7m45s

This commit is contained in:
Quintine committed 2026-10-28 20:47:15 +11:00
1 parent 6bf165a6aa
commit 91ce096818
8 files changed
+371 -8

No files matched your search

+29
View File
@@ -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
+13 -3
View File
@@ -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 /
+8
View File
@@ -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
+25
View File
@@ -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
+21 -5
View File
@@ -1773,6 +1773,8 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
declaration?: NonNullable<Game['pendingChoamWorthless']>;
use?: Extract<KaramaUse, { kind: 'cancel' }>;
/** 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<Game['pendingKull']>): 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;
+1
View File
@@ -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'],
+102
View File
@@ -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 };
}
+172
View File
@@ -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<typeof createKullBattleSemutaFixture>;
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);
});
}