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 # 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 ## Advanced printed Kull in the lobby — 27 October 2026
**Prototyped / Partial.** The host can explicitly select the existing printed **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 ## 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.** **Kull during Basic battles with Richese — 28 October. Prototyped / Partial.**
CHOAM may interrupt a Karama attempting to cancel Voice or Prescience before CHOAM may interrupt a Karama attempting to cancel Voice or Prescience before
battle plans. Success, decline and a distinct counter retain the original 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). [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 **In progress: Gitea migration — 28 October.** The local `origin` now tracks
`https://git.juiced.computer/admin/Dune`; its imported `main` is an ancestor of `https://git.juiced.computer/admin/Dune`. A repository-scoped deploy key pushes
the local checkout. Git push authentication and an Actions runner are not yet over verified SSH on the local network; `main` reached `91ce096`. A dedicated
available. A committed Gitea-native container workflow preserves build, repository-scoped TrueNAS runner is active with its own Docker daemon and a
isolated verification and gated image publication; registry selection and package-write Actions secret is configured for Gitea's registry. The first
runner installation are pending the user's response. Existing NAS Gitea-native build and image publication are in progress and require observed
deployment and its persistent storage remain untouched. success before the migration is certified. Existing NAS deployment and its
persistent storage remain untouched.
**Semuta before Basic Moritani ally retention — 28 October. Prototyped / **Semuta before Basic Moritani ally retention — 28 October. Prototyped /
Partial.** In the bare Basic Semuta preview, mandatory loser cards form an 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 { function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKull']>): boolean {
const battle = g.battle; const battle = g.battle;
const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined; 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 && const battleSupported = !battle || !!frame && !g.advanced && g.phase === 6 && !battle.revealed &&
!!battle.event && (!battle.preLeader || battle.preLeader.event === battle.event && !!battle.event && (!battle.preLeader || battle.preLeader.event === battle.event &&
Array.isArray(battle.preLeader.ready) && new Set(battle.preLeader.ready).size === battle.preLeader.ready.length && 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) && JSON.stringify(use.response) === JSON.stringify(frame.resume.response) &&
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening; !frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
return battleSupported && !g.truthtrance && !g.pendingRicheseGift && !g.pendingNullentropy && return battleSupported && !g.truthtrance && !g.pendingRicheseGift && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingIxAlly && !g.pendingRevival && !g.pendingShipment && !g.pendingAmbassador && !g.pendingIxAlly && (!g.pendingRevival || revivalSupported) &&
!g.pendingGuildTransport && !g.pendingHomeworldShipment && !g.pendingTerrorEntry && (!g.pendingShipment || guildSupported) && (!g.pendingGuildTransport || guildSupported) &&
!g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement && !g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange && !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && !g.currentAuctionSale && !g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && !g.currentAuctionSale &&
@@ -37547,7 +37568,9 @@ function savedNoFieldResponses(g: Game): ResponseWindow[] {
const continuation = g.pendingTreacheryDiscard?.continuation; const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.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 => { return contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : 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);
});
}