Preserve native BG conversion through Semuta physical costs and counters

This commit is contained in:
Quintine committed 2026-10-16 02:41:42 +11:00
1 parent 8a2df97be8
commit f587374923
13 files changed
+693 -63

No files matched your search

+18 -2
View File
@@ -3,6 +3,7 @@ import { botActions } from '../game/bots';
import { takeKullCard } from './fixture-choam-kull';
import { orderNexusSpice } from './fixture-nexus-cards';
import type { FactionId } from '../game/catalog';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export function createNexusChoamKullFixture(options: {
advanced?: boolean; bg?: boolean; seatIds?: string[]; nexusFace?: 'choam' | 'richese';
@@ -79,6 +80,22 @@ export function createNexusChoamKullFixture(options: {
original: original.id, fuel: fuel.id, counter: counter?.id ?? null };
}
/** Follow the real pre-inspection queue; never clear native preparation by hand. */
export function passNexusKullBorrowedVoice(state: Game): Game {
let game = state;
for (let n = 0; game.battle && n < 30 &&
((game.battle.preLeader && !game.battle.preLeader.closed) ||
game.battle.preparation?.kind === 'nexusVoice'); n++) {
const next = nextStrongholdFactionsNativeStep(game);
if (!next) throw new Error('The original pre-Voice preparation needs a legal native action.');
game = applyAction(game, next.actor, next.action);
}
if ((game.battle?.preLeader && !game.battle.preLeader.closed) ||
game.battle?.preparation?.kind === 'nexusVoice')
throw new Error('The original pre-leader and borrowed Voice queues must actually finish.');
return game;
}
/** A native declaration is produced by actions before its Karama/Kull parent
* is suspended. The final faction roster is fixed before genuine setup. */
export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | 'advisors' | 'guild',
@@ -116,8 +133,7 @@ export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | '
game.active = choice.chooser;
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
for (const id of [f.actor, f.other!]) if (game.battle!.preLeader && !game.battle!.preLeader.closed)
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.event });
game = passNexusKullBorrowedVoice(game);
if (kind === 'inspection') {
if (game.response) game = allowNativeParent(game);
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
export type SemutaBgUse = 'shipment' | 'cancel' | 'purchase' | 'auctionPayment';
export type SemutaBgStep = { actor: string; action: Action };
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
/** Original native BG/Richese/classic setup, actual paid Semuta, Karama and
* Worthless auctions. Only original undealt lotteries/deck order are selected;
* no played hand/army/wallet/phase/response is staged. */
export function createOriginalSemutaBg(use: SemutaBgUse = 'shipment', homeworlds = false) {
let game = createGame('SEMUTABGCONVERSION', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
for (const [id, name, faction] of [['b', 'Bene Gesserit', 'beneGesserit'], ['a', 'Atreides', 'atreides'],
['e', 'Emperor', 'emperor'], ['g', 'Guild', 'guild']] as const) joinGame(game, newPlayer(id, name, faction));
if (homeworlds) game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const actions: SemutaBgStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; !until(game) && n < 1800; n++) {
assert.notEqual(game.status, 'finished');
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
}
assert.ok(until(game), `Original BG source stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`);
};
reach(state => state.status === 'playing');
const setup = structuredClone(game);
const ki = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(ki >= 0);
const karama = game.deck.splice(ki, 1)[0];
const wi = game.deck.findIndex(card => card.kind === 'worthless'); assert.ok(wi >= 0);
const worthless = game.deck.splice(wi, 1)[0]; game.deck.unshift(karama, worthless);
for (const position of [0, 1]) {
const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card);
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
reach(state => state.decision?.kind === 'richeseCache');
act('r', { type: 'decision', event: game.richeseBidding!.event, card: 'richese-semuta-drug',
method: 'onceAround', direction: 'counterclockwise' });
reach(state => !!state.richeseAuction && !state.richeseAuction.outcome &&
state.richeseAuction.active === 'a' && clean(state));
act('a', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 });
reach(state => player(state, 'a').hand.some(card => card.id === 'richese-semuta-drug') && clean(state));
const boughtSemuta = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'e' && clean(state));
act('e', { type: 'bid', amount: 1 });
reach(state => player(state, 'e').hand.some(card => card.id === karama.id) && clean(state));
const boughtKarama = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 1 && state.auction.active === 'b' && clean(state));
act('b', { type: 'bid', amount: 1 });
reach(state => player(state, 'b').hand.some(card => card.id === worthless.id) && clean(state));
const boughtWorthless = structuredClone(game);
let action: Action, lot: string | undefined, originalResponse: Game['response'] = null;
let target: string | undefined;
if (use === 'purchase' || use === 'auctionPayment') {
reach(state => !!state.auction && state.auction.index === 2 && state.auction.active === 'b' && clean(state));
lot = game.auction!.cards[game.auction!.index].id;
if (use === 'purchase') action = { type: 'card', card: worthless.id, mode: 'purchase' };
else {
act('b', { type: 'bid', amount: player(game, 'b').spice + 1 });
reach(state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'b' && !state.response && !state.phaseOpening);
action = { type: 'decision', karama: true, card: worthless.id };
}
} else {
reach(state => state.phase === 5 && state.active === (use === 'cancel' ? 'g' : 'a') && clean(state));
if (use === 'shipment') action = { type: 'card', card: worthless.id, mode: 'shipment', target: 'a' };
else {
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
target = `${destination.id}:${sector}`;
act('g', { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 });
if (game.decision?.kind === 'guildShipment') act('g', { type: 'decision', allow: true });
assert.equal(game.response?.kind, 'guildRate'); originalResponse = structuredClone(game.response);
action = { type: 'card', card: worthless.id, mode: 'cancel' };
}
}
const beforeCost = structuredClone(game);
act('b', action);
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
assert.equal(game.pendingKarama, null);
return { setup, boughtSemuta, boughtKarama, boughtWorthless, actions, beforeCost,
game, action, use, homeworlds, lot, target, originalResponse,
owner: 'b', holder: 'a', counter: 'e', beneficiary: 'a', guild: 'g',
worthless, karama, semuta: 'richese-semuta-drug',
provenance: [
'Original five-faction Semuta setup and native BG choices; only original undealt entropy/deck order selected.',
'Atreides pays1 for public Semuta; Emperor pays1 for normal Karama; BG pays1 for the actual normal Worthless.',
'Real native phase/lot progression and optional native Guild declaration or actual BG overbid; no played state staged.',
] };
}
export function passSemutaBgResponse(state: Game, kind = 'worthlessKarama') {
let game = state;
for (let n = 0; game.response?.kind === kind && n < 30; n++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Only native already-earned arrival/inspection continuations, never another
* shipment, bid, card spend or invented controller. */
export function settleSemutaBgNative(state: Game) {
let game = state;
for (let n = 0; n < 80; n++) {
if (!game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard) return game;
if (game.pendingTreacheryDiscard) throw Error('Resolve the actual fresh Semuta event first.');
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
else if (game.decision?.kind === 'guildShipment') game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
else if (game.decision?.kind === 'homeworldShipmentGuild') game = applyAction(game, game.decision.player,
{ type: 'decision', event: game.decision.event, allow: true });
else throw Error(`Finish only the original native suffix: ${game.decision?.kind ?? 'phase opening'}`);
}
throw Error('Original BG native suffix did not settle.');
}
/** CONTROLLED acquisition/phase fixture for a real subsequent CHOAM rediscard,
* not a synthetic discard or an original-action acquisition claim. Native
* roles/setup/stocks/wallets are genuine and are never replaced after setup. */
export function createControlledBgClaimedPriceRefund() {
let game = createGame('BGCLAIMEDCASHOUT', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
for (const [id, faction] of [['b', 'beneGesserit'], ['c', 'choam'], ['g', 'guild'], ['a', 'atreides']] as const)
joinGame(game, newPlayer(id, faction, faction));
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
game = initializeSemutaGameForAudit(game);
for (let n = 0; game.status === 'setup' && n < 300; n++) {
const next = nextSemutaGuildIncomeNativeStep(game); game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const worthless = game.deck.find(card => card.kind === 'worthless'); assert.ok(worthless);
const karamas = game.deck.filter(card => card.effect === 'karama').slice(0, 2);
assert.equal(karamas.length, 2);
moveSemutaEmperorCard(game, worthless.id, 'b');
moveSemutaEmperorCard(game, karamas[0].id, 'c');
moveSemutaEmperorCard(game, karamas[1].id, 'g');
moveSemutaEmperorCard(game, 'richese-semuta-drug', 'c');
game.phase = 5; game.active = 'a'; game.order = ['a', 'b', 'c', 'g', 'r'];
game.movementRemaining = ['a', 'b', 'c', 'g', 'r']; game.ready = [];
game.response = null; game.decision = null; game.phaseOpening = null;
return { game, worthless, cashout: karamas[0], stop: karamas[1], holder: 'c',
owner: 'b', beneficiary: 'a', guild: 'g', semuta: 'richese-semuta-drug',
provenance: ['CONTROLLED conserved later acquisition and phase after genuine native BG/CHOAM/Richese/classic setup.',
'Original native armies/wallets retained; actual BG conversion, Semuta recovery, CHOAM cashout and Guild stop thereafter.'] };
}
+4 -11
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { createNexusChoamKullFixture, createNexusKullNativeParent, createNexusKullNestedParent,
createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
createNexusKullAuctionParent, passNexusKullBorrowedVoice } from './fixture-nexus-choam-kull';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
import { spiceDeck } from '../game/cards';
import { createNexusCardPhase } from '../game/nexus-card-phase';
@@ -243,22 +243,15 @@ void test('printed Kull discovers a suspended Nexus parent through direct and BG
});
void test('Nexus fuel needed by a live own Truthtrance battle promise cannot be accepted', () => {
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
const f = createNexusChoamKullFixture({ fuel: 'weapon', actorFaction: 'atreides' });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
Object.assign(game, { phase: 6, active: f.choam, karamaShipping: null });
for (const p of game.players) { p.forces = {}; p.reserves = 20; }
for (const p of game.players.slice(0, 2)) { p.forces = { 'arrakeen:10': 3 }; p.reserves = 17; }
game = applyAction(game, f.choam, { type: 'chooseBattle', territory: 'arrakeen', target: f.actor });
for (let step = 0; game.response && step < 20; step++) {
const p = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game);
}
game.battle!.preparation = undefined;
game = passNexusKullBorrowedVoice(game);
game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }];
game.response = { kind: 'eliteStrength', owner: f.actor, passed: [] };
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
const offered = applyAction(game, f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false);
reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'nexus', card: f.fuel });
+157
View File
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import {
createOriginalSemutaBg, createControlledBgClaimedPriceRefund,
passSemutaBgResponse, settleSemutaBgNative, type SemutaBgUse,
} from './fixture-semuta-bg-karama';
import { reactSemutaSpecial } from './fixture-semuta-special-karama';
import { passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!;
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function ordinaryPriceShipment(game: Game): Action {
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm) &&
game.players.every(p => presenceAt(p, t.id) === 0));
assert.ok(destination);
return { type: 'ship', territory: destination.id, sector: destination.sectors.find(s => s !== game.storm)!,
amount: 3, elite: 0, allyPayment: 0 };
}
for (const use of ['shipment', 'cancel', 'purchase', 'auctionPayment'] as SemutaBgUse[])
void test(`original BG ${use} keeps both fresh costs and its original native allowance or denial`, () => {
for (const claim of [false, true]) for (const canceled of [false, true]) {
const f = createOriginalSemutaBg(use), before = structuredClone(f.beforeCost);
let game = f.game;
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:conversion');
assert.equal(game.pendingKarama, null);
if (use === 'shipment') assert.equal(game.karamaShipping, null);
reject(game, f.owner, f.action);
game = reactSemutaSpecial(game, f.holder, claim);
assert.equal(game.response?.kind, 'worthlessKarama');
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
assert.ok(viewGame(game, f.counter).responseControls!.cancelCards.includes(f.karama.id));
if (canceled) {
game = applyAction(game, f.counter, { type: 'card', mode: 'cancel', card: f.karama.id });
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:worthlessKarama');
game = reactSemutaSpecial(game, f.holder, !claim);
assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama.id), !claim);
} else game = passSemutaBgResponse(game);
assert.equal(game.pendingKarama, null);
const recovered = player(game, f.holder).hand.find(c => c.id === f.worthless.id);
assert.equal(!!recovered, claim);
if (recovered) assert.deepEqual(recovered, f.worthless);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim || canceled ? 1 : 0);
if (use === 'shipment') {
if (canceled) {
assert.equal(game.karamaShipping, null);
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves);
assert.equal(player(game, f.beneficiary).shipped, false);
} else {
assert.equal(viewGame(game, f.beneficiary).karamaShipping!.owner, f.owner);
const shipment = ordinaryPriceShipment(game);
game = settleSemutaBgNative(applyAction(game, f.beneficiary, shipment));
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 3);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
assert.equal(player(game, f.beneficiary).forces[`${String(shipment.territory)}:${Number(shipment.sector)}`], 3);
assert.equal(game.karamaShipping, null);
}
} else if (use === 'cancel') {
// Original already-passed seats stay passed: native normalization may
// finish this restored response automatically. Assert its actual price.
if (game.response?.kind === 'guildRate') game = passSemutaBgResponse(game, 'guildRate');
game = settleSemutaBgNative(game);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice - (canceled ? 2 : 4));
assert.equal(player(game, f.guild).reserves, player(before, f.guild).reserves - 2);
assert.equal(player(game, f.guild).forces[f.target!], 2);
assert.equal(player(game, f.guild).shipped, true);
assert.equal(player(game, f.guild).moved, 0);
} else {
assert.equal(player(game, f.owner).hand.filter(c => c.id === f.lot).length, canceled ? 0 : 1);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
if (canceled) assert.equal(game.auction!.cards[game.auction!.index].id, f.lot);
}
}
});
void test('an original converted price retains exact Caladan withdrawal and bank-only world payment after cost recovery', () => {
for (const claim of [false, true]) {
const f = createOriginalSemutaBg('shipment', true);
const before = structuredClone(f.beforeCost);
let game = passSemutaBgResponse(reactSemutaSpecial(f.game, f.holder, claim));
const quote = homeworldShipmentChoice(viewGame(game, f.beneficiary), 'homeworld:emperor',
{ 'homeworld:atreides': { normal: 3, elite: 0 } });
assert.ok(quote.action, quote.blocked ?? undefined); assert.equal(quote.cost, 2);
game = settleSemutaBgNative(applyAction(game, f.beneficiary, quote.action));
assert.equal(player(game, f.beneficiary).spice, player(before, f.beneficiary).spice - 2);
assert.equal(player(game, f.beneficiary).reserves, player(before, f.beneficiary).reserves - 3);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice);
assert.deepEqual(game.homeworlds!.custody!.visitors['homeworld:emperor'][f.beneficiary], { normal: 3, elite: 0 });
assert.equal(player(game, f.holder).hand.some(c => c.id === f.worthless.id), claim);
assert.equal(game.karamaShipping, null);
}
});
void test('a controlled real CHOAM rediscard cannot revive refund eligibility for a recovered converted price cost', () => {
const f = createControlledBgClaimedPriceRefund();
let game = applyAction(f.game, f.owner, { type: 'card', card: f.worthless.id,
mode: 'shipment', target: f.beneficiary });
game = passSemutaBgResponse(reactSemutaSpecial(game, f.holder, true));
const cashoutSpice = player(game, f.holder).spice;
game = applyAction(game, f.holder, { type: 'card', mode: 'special', card: f.cashout.id,
cards: [f.worthless.id] });
game = passSemutaSpecialDiscards(game);
assert.equal(player(game, f.holder).spice, cashoutSpice + 3);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
const before = structuredClone(player(game, f.beneficiary));
game = applyAction(game, f.beneficiary, ordinaryPriceShipment(game));
game = applyAction(game, f.guild, { type: 'card', mode: 'special', card: f.stop.id });
game = passSemutaSpecialDiscards(game);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.worthless.id), false);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
assert.equal(player(game, f.beneficiary).spice, before.spice);
assert.equal(player(game, f.beneficiary).reserves, before.reserves);
assert.equal(player(game, f.beneficiary).shipped, true);
assert.equal(player(game, f.beneficiary).moved, 0);
assert.equal(game.karamaShipping, null);
});
void test('recovering a Worthless does not grant its new faction the native BG conversion role', () => {
const f = createOriginalSemutaBg();
let game = reactSemutaSpecial(f.game, f.holder, true);
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
game = reactSemutaSpecial(game, f.holder, false);
assert.equal(game.karamaShipping, null);
assert.equal(game.active, f.beneficiary);
assert.equal(player(game, f.beneficiary).shipped, false);
assert.deepEqual(player(game, f.holder).hand.find(c => c.id === f.worthless.id), f.worthless);
reject(game, f.holder, { type: 'card', card: f.worthless.id, mode: 'shipment', target: f.beneficiary });
});
void test('changed source and stale conversion/counter events cannot redirect the original physical cost or power', () => {
const f = createOriginalSemutaBg();
const event = f.game.pendingTreacheryDiscard!.batch.event;
reject(f.game, f.owner, { type: 'semutaCommit', event });
const changed = structuredClone(f.game);
const continuation = changed.pendingTreacheryDiscard!.continuation;
assert.ok(continuation?.kind === 'bgKaramaCostDiscard' && continuation.use.kind === 'shipment');
continuation.use.recipient = f.guild;
reject(changed, f.holder, { type: 'semutaCommit', event });
let game = reactSemutaSpecial(f.game, f.holder, false);
game = applyAction(game, f.counter, { type: 'card', card: f.karama.id, mode: 'cancel' });
reject(game, f.holder, { type: 'semutaCommit', event });
game = reactSemutaSpecial(game, f.holder, true);
assert.equal(game.karamaShipping, null);
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.karama.id).length, 1);
assert.equal(game.discard.filter(c => c.id === f.worthless.id).length, 1);
});