Resume printed native specials through Semuta cost and cashout stages

This commit is contained in:
Quintine committed 2026-10-16 01:47:32 +11:00
1 parent 44c2bf0da4
commit 8a2df97be8
14 files changed
+656 -35

No files matched your search

+2 -1
View File
@@ -7,7 +7,7 @@ import type { Card } from '../game/cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards,
} from './fixture-semuta-emperor-share';
export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
@@ -138,6 +138,7 @@ export function createSemutaEmperorIncomeFixture(
const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired };
sourceActions.push({ actor: 'r', action: purchase });
game = applyAction(game, 'r', purchase);
game = passSemutaSpecialDiscards(game, sourceActions);
} else {
if (!options.acquiredKarama) {
const first = game.deck.find(card => card.effect !== 'karama');
+20
View File
@@ -127,3 +127,23 @@ export function semutaEmperorSharePhysical(game: Game) {
export function semutaEmperorShareViews(game: Game): void {
for (const player of game.players) viewGame(game, player.id);
}
/** Pass only original special costs/results; never skip a committed private selection. */
export function passSemutaSpecialDiscards(
state: Game, actions?: { actor: string; action: Action }[],
): Game {
let game = state;
for (let n = 0; game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:') && n < 20; n++) {
const pending = game.pendingTreacheryDiscard;
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const event = pending.batch.event;
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id))) {
const action: Action = { type: 'semutaPass', event };
actions?.push({ actor: seat.id, action });
game = applyAction(game, seat.id, action);
}
}
assert.ok(!game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:'),
'The native special discard continuation must actually retire.');
return game;
}
@@ -10,7 +10,7 @@ import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income';
import {
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards,
} from './fixture-semuta-emperor-share';
export type SemutaHomeworldEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome';
@@ -208,6 +208,7 @@ export function createSemutaHomeworldEmperorIncomeFixture(
beforePayment = structuredClone(game);
acquired = game.richeseCache![0].id;
act({ actor: 'r', action: { type: 'card', mode: 'special', card: special!, acquire: acquired } }, sourceActions);
game = passSemutaSpecialDiscards(game, sourceActions);
} else {
acquired = options.acquiredKarama ? karama : game.deck.find(card => card.effect !== 'karama')!.id;
nextOriginalDraw(game, acquired);
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { TERRITORIES, MOBILE_LOCATION, mobileRoutes } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { createSemutaGuildTransport } from './fixture-semuta-guild-transport';
import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
import { moveSemutaEmperorCard, passSemutaSpecialDiscards } from './fixture-semuta-emperor-share';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
export type SemutaSpecialStep = { 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;
/** Two original paid auctions deliberately give Semuta to the shipper and the
* printed Karama to native Guild. No played hand, wallet, force or phase is assigned. */
export function createOriginalSemutaSpecialGuild(homeworlds = false) {
const original = createSemutaGuildTransport({ route: homeworlds ? 'world' : 'reserve' });
let game = structuredClone(original.setup);
const actions: SemutaSpecialStep[] = [];
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 < 1600; n++) {
const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action);
}
assert.ok(until(game));
};
const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card);
for (const position of [0, 1]) {
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c);
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
reach(state => state.decision?.kind === 'richeseCache');
act('r', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
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(c => c.id === SEMUTA_DRUG_ID) && clean(state));
const boughtSemuta = structuredClone(game);
reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'g' && clean(state));
act('g', { type: 'bid', amount: 1 });
reach(state => player(state, 'g').hand.some(c => c.id === card.id) && clean(state));
const boughtKarama = structuredClone(game);
reach(state => state.phase === 5 && state.active === 'a' && clean(state));
const beforeShipment = structuredClone(game);
let shipment: Action, target: string;
if (homeworlds) {
const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor',
{ 'homeworld:atreides': { normal: 2, elite: 0 } });
assert.ok(quote.action, quote.blocked ?? undefined); shipment = quote.action; target = 'homeworld:emperor';
} 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)!;
shipment = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 };
target = `${destination.id}:${sector}`;
}
act('a', shipment);
assert.equal(game.decision?.kind, homeworlds ? 'homeworldShipmentGuild' : 'guildShipment');
return { original, actions, beforeShipment, beforeCost: structuredClone(game), game,
boughtSemuta, boughtKarama, homeworlds, shipment, target,
owner: 'g', holder: 'a', shipper: 'a', karama: card.id, semuta: SEMUTA_DRUG_ID,
action: { type: 'card', mode: 'special', card: card.id } as Action,
provenance: [
'Original printed Semuta setup; only original undealt lottery and conserved unplayed deck order selected.',
'Atreides pays1 for the actual public cache Semuta; Guild pays1 for the actual normal Karama auction.',
'Original Atreides shipment and actual native interception; no played cards/forces/wallets/phase staged.',
] };
}
export function reactSemutaSpecial(game: Game, holder: string, claim: boolean, card?: string): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.ok(pending.reaction?.stage === 'offer'); const event = pending.batch.event;
if (!claim) {
const reaction = pending.reaction;
for (const seat of game.players.filter(p => !reaction.passed.includes(p.id)))
game = applyAction(game, seat.id, { type: 'semutaPass', event });
return game;
}
game = applyAction(game, holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.ok(card, 'Private multi-card recovery needs the caller’s actual selection.');
assert.ok(viewGame(game, holder).semutaReaction!.candidates.some(c => c.id === card));
game = applyAction(game, holder, { type: 'semutaSelect', event, card });
}
return game;
}
export type ControlledSemutaSpecialKind = 'emperorForces' | 'emperorLeader' | 'choam' |
'richese' | 'ixians' | 'tleilaxu' | 'fremen' | 'atreides' | 'harkonnen';
/** CONTROLLED native-effect fixture: genuine native Semuta setup, then conserved
* later hands/armies/wallets and phase are staged to isolate each printed boundary.
* These are not original-action acquisition or full-game claims. */
export function createControlledSemutaSpecial(kind: ControlledSemutaSpecialKind) {
const faction: FactionId = kind === 'emperorForces' || kind === 'emperorLeader' ? 'emperor' : kind;
let game = createGame('CONTROLLEDSPECIALSEMUTA', newPlayer('p', 'Owner', faction), true,
['choam', ...(['ixians', 'tleilaxu'].includes(faction) ? ['ix' as const] : [])]);
joinGame(game, newPlayer('r', faction === 'richese' ? 'Guild' : 'Richese', faction === 'richese' ? 'guild' : 'richese'));
joinGame(game, newPlayer('h', 'Holder', faction === 'atreides' ? 'guild' : 'atreides'));
for (const seat of game.players) game = applyAction(game, seat.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));
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0;
if (p.elites) {
p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {}; p.elites.tanks = 0; p.elites.revived = 0;
}
}
const karama = game.deck.find(c => c.effect === 'karama'); assert.ok(karama);
moveSemutaEmperorCard(game, karama.id, 'p'); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'h');
game.phase = 4; game.active = 'p'; game.order = ['p', 'r', 'h'];
game.movementRemaining = ['p', 'r', 'h']; game.ready = [];
game.response = null; game.decision = null; game.phaseOpening = null;
game.storm = 18; game.spiceWindow = null; game.spiceResolution = null; game.spice = {};
const owner = player(game, 'p'), holder = player(game, 'h');
const action: Action = { type: 'card', mode: 'special', card: karama.id };
let selected: string[] = [], acquired: string | undefined, target: string | undefined;
if (kind === 'emperorForces') {
owner.reserves = 17; owner.tanks = 3; action.amount = 2; action.elite = 0;
} else if (kind === 'emperorLeader') {
owner.leaders[0].dead = true; action.leader = owner.leaders[0].id;
} else if (kind === 'choam') {
game.phase = 3;
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
for (const card of cards) moveSemutaEmperorCard(game, card.id, 'p');
selected = cards.map(c => c.id); action.cards = selected;
} else if (kind === 'richese') {
game.phase = 3; acquired = game.richeseCache![0].id; action.acquire = acquired;
} else if (kind === 'ixians') {
game.phase = 5; game.mobileStronghold = { location: 'polar_sink:0' };
owner.forces = { [MOBILE_LOCATION]: 1 }; owner.reserves = 19;
action.route = mobileRoutes(game, 2)[0]; action.collect = false;
} else if (kind === 'tleilaxu') {
holder.reserves = 18; holder.tanks = 2; game.active = 'h';
game = applyAction(game, 'h', { type: 'revive', amount: 2, elite: 0 });
assert.equal(game.decision?.kind, 'revivalStop');
} else if (kind === 'fremen') {
game.phase = 1;
const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm));
assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!;
target = `${destination.id}:${sector}`; holder.forces[target] = 2; holder.reserves = 18;
action.territory = destination.id;
} else if (kind === 'atreides') {
game.phase = 6; owner.forces = { 'arrakeen:10': 2 }; owner.reserves = 18;
holder.forces = { 'arrakeen:10': 2 }; holder.reserves = 18;
game = applyAction(game, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' });
for (let n = 0; n < 40 && (game.response || game.battle?.preparation); n++) {
if (game.response) game = applyAction(game,
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else game = applyAction(game, game.battle!.preparation!.owner, { type: 'declineBattlePower' });
}
assert.equal(game.decision?.kind, 'fullPlanOffer'); action.target = 'h';
} else {
game.phase = 3; target = 'h'; action.target = target; action.amount = 1;
const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2);
assert.equal(cards.length, 2);
moveSemutaEmperorCard(game, cards[0].id, 'p'); selected = [cards[0].id];
moveSemutaEmperorCard(game, cards[1].id, 'h');
}
return { kind, beforeCost: structuredClone(game), game, action, owner: 'p', holder: 'h',
karama: karama.id, semuta: SEMUTA_DRUG_ID, selected, acquired, target,
provenance: ['CONTROLLED genuine native Semuta setup, then conserved later hands/armies/wallets/phase.',
'Actual native battle/revival declarations where needed; actual printed activation and Semuta reactions thereafter.'] };
}
export { passSemutaSpecialDiscards };
+1 -3
View File
@@ -270,7 +270,7 @@ void test('Guild and Tleilaxu intentions bind the actual declaration, not just i
}
});
void test('Harkonnen preparation does not expose or select unseen cards; only commitment draws them', (t) => {
void test('Harkonnen preparation withholds unseen identities until the actual hand transfer', () => {
const g = fixture('harkonnen');
g.phase = 3;
const foreign = [hold(g, 'q', 'Baliset'), hold(g, 'q', 'Snooper')];
@@ -280,9 +280,7 @@ void test('Harkonnen preparation does not expose or select unseen cards; only co
action(g, { target: 'q', amount: 1 }),
);
assert.ok(foreign.every((id) => !JSON.stringify(intent).includes(id)));
const draw = t.mock.method(crypto, 'getRandomValues');
executeSpecialKaramaIntent(g, intent);
assert.ok(draw.mock.callCount() > 0);
assert.equal(g.players[1].hand.length, 1);
assert.equal(g.players[0].hand.length, 1);
assert.equal(g.decision?.kind, 'handExchange');
+4
View File
@@ -5,6 +5,7 @@ import {
createSemutaShipmentPrice, reactSemutaShipmentPrice,
semutaPurchasedShipment, allowSemutaPurchasedShipment,
} from './fixture-semuta-shipment-price';
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) {
@@ -54,6 +55,8 @@ for (const homeworlds of [false, true])
const shipment = semutaPurchasedShipment(game, homeworlds);
game = applyAction(game, f.beneficiary, shipment.action);
game = applyAction(game, f.guild, { type: 'card', card: f.karama, mode: 'special' });
assert.equal(player(game, f.beneficiary).shipped, false);
game = passSemutaSpecialDiscards(game);
const shipper = player(game, f.beneficiary);
assert.equal(shipper.spice, original.spice);
assert.equal(shipper.reserves, original.reserves);
@@ -83,6 +86,7 @@ void test('declining the price cost retains its existing exact-card refund when
const shipment = semutaPurchasedShipment(game, homeworlds);
game = applyAction(game, f.beneficiary, shipment.action);
game = applyAction(game, f.guild, { type: 'card', card: stop.id, mode: 'special' });
game = passSemutaSpecialDiscards(game);
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);
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createOriginalSemutaSpecialGuild, createControlledSemutaSpecial,
reactSemutaSpecial, type ControlledSemutaSpecialKind,
} from './fixture-semuta-special-karama';
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 exactCost(game: Game, id: string) {
assert.equal(game.discard.filter(c => c.id === id).length + game.deck.filter(c => c.id === id).length +
game.players.reduce((sum, p) => sum + p.hand.filter(c => c.id === id).length, 0), 1);
}
for (const homeworlds of [false, true])
void test(`original ${homeworlds ? 'Homeworld' : 'reserve'} Guild stop waits for cost recovery and then prevents the same unpaid shipment`, () => {
for (const claim of [false, true]) {
const f = createOriginalSemutaSpecialGuild(homeworlds);
let game = applyAction(f.beforeCost, f.owner, f.action);
assert.equal(game.pendingTreacheryDiscard?.batch.cause,
homeworlds ? 'specialKarama:guildHomeworld' : 'specialKarama:guild');
assert.equal(player(game, f.shipper).shipped, false);
assert.equal(player(game, f.shipper).spice, 9);
assert.equal(player(game, f.shipper).reserves, 10);
assert.equal(player(game, f.owner).specialKaramaUsed, undefined);
reject(game, f.shipper, { type: 'endMovement' });
game = reactSemutaSpecial(game, f.holder, claim);
assert.equal(player(game, f.shipper).shipped, true);
assert.equal(player(game, f.shipper).spice, 9);
assert.equal(player(game, f.shipper).reserves, 10);
assert.equal(player(game, f.shipper).moved, 0);
assert.equal(player(game, f.owner).specialKaramaUsed, true);
assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama), claim);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim ? 1 : 0);
if (homeworlds) assert.equal(game.homeworlds!.custody!.visitors[f.target]?.[f.shipper], undefined);
else assert.equal(player(game, f.shipper).forces[f.target] ?? 0, 0);
exactCost(game, f.karama);
reject(game, f.owner, f.action);
game = applyAction(game, f.shipper, { type: 'endMovement' });
assert.notEqual(game.active, f.shipper);
}
});
for (const kind of ['emperorForces', 'emperorLeader', 'choam', 'richese', 'ixians',
'tleilaxu', 'fremen', 'atreides', 'harkonnen'] as ControlledSemutaSpecialKind[])
void test(`controlled native ${kind} preserves its own cost/effect order across Semuta claim or decline`, () => {
for (const claim of [false, true]) {
const f = createControlledSemutaSpecial(kind);
const before = structuredClone(f.beforeCost);
let game = applyAction(f.beforeCost, f.owner, f.action);
assert.equal(game.pendingTreacheryDiscard?.batch.cause, `specialKarama:${kind}`);
if (kind === 'emperorForces') {
assert.equal(player(game, f.owner).reserves, 19);
assert.equal(player(game, f.owner).tanks, 1);
assert.equal(player(game, f.owner).specialKaramaUsed, true);
} else if (kind === 'emperorLeader') {
assert.equal(player(game, f.owner).leaders[0].dead, false);
assert.equal(player(game, f.owner).specialKaramaUsed, true);
} else {
assert.equal(player(game, f.owner).specialKaramaUsed, undefined);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves);
}
if (kind === 'richese') {
assert.ok(game.richeseCache!.some(c => c.id === f.acquired));
assert.equal(player(game, f.owner).hand.some(c => c.id === f.acquired), false);
}
if (kind === 'fremen') assert.equal(player(game, f.holder).tanks, 0);
game = reactSemutaSpecial(game, f.holder, claim);
if (kind === 'choam') {
assert.equal(player(game, f.owner).spice, 26);
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'specialKarama:choamCashout');
game = reactSemutaSpecial(game, f.holder, !claim, !claim ? f.selected[0] : undefined);
assert.equal(player(game, f.owner).spice, 26);
if (!claim) assert.ok(player(game, f.holder).hand.some(c => c.id === f.selected[0]));
} else if (kind === 'emperorForces') {
assert.equal(player(game, f.owner).reserves, 19);
assert.equal(player(game, f.owner).tanks, 1);
assert.equal(player(game, f.owner).elites!.revived, 0);
} else if (kind === 'emperorLeader') {
assert.equal(player(game, f.owner).leaders[0].dead, false);
} else if (kind === 'richese') {
assert.equal(player(game, f.owner).spice, 17);
assert.equal(player(game, f.owner).hand.filter(c => c.id === f.acquired).length, 1);
assert.equal(game.richeseCache!.some(c => c.id === f.acquired), false);
} else if (kind === 'ixians') {
assert.equal(game.mobileStronghold!.location, (f.action.route as string[]).at(-1));
assert.equal(player(game, f.owner).reserves, 19);
assert.equal(player(game, f.owner).moved, 0);
} else if (kind === 'tleilaxu') {
assert.equal(game.revivalPrevention!.player, f.holder);
assert.equal(player(game, f.holder).tanks, 2);
assert.equal(player(game, f.holder).reserves, 18);
reject(game, f.holder, { type: 'revive', amount: 2, elite: 0 });
} else if (kind === 'fremen') {
assert.equal(player(game, f.holder).tanks, 2);
assert.equal(player(game, f.holder).forces[f.target!] ?? 0, 0);
assert.equal(player(game, f.owner).shipped, false);
} else if (kind === 'atreides') {
assert.deepEqual(game.battle!.fullPlan, { owner: f.owner, target: f.holder });
assert.equal(player(game, f.owner).forces['arrakeen:10'], 2);
assert.equal(player(game, f.holder).forces['arrakeen:10'], 2);
} else {
assert.equal(game.decision?.kind, 'handExchange');
assert.equal(player(game, f.owner).hand.length, 2);
const returned = f.selected[0];
game = applyAction(game, f.owner, { type: 'decision', returnCards: [returned] });
assert.equal(player(game, f.owner).hand.length, 1);
assert.equal(player(game, f.holder).hand.length, 2);
}
assert.equal(player(game, f.owner).specialKaramaUsed, true);
exactCost(game, f.karama);
reject(game, f.owner, f.action);
}
});
void test('CHOAM cashout is a separate private simultaneous batch with one earned credit and only fresh selectable cards', () => {
const f = createControlledSemutaSpecial('choam');
let game = reactSemutaSpecial(applyAction(f.beforeCost, f.owner, f.action), f.holder, false);
assert.equal(player(game, f.owner).spice, 26);
const event = game.pendingTreacheryDiscard!.batch.event;
for (const seat of game.players)
assert.deepEqual(viewGame(game, seat.id).semutaReaction!.candidates, []);
game = applyAction(game, f.holder, { type: 'semutaCommit', event });
assert.deepEqual(new Set(viewGame(game, f.holder).semutaReaction!.candidates.map(c => c.id)), new Set(f.selected));
reject(game, f.holder, { type: 'semutaSelect', event, card: f.karama });
const chosen = f.selected[1];
game = applyAction(game, f.holder, { type: 'semutaSelect', event, card: chosen });
assert.equal(player(game, f.owner).spice, 26);
assert.ok(player(game, f.holder).hand.some(c => c.id === chosen));
assert.ok(game.discard.some(c => c.id === f.selected[0]));
assert.equal(game.discard.filter(c => c.id === f.karama).length, 1);
assert.equal(game.discard.filter(c => c.id === f.semuta).length, 1);
});
void test('a malformed saved native intent cannot spend a second effect or reward through a fresh-card claim', () => {
const f = createOriginalSemutaSpecialGuild();
const game = applyAction(f.beforeCost, f.owner, f.action);
const event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.owner, { type: 'semutaCommit', event });
const changed = structuredClone(game);
const continuation = changed.pendingTreacheryDiscard!.continuation;
assert.ok(continuation?.kind === 'specialKaramaDiscard');
continuation.intent.turn++;
reject(changed, f.holder, { type: 'semutaCommit', event });
const done = reactSemutaSpecial(game, f.holder, true);
assert.equal(player(done, f.shipper).spice, 9);
assert.equal(player(done, f.shipper).shipped, true);
});