Support Kull during Basic Guild and revival responses
Verify and publish checkpoint container / container (push) Failing after 6m28s
Verify and publish checkpoint container / container (push) Failing after 6m28s
This commit is contained in:
1 parent
91ce096818
commit
71009d73e3
7 files changed
+431
-9
No files matched your search
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user