Support Semuta after market Ghola
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 08:34:18 +11:00
1 parent 6ecd26c8d1
commit 79fc12c1c1
11 files changed
+557 -5

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actSemutaNativeMarkets as act, advanceSemutaNativeMarkets as advance,
semutaNativeMarketsInventory as inventory, semutaNativeMarketsPlayer as player } from './fixture-semuta-native-markets';
export const actSemutaMarketGhola = act;
export const semutaMarketGholaPlayer = player;
export const semutaMarketGholaInventory = inventory;
/** Public Basic setup and real CHOAM market declaration. The original
* starting card order and Spice positions are selected before they are drawn.
* After setup, the real Ghola and Semuta cards are moved from their respective
* decks to their owners' hands and one CHOAM reserve is moved to Tanks. Those
* conserved staging choices establish the relevant holdings and casualty; no
* response, market, discard receipt, wallet, revival or income is staged. */
export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; withoutTleilaxu?: boolean} = {}) {
const owner = 'choam', claimant = 'richese',
incomeOwner = options.withoutTleilaxu ? 'atreides' : 'tleilaxu', counterOwner = 'emperor';
let game = createGame('SEMUTAMARKETGHOLA', newPlayer(claimant, 'Richese', 'richese'), false, ['choam', 'ix']);
for (const id of [owner, incomeOwner, counterOwner] as const) joinGame(game, newPlayer(id, id, id));
game = act(game, claimant, {type:'moduleOption', module:'kullPreview', enabled:false});
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, claimant, {type:'start'});
assert.equal(game.status, 'setup');
assert.equal(game.semutaPreview, true);
assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds);
const originalInventory = inventory(game);
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
const sale = game.deck.find(card => card.kind === 'worthless');
const counter = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 2); assert.ok(sale); assert.ok(counter);
for (const [position, card] of [ordinary[0], sale, ordinary[1], counter].entries()) {
const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position);
[game.deck[position], game.deck[index]] = [game.deck[index], game.deck[position]];
}
game = advance(game, state => state.status === 'playing');
assert.ok(player(game, owner).hand.some(card => card.id === sale.id));
assert.ok(player(game, counterOwner).hand.some(card => card.id === counter.id));
const front = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(front >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(front, 1)[0]);
const semutaIndex = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaIndex >= 0);
player(game, claimant).hand.push(game.richeseCache!.splice(semutaIndex, 1)[0]);
const gholaIndex = game.deck.findIndex(card => card.effect === 'ghola'); assert.ok(gholaIndex >= 0);
const ghola = game.deck.splice(gholaIndex, 1)[0]; player(game, owner).hand.push(ghola);
const choam = player(game, owner); assert.ok(choam.reserves > 0);
choam.reserves--; choam.tanks++;
if (options.fullHand) while (player(game, claimant).hand.length < 4) {
const index = game.deck.findIndex(card => ['projectile','poison','shield','snooper','worthless'].includes(card.kind));
assert.ok(index >= 0); player(game, claimant).hand.push(game.deck.splice(index, 1)[0]);
}
assert.deepEqual(inventory(game), originalInventory);
game = advance(game, state => state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening);
assert.equal(game.decision?.player, owner);
game = act(game, owner, {type:'decision', mode:'sell', card:sale.id});
assert.equal(game.response?.kind, 'choamSale');
const source = structuredClone(game), market = structuredClone(game.choamMarket), response = structuredClone(game.response);
const gholaAction:Action = {type:'card', card:ghola.id, amount:1, elite:0};
game = act(game, owner, gholaAction);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(inventory(game), originalInventory);
return {game, source, owner, claimant, incomeOwner, counterOwner, ghola:ghola.id,
semuta:SEMUTA_DRUG_ID, counter:counter.id, sale:sale.id, event, originalInventory,
market, response, gholaAction, fullHand:!!options.fullHand};
}
/** Pass only the current discard offer, without consuming a later response. */
export function passSemutaMarketGhola(source:Game):Game {
const pending = source.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
const event = pending!.batch.event;
let game = source;
for (const seat of source.players) {
if (game.pendingTreacheryDiscard?.reaction?.stage !== 'offer') break;
if (!game.pendingTreacheryDiscard.reaction.passed.includes(seat.id))
game = act(game, seat.id, {type:'semutaPass', event});
}
return game;
}
export function claimSemutaMarketGhola(source:Game, actor:string, card:string):Game {
const event = source.pendingTreacheryDiscard!.batch.event;
let game = act(source, actor, {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, actor, {type:'semutaSelect', event, card});
return game;
}
export function allowSemutaMarketGholaIncome(source:Game):Game {
let game = source;
for (let count = 0; game.response?.kind === 'revivalIncome' && count < game.players.length; count++) {
const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat);
game = act(game, seat.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind, 'revivalIncome');
return game;
}
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SemutaReaction } from '../components/semuta-reaction';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaMarketGholaFixture, actSemutaMarketGhola as act,
allowSemutaMarketGholaIncome, passSemutaMarketGhola, semutaMarketGholaPlayer as player,
} from './fixture-semuta-market-ghola';
type Props = Parameters<typeof SemutaReaction>[0];
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
function controls(node: unknown): Control[] {
if (Array.isArray(node)) return node.flatMap(controls);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{children?: unknown; disabled?: boolean; onClick?: () => void}>).props;
return [...(props.onClick ? [{...props, onClick: props.onClick}] : []), ...controls(props.children)];
}
const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
function control(game: Game, actor: string, label: 'Continue' | 'Commit Semuta Drug', busy = false) {
const sent: Action[] = [], props = {game:viewGame(game, actor), busy, act(action: Action) {sent.push(action);}};
const button = controls(SemutaReaction(props)).find(candidate => candidate.children === label);
assert.ok(button, `The actual fresh discard offers ${label}`);
assert.equal(button.disabled, busy);
button.onClick();
if (busy) { assert.deepEqual(sent, []); return null; }
assert.equal(sent.length, 1);
assert.deepEqual(sent[0], {type:label === 'Continue' ? 'semutaPass' : 'semutaCommit',
event:props.game.semutaReaction!.event});
return sent[0];
}
function parked(game: Game) {
const pending = game.pendingTreacheryDiscard, market = game.pendingChoamMarketGhola;
assert.ok(pending?.continuation.kind === 'ordinaryCardDiscard');
assert.equal(pending.continuation.effect, 'ghola');
assert.ok(market);
assert.equal(market.response.kind, 'choamSale');
assert.deepEqual(game.choamMarket, market.market);
const income = pending.continuation.resume.response;
assert.ok(income?.kind === 'revivalIncome');
assert.equal(game.response, null);
return {event:pending.batch.event, response:structuredClone(market.response),
market:structuredClone(market.market), income:structuredClone(income)};
}
void test('every seat sees a neutral persistent market-Ghola offer; a pass leaves the parent and income parked', () => {
const f = createSemutaMarketGholaFixture(), game = f.game;
const original = parked(game), before = structuredClone(game);
for (const seat of game.players) {
const view = viewGame(game, seat.id), reaction = view.semutaReaction;
assert.ok(reaction); assert.equal(reaction.stage, 'offer');
assert.deepEqual(reaction.candidates, []);
const html = markup({game:view, busy:false, act() {}});
assert.match(html, /Fresh Treachery discard/);
assert.match(html, /Continue when you are ready/);
assert.doesNotMatch(html, /Ghola/);
}
assert.deepEqual(game, before, 'Rendering does not acknowledge or resume the parent.');
assert.equal(control(game, f.owner, 'Continue', true), null);
const pass = control(game, f.owner, 'Continue'); assert.ok(pass);
assert.throws(() => act(game, f.owner, {...pass,event:'stale-market-ghola'}));
assert.deepEqual(game, before, 'A stale event cannot change the pending response.');
const next = act(game, f.owner, pass);
assert.deepEqual(parked(next), original);
assert.match(markup({game:viewGame(next, f.owner), busy:false, act() {}}), /You continued. Waiting for other seats/);
const continued = passSemutaMarketGhola(next);
assert.equal(continued.pendingTreacheryDiscard, null);
assert.deepEqual(continued.response, original.income);
assert.deepEqual(continued.pendingChoamMarketGhola?.response, original.response);
const restored = allowSemutaMarketGholaIncome(continued);
assert.equal(restored.pendingChoamMarketGhola, null);
assert.deepEqual(restored.response, original.response);
assert.deepEqual(restored.choamMarket, original.market);
});
for (const fullHand of [false, true]) void test(
`the market-Ghola claim resumes the same income and sale${fullHand ? ' with a full hand' : ''}`, () => {
const f = createSemutaMarketGholaFixture({fullHand}), game = f.game;
const original = parked(game), before = structuredClone(game);
const view = viewGame(game, f.claimant);
assert.equal(view.semutaReaction?.canCommit, true);
const html = markup({game:view,busy:false,act() {}});
assert.match(html, /Commit Semuta Drug/);
assert.doesNotMatch(html, /Ghola/);
assert.equal(control(game, f.claimant, 'Commit Semuta Drug', true), null);
const commit = control(game, f.claimant, 'Commit Semuta Drug'); assert.ok(commit);
assert.throws(() => act(game, f.claimant, {...commit,event:'stale-market-ghola'}));
assert.deepEqual(game, before);
let next = act(game, f.claimant, commit);
if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selection = viewGame(next, f.claimant).semutaReaction;
assert.ok(selection && selection.stage === 'select');
assert.deepEqual(selection.candidates.map(card => card.id), [f.ghola]);
assert.deepEqual(viewGame(next, f.owner).semutaReaction?.candidates, []);
next = act(next, f.claimant, {type:'semutaSelect',event:original.event,card:f.ghola});
}
assert.equal(next.pendingTreacheryDiscard, null);
assert.deepEqual(next.response, original.income);
assert.deepEqual(next.choamMarket, original.market);
assert.deepEqual(next.pendingChoamMarketGhola?.response, original.response);
assert.ok(player(next, f.claimant).hand.some(card => card.id === f.ghola));
assert.ok(!player(next, f.claimant).hand.some(card => card.id === f.semuta));
next = allowSemutaMarketGholaIncome(next);
assert.equal(next.pendingChoamMarketGhola, null);
assert.deepEqual(next.response, original.response);
assert.deepEqual(next.choamMarket, original.market);
},
);
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { choamGholaEvent } from '../game/choam-market-ghola';
import { semutaMarketGholaSupported as supported } from '../game/semuta-market-ghola';
import type { FreshDiscardBatch } from '../game/semuta-drug';
type Ordinary = Extract<NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{kind:'ordinaryCardDiscard'}>;
/** A staged frame isolates admission. The separate runtime suite exercises
* the actual Ghola, market, income and physical continuation. */
function source(withIncome = true) {
const game = createGame('MARKETGHOLA', newPlayer('r','Richese','richese'), false, ['choam','ix']);
game.players.push(newPlayer('c','CHOAM','choam'), newPlayer('a','Atreides','atreides'));
if (withIncome) game.players.push(newPlayer('t','Tleilaxu','tleilaxu'));
game.status = 'playing'; game.turn = 2; game.phase = 2; game.semutaPreview = true;
game.response = null; game.decision = null; game.pendingKarama = null; game.phaseOpening = null;
game.choamMarket = {owner:'c',resume:'phase',blocked:[],sale:{card:'sale',price:2}};
game.treacheryDiscardSequence = 7;
const card = {id:'physical-ghola',name:'Ghola',kind:'special',effect:'ghola'} as const;
game.discard = [{...card}];
game.pendingChoamMarketGhola = {turn:game.turn,phase:game.phase,player:'a',card:card.id,
discardSequence:7,event:'',stage:'discard',response:{kind:'choamSale',owner:'c',passed:[]},
market:structuredClone(game.choamMarket)};
game.pendingChoamMarketGhola.event = choamGholaEvent(game.pendingChoamMarketGhola);
const continuation:Ordinary = {kind:'ordinaryCardDiscard',player:'a',card:card.id,effect:'ghola',
resume:{response:withIncome ? {kind:'revivalIncome',owner:'t',recipient:'a',amount:1,passed:[],
intent:game.pendingChoamMarketGhola.event} : null,decision:null,pendingKarama:null,phaseOpening:null},
stateSignature:'staged'};
const entries:FreshDiscardBatch['entries'] = [{card:{...card},discardedBy:'a',publicFace:true}];
return {game,continuation,entries};
}
function install(frame: ReturnType<typeof source>, reaction = true) {
const {game,continuation,entries} = frame;
game.pendingTreacheryDiscard = {sequence:7,batch:{event:'discard:2:2:7',turn:2,phase:2,
cause:'ordinary:ghola',entries:structuredClone(entries)},continuation:structuredClone(continuation),
...(reaction ? {reaction:{stage:'offer' as const,passed:[]}} : {})};
return frame;
}
void test('a fresh actual market Ghola with and without native income is admitted without mutation', () => {
for (const withIncome of [true,false]) {
const {game,continuation,entries} = source(withIncome), before = structuredClone(game);
assert.equal(supported(game,continuation,entries),true);
assert.deepEqual(game,before);
}
});
void test('an installed reaction retains its same typed source, while an old unreacted discard stays closed', () => {
const fresh = install(source());
const before = structuredClone(fresh.game);
assert.equal(supported(fresh.game,fresh.continuation,fresh.entries),true);
assert.deepEqual(fresh.game,before);
const old = install(source(),false);
assert.equal(supported(old.game,old.continuation,old.entries),false);
});
void test('market, sequence, source, income and nested-control changes are rejected', () => {
const changes: Array<(s:ReturnType<typeof source>) => void> = [
s => { s.game.advanced = true; },
s => { s.game.techTokens = {} as NonNullable<Game['techTokens']>; },
s => { s.game.kullPreview = true; },
s => { s.game.nexusKullPreview = true; },
s => { s.game.semutaPreview = undefined; },
s => { s.game.pendingChoamMarketGhola!.turn++; },
s => { s.game.pendingChoamMarketGhola!.stage = 'income'; },
s => { s.game.pendingChoamMarketGhola!.discardSequence++; },
s => { s.game.pendingChoamMarketGhola!.event = 'stale'; },
s => { s.game.pendingChoamMarketGhola!.card = 'other'; },
s => { s.game.pendingChoamMarketGhola!.player = 'r'; },
s => { s.game.pendingChoamMarketGhola!.market.sale!.price = 3; },
s => { s.game.pendingChoamMarketGhola!.response.owner = 'a'; },
s => { s.game.pendingChoamMarketGhola!.response.passed = ['a','a']; },
s => { s.entries[0].publicFace = false; },
s => { s.entries[0].discardedBy = 'r'; },
s => { s.game.discard = []; },
s => { s.continuation.resume.response!.intent = 'wrong'; },
s => { s.continuation.resume.response!.amount = 2; },
s => { s.continuation.resume.response!.passed = ['r']; },
s => { s.continuation.resume.decision = {kind:'auctionPayment',player:'a'}; },
s => { s.game.response = {kind:'choamSale',owner:'c',passed:[]}; },
s => { s.game.pendingKarama = {} as NonNullable<Game['pendingKarama']>; },
];
for (const change of changes) {
const candidate = source(); change(candidate);
const before = structuredClone(candidate.game);
assert.equal(supported(candidate.game,candidate.continuation,candidate.entries),false);
assert.deepEqual(candidate.game,before);
}
});
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createSemutaMarketGholaFixture as fixture, actSemutaMarketGhola as act,
semutaMarketGholaPlayer as player, semutaMarketGholaInventory as inventory,
passSemutaMarketGhola as pass, claimSemutaMarketGhola as claim,
allowSemutaMarketGholaIncome as allowIncome } from './fixture-semuta-market-ghola';
function reject(game:Game, actor:string, action:Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function allowSale(source:Game):Game {
let game = source;
for (let count = 0; game.response?.kind === 'choamSale' && 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.notEqual(game.response?.kind, 'choamSale');
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
return game;
}
void test('market Ghola commits the actual return and parks its income and original sale through the Semuta offer', () => {
const f = fixture(), pending = f.game.pendingTreacheryDiscard;
assert.equal(f.source.response?.kind, 'choamSale');
assert.equal(pending?.reaction?.stage, 'offer');
assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard');
if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.');
assert.equal(pending.continuation.effect, 'ghola');
assert.equal(pending.continuation.player, f.owner);
assert.equal(pending.continuation.card, f.ghola);
assert.equal(pending.continuation.resume.response?.kind, 'revivalIncome');
assert.equal(pending.continuation.resume.response?.owner, f.incomeOwner);
assert.equal(pending.continuation.resume.response?.recipient, f.owner);
assert.equal(pending.continuation.resume.response?.amount, 1);
assert.equal(pending.continuation.resume.response?.intent, f.game.pendingChoamMarketGhola?.event);
assert.deepEqual(f.game.pendingChoamMarketGhola?.response, f.response);
assert.deepEqual(f.game.pendingChoamMarketGhola?.market, f.market);
assert.equal(f.game.pendingChoamMarketGhola?.stage, 'discard');
assert.equal(f.game.response, null);
assert.equal(player(f.game, f.owner).reserves, player(f.source, f.owner).reserves + 1);
assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.equal(player(f.game, f.incomeOwner).spice, player(f.source, f.incomeOwner).spice);
assert.deepEqual(inventory(f.game), f.originalInventory);
for (const seat of f.game.players) {
const projected = viewGame(f.game, seat.id).semutaReaction; assert.ok(projected);
assert.deepEqual(projected.candidates, []);
assert.equal(projected.canCommit, seat.id === f.claimant);
}
reject(f.game, f.claimant, {type:'semutaCommit', event:'stale-market-ghola'});
});
for (const claims of [false, true]) for (const cancelIncome of [false, true]) void test(
`market Ghola ${claims ? 'claim' : 'pass'} preserves ${cancelIncome ? 'canceled' : 'paid'} income and original sale`, () => {
const f = fixture(), beforeIncome = player(f.source, f.incomeOwner).spice,
beforeSale = player(f.source, f.owner).spice;
let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(game.response?.kind, 'revivalIncome');
assert.equal(game.response?.intent, game.pendingChoamMarketGhola?.event);
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
assert.deepEqual(game.choamMarket, f.market);
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
if (cancelIncome) {
game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
} else game = allowIncome(game);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome));
assert.equal(game.pendingChoamMarketGhola ?? null, null);
if (game.response?.kind === 'choamSale') {
assert.deepEqual(game.response, f.response);
assert.deepEqual(game.choamMarket, f.market);
assert.equal(player(game, f.owner).spice, beforeSale);
} else {
// When the counter consumed the last usable Karama, the restored sale
// may immediately advance through the existing automatic response path.
assert.equal(game.response ?? null, null);
assert.equal(player(game, f.owner).spice, beforeSale + 2);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
}
game = allowSale(game);
assert.equal(player(game, f.owner).spice, beforeSale + 2);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
assert.equal(game.discard.filter(card => card.id === f.sale).length, 1);
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome));
assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.claimant, {type:'semutaCommit', event:f.event});
},
);
void test('without Tleilaxu, Semuta claim or pass restores the original sale without income', () => {
for (const claims of [false, true]) {
const f = fixture({withoutTleilaxu:true}), beforeSale = player(f.source, f.owner).spice;
const pending = f.game.pendingTreacheryDiscard;
assert.equal(f.game.players.some(seat => seat.faction === 'tleilaxu'), false);
assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard');
if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.');
assert.equal(pending.continuation.resume.response ?? null, null);
assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game);
assert.equal(game.pendingChoamMarketGhola ?? null, null);
assert.deepEqual(game.response, f.response);
assert.deepEqual(game.choamMarket, f.market);
assert.equal(player(game, f.owner).spice, beforeSale);
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
game = allowSale(game);
assert.equal(player(game, f.owner).spice, beforeSale + 2);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
assert.equal(game.discard.filter(card => card.id === f.sale).length, 1);
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('a full-hand Semuta claimant exchanges its physical card and still restores market income', () => {
const f = fixture({fullHand:true});
assert.equal(player(f.game, f.claimant).hand.length, 4);
const game = claim(f.game, f.claimant, f.ghola);
assert.equal(player(game, f.claimant).hand.length, 4);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(game.response?.kind, 'revivalIncome');
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all minimal bot profiles can continue the preserved neutral offer', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(f.game);
const view = viewGame(game, f.claimant);
view.players.find(seat => seat.id === f.claimant)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'semutaPass' || candidate.type === 'semutaCommit');
assert.ok(action, `${difficulty} needs a legal Semuta response`);
game = applyAction(game, f.claimant, action);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const choice = botActions(viewGame(game, f.claimant)).find(candidate => candidate.type === 'semutaSelect');
assert.ok(choice, `${difficulty} needs a legal discard selection`);
game = applyAction(game, f.claimant, choice);
}
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
assert.equal(game.response?.kind, 'revivalIncome');
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
assert.deepEqual(inventory(game), f.originalInventory);
}
});