Support Kull during Basic Guild and revival responses
Verify and publish checkpoint container / container (push) Failing after 6m28s

This commit is contained in:
Quintine committed 2026-10-28 20:57:25 +11:00
1 parent 91ce096818
commit 71009d73e3
7 files changed
+431 -9

No files matched your search

+16
View File
@@ -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
+16 -6
View File
@@ -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
+26 -3
View File
@@ -6878,6 +6878,26 @@ type KullSemutaDiscardContinuation = Extract<
function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKull']>): 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<Game['pendingKul
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.pendingAmbassador && !g.pendingIxAlly && (!g.pendingRevival || revivalSupported) &&
(!g.pendingShipment || guildSupported) && (!g.pendingGuildTransport || guildSupported) &&
!g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && !g.currentAuctionSale &&
@@ -37547,7 +37568,9 @@ function savedNoFieldResponses(g: Game): ResponseWindow[] {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
continuation && 'resume' in continuation ? continuation.resume : null,
g.pendingKull?.stage === 'counter' ? g.pendingKull.resume : null,
continuation?.kind === 'kullCounterDiscard' ? continuation.frame.resume : null];
return contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
+67
View File
@@ -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' };
}
+49
View File
@@ -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 };
}
+132
View File
@@ -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<typeof createKullGuildSemutaFixture>;
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);
});
+125
View File
@@ -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<typeof createKullRevivalSemutaFixture>;
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);
});
}