Compose Homeworld storm and native Worthless counter costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 02:08:42 +11:00
1 parent 506bf25bc6
commit 7004977e75
12 files changed
+776 -13

No files matched your search

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import {
createSemutaHomeworldChoamMovementFixture,
type SemutaHomeworldChoamMovementFixture,
} from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldChoamReturnsFixture,
type SemutaHomeworldChoamReturnsFixture,
} from './fixture-semuta-homeworld-choam-returns';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldChoamKaramaPower = 'kulon' | 'baliset' | 'laLaLa' | 'gamont';
export type SemutaHomeworldChoamKaramaFixture = {
setup: Game;
beforeDeclaration: Game;
/** Exact original declaration response, before Emperor spends its Karama. */
game: Game;
owner: string;
holder: string;
target: string;
canceller: string;
card: string;
karama: string;
power: SemutaHomeworldChoamKaramaPower;
action: Action;
cancelAction: Action;
actions: RicheseSkillsTechStep[];
movement?: SemutaHomeworldChoamMovementFixture;
returns?: SemutaHomeworldChoamReturnsFixture;
};
/** Genuine native prefix and printed declaration. Only conserved original
* physical cards are positioned; the movement fixture already holds Karama. */
export function createSemutaHomeworldChoamKaramaFixture(
power: SemutaHomeworldChoamKaramaPower, advanced = false, fullHolder = false,
): SemutaHomeworldChoamKaramaFixture {
const movement = power === 'kulon' || power === 'baliset'
? createSemutaHomeworldChoamMovementFixture(power, advanced, fullHolder) : undefined;
const returns = power === 'laLaLa' || power === 'gamont'
? createSemutaHomeworldChoamReturnsFixture(power, advanced) : undefined;
const native = movement ?? returns;
assert.ok(native);
const beforeDeclaration = structuredClone(native.game);
const canceller = movement?.canceller
?? beforeDeclaration.players.find(player => player.faction === 'emperor')!.id;
assert.equal(semutaEmperorPlayer(beforeDeclaration, canceller).faction, 'emperor');
const held = semutaEmperorPlayer(beforeDeclaration, canceller).hand.find(card => card.effect === 'karama');
const karama = held?.id ?? holdSemutaHomeworldChoamCard(beforeDeclaration, canceller, 'Karama');
const actions = structuredClone(native.actions);
const game = applySemutaHomeworldChoamStep(beforeDeclaration, native.owner, native.action, actions);
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(game.response!.owner, native.owner);
assert.ok(viewGame(game, canceller).responseControls?.cancelCards.includes(karama));
return { setup: native.setup, beforeDeclaration, game,
owner: native.owner, holder: native.holder, target: native.target, canceller,
card: native.card, karama, power, action: native.action,
cancelAction: { type: 'card', mode: 'cancel', card: karama }, actions, movement, returns };
}
export function cancelSemutaHomeworldChoamKarama(
fixture: SemutaHomeworldChoamKaramaFixture, state = fixture.game, actions?: RicheseSkillsTechStep[],
): Game {
return applySemutaHomeworldChoamStep(state, fixture.canceller, fixture.cancelAction, actions);
}
/** Singleton printed Karama only; pass every still-entitled neutral participant. */
export function reactSemutaHomeworldChoamKarama(
state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[],
): Game {
const pending = state.pendingTreacheryDiscard;
assert.ok(pending);
const reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Expected the fresh native Karama discard offer');
const event = pending.batch.event;
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
let game = state;
for (const player of state.players.filter(player => !reaction.passed.includes(player.id)))
game = applySemutaHomeworldChoamStep(game, player.id, { type: 'semutaPass', event }, actions);
return game;
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { createSemutaHomeworldChoamMovementFixture,
allowSemutaHomeworldChoamMovement } from './fixture-semuta-homeworld-choam-movement';
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldChoamStormFixture = {
setup: Game; beforeShipment: Game; afterShipment: Game; beforeWeather: Game;
afterWeather: Game; game: Game; owner: string; holder: string; target: string;
canceller: string; weatherOwner: string; weatherCard: string; retainedKulon: string;
card: string; source: 'printed'; territory: string; origin: string;
action: Action; weatherAction: Action; weatherAmount: number; shipment: Action;
participants: string[]; actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingTreacheryDiscard;
/** Paid original reserves shipment, original first-turn suffix, then actual
* Weather Control and native Storm readiness. No force or clock is assigned. */
export function createSemutaHomeworldChoamStormFixture(
advanced = false, fullHolder = false,
): SemutaHomeworldChoamStormFixture {
const prefix = createSemutaHomeworldChoamMovementFixture('kulon', advanced, false);
let game = structuredClone(prefix.afterShipment);
const actions = [...prefix.actions], staging = [...prefix.staging];
const owner = prefix.owner, holder = prefix.holder;
const weatherPlayer = game.players.find(player => player.faction === 'atreides');
assert.ok(weatherPlayer, 'The original setup supplies the other Weather seat');
const weatherOwner = weatherPlayer.id;
assert.notEqual(weatherOwner, owner);
const card = holdSemutaHomeworldChoamCard(game, owner, 'Jubba Cloak');
const weatherCard = holdSemutaHomeworldChoamCard(game, weatherOwner, 'Weather Control');
staging.push('Conserved original Jubba Cloak to CHOAM and Weather Control to Atreides; Kulon remains unplayed.');
for (const player of game.players) assert.ok(player.hand.length <= handLimit(player));
game = advanceSemutaHomeworldChoam(game,
state => state.turn === 2 && state.phase === 0 && clean(state), actions);
assert.equal(semutaEmperorPlayer(game, owner).forces[prefix.origin], 1);
assert.ok(semutaEmperorPlayer(game, owner).hand.some(held => held.id === prefix.card));
const beforeWeather = structuredClone(game);
const amount = (7 - game.storm + 18) % 18;
assert.ok(amount >= 0 && amount <= 10, 'Original Weather dial can reach the shipped counter');
const weatherAction: Action = { type: 'card', card: weatherCard, amount };
game = applySemutaHomeworldChoamStep(game, weatherOwner, weatherAction, actions);
game = advanceSemutaHomeworldChoam(game, state => clean(state), actions);
const afterWeather = structuredClone(game);
assert.equal(game.stormPending, amount);
assert.equal(game.stormMovementSource?.kind, 'weather');
game = advanceSemutaHomeworldChoam(game,
state => state.decision?.kind === 'choamStorm' && state.decision.player === owner, actions);
assert.equal(game.turn, 2);
assert.equal(game.phase, 0);
assert.equal(game.storm, beforeWeather.storm);
assert.equal(semutaEmperorPlayer(game, owner).forces[prefix.origin], 1);
if (fullHolder) {
const player = semutaEmperorPlayer(game, holder);
while (player.hand.length < handLimit(player)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder);
}
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
}
const view = viewGame(game, owner);
const printed = choamPowerPlays(view, 'jubba').find(play => play.source === 'printed' && play.card.id === card);
assert.ok(printed);
const territory = 'red_chasm';
const action = choamPowerAction(view, printed, { territory });
assert.ok(action);
return { setup: prefix.setup, beforeShipment: prefix.beforeShipment,
afterShipment: prefix.afterShipment, beforeWeather, afterWeather, game,
owner, holder, target: owner, canceller: prefix.canceller, weatherOwner, weatherCard,
retainedKulon: prefix.card, card, source: 'printed', territory, origin: prefix.origin,
action, weatherAction, weatherAmount: amount, shipment: prefix.shipment,
participants: game.players.map(player => player.id), actions, staging };
}
/** Declare the canonical printed power and pass only real Karama controls. */
export function disposeSemutaHomeworldChoamStorm(
fixture: SemutaHomeworldChoamStormFixture, state = fixture.game,
actions?: RicheseSkillsTechStep[],
): Game {
return allowSemutaHomeworldChoamMovement(
applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions);
}
/** Finish the original Storm's CHOAM market and opening controls, not a new turn. */
export function finishSemutaHomeworldChoamStorm(
state: Game, actions?: RicheseSkillsTechStep[],
): Game {
const game = advanceSemutaHomeworldChoam(state,
current => current.phase === 1 && clean(current), actions);
assert.equal(game.turn, 2);
return game;
}
+189
View File
@@ -0,0 +1,189 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import {
semutaHomeworldChoamMovementResources as resources,
semutaHomeworldChoamMovementStock as stock,
withoutSemutaHomeworldChoamMovementHolder,
} from './fixture-semuta-homeworld-choam-movement';
import { semutaHomeworldChoamReturnsForces } from './fixture-semuta-homeworld-choam-returns';
import {
cancelSemutaHomeworldChoamKarama, createSemutaHomeworldChoamKaramaFixture,
reactSemutaHomeworldChoamKarama, type SemutaHomeworldChoamKaramaFixture,
} from './fixture-semuta-homeworld-choam-karama';
function assertCancellation(f: SemutaHomeworldChoamKaramaFixture, done: Game, claim: boolean) {
assert.ok(!done.pendingTreacheryDiscard);
assert.ok(!done.response);
assert.equal(done.turn, f.beforeDeclaration.turn);
assert.equal(done.phase, f.beforeDeclaration.phase);
assert.deepEqual(stock(done), stock(f.beforeDeclaration));
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.beforeDeclaration));
assert.equal(player(done, f.owner).hand.filter(card => card.id === f.card).length, 1,
'Canceled printed fuel stays with its original CHOAM owner');
assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), false);
assert.equal(done.discard.some(card => card.id === f.card), false);
assert.equal(player(done, f.canceller).hand.some(card => card.id === f.karama), false);
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
for (const seat of done.players) {
assert.equal(seat.hand.length, player(f.beforeDeclaration, seat.id).hand.length - Number(seat.id === f.canceller),
'Only Emperor loses a card; a claim is an exact one-for-one Semuta exchange');
assert.equal(seat.spice, player(f.beforeDeclaration, seat.id).spice);
assert.equal(seat.revived, player(f.beforeDeclaration, seat.id).revived);
assert.equal(seat.freeForcesRevived, player(f.beforeDeclaration, seat.id).freeForcesRevived);
}
assert.throws(() => applyAction(done, f.canceller, f.cancelAction), 'Paid original Karama cannot cancel again');
assert.throws(() => applyAction(done, f.owner, f.action), 'Retained printed fuel cannot replay this phase');
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
'Only Karama is recovered; Richese never acquires the canceled CHOAM power');
if (f.power === 'baliset') {
const movement = f.movement!;
const declined = applyAction(f.beforeDeclaration, f.owner, { type: 'decision', decline: true });
assert.deepEqual(resources(done), resources(declined),
'Canceling Baliset commits exactly the original unmodified reactive move');
assert.equal(player(done, f.target).moved, 1);
assert.equal(player(done, f.target).forces[movement.origin], player(movement.beforeMove, f.target).forces[movement.origin] - 1);
assert.equal(player(done, f.target).forces[movement.destination], 1);
assert.ok(!done.pendingChoamMove);
assert.deepEqual(viewGame(done, f.target).balisetRestrictions, []);
assert.throws(() => applyAction(done, f.target, movement.move), 'The original move cannot resume twice');
assert.equal(player(done, f.owner).reserves, player(movement.afterShipment, f.owner).reserves);
} else {
assert.deepEqual(resources(done), resources(f.beforeDeclaration),
'Canceling a power never applies its original resources or counters');
if (f.power === 'kulon') {
assert.equal(viewGame(done, f.owner).choamMovementBonus, 0);
assert.throws(() => applyAction(done, f.owner, f.movement!.move), 'Kulon cancellation gives no range-two bonus');
assert.equal(player(done, f.owner).reserves, player(f.movement!.afterShipment, f.owner).reserves);
} else if (f.power === 'laLaLa') {
const before = viewGame(f.beforeDeclaration, f.target).revival;
const after = viewGame(done, f.target).revival;
assert.ok(before.freeRemaining > 0);
assert.equal(after.freeRemaining, before.freeRemaining);
assert.equal(after.freeBlocked, false);
assert.equal(after.limit, before.limit);
for (const seat of done.players)
assert.equal(viewGame(done, seat.id).revival.freeRemaining, viewGame(f.beforeDeclaration, seat.id).revival.freeRemaining);
} else {
assert.deepEqual(viewGame(done, f.target).homeworlds!.worlds!.map(world => world.forces),
viewGame(f.beforeDeclaration, f.target).homeworlds!.worlds!.map(world => world.forces));
assert.equal(done.decision?.kind, 'choamMentat');
assert.equal(done.decision!.player, f.owner);
assert.equal(done.choamMentatPending, true);
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
}
}
}
function consumeCancellationSuffix(f: SemutaHomeworldChoamKaramaFixture, state: Game) {
const after = f.power === 'gamont'
? applyAction(state, f.owner, { type: 'decision', done: true })
: advanceSemutaHomeworldChoam(state, game => game.phase > state.phase
&& !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard);
assert.equal(after.status, 'playing');
assert.equal(after.turn, state.turn + Number(f.power === 'gamont'));
assert.deepEqual(stock(after), stock(state));
assert.deepEqual(semutaHomeworldChoamReturnsForces(after), semutaHomeworldChoamReturnsForces(state));
for (const seat of after.players) {
const before = player(state, seat.id);
assert.deepEqual(seat.forces, before.forces, 'Native suffix cannot run a canceled move or return a second time');
assert.deepEqual(seat.elites, before.elites);
assert.equal(seat.reserves, before.reserves);
assert.equal(seat.tanks, before.tanks);
}
if (f.power === 'gamont') {
assert.equal(after.phase, 0);
assert.equal(after.choamMentatPending, false);
assert.throws(() => applyAction(after, f.owner, { type: 'decision', done: true }),
'The original CHOAM close is consumed exactly once');
} else assert.ok(after.phase >= (f.power === 'laLaLa' ? 5 : 6));
}
for (const advanced of [false, true]) for (const power of ['kulon', 'baliset', 'laLaLa', 'gamont'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed ${power} cancellation`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claims' : 'passes'} only original Emperor Karama before one native denial suffix`, () => {
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced);
if (f.movement) {
assert.equal(player(f.movement.afterShipment, f.owner).spice, player(f.movement.beforeShipment, f.owner).spice - 2);
assert.equal(player(f.movement.afterShipment, f.owner).reserves, player(f.movement.beforeShipment, f.owner).reserves - 1);
}
const pending = cancelSemutaHomeworldChoamKarama(f);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'Actual Karama spending pauses before printed denial and original continuation');
assert.equal(pending.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), true);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(resources(pending), resources(f.beforeDeclaration), 'Original move/return stays uncommitted during recovery');
assert.deepEqual(stock(pending), stock(f.beforeDeclaration));
assert.throws(() => applyAction(pending, f.canceller, f.cancelAction));
assert.throws(() => applyAction(pending, f.owner, f.action));
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
if (f.movement) assert.throws(() => applyAction(pending, f.target, f.movement!.move));
const done = reactSemutaHomeworldChoamKarama(structuredClone(pending), f.holder, claim);
assertCancellation(f, done, claim);
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event }));
consumeCancellationSuffix(f, done);
});
void test(`${label}: actual entitlement is private and cancellation cost is a singleton exchange`, () => {
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced, power === 'kulon' || power === 'baliset');
const pending = cancelSemutaHomeworldChoamKarama(f);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const neutral = cancelSemutaHomeworldChoamKarama(f, absent);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(pending, seat.id,
{ type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event }));
}
const done = reactSemutaHomeworldChoamKarama(pending, f.holder, true);
assertCancellation(f, done, true);
if (f.movement) assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)),
'Printed Karama exchange remains legal at the native hand limit');
const noHolder = reactSemutaHomeworldChoamKarama(neutral, f.holder, false);
assert.equal(noHolder.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(resources(noHolder), resources(done));
consumeCancellationSuffix(f, done);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} legally claims or passes ${label} then consumes original controls`, () => {
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced);
let game = cancelSemutaHomeworldChoamKarama(f);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
game = applyAction(game, f.holder, action);
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldChoamKarama(game, f.holder, false);
assertCancellation(f, game, action.type === 'semutaCommit');
if (power === 'gamont') {
const closing = viewGame(game, f.owner);
closing.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const close = botActions(closing)[0];
assert.ok(close);
const done = applyAction(game, f.owner, close);
assert.equal(done.phase, 0);
assert.equal(done.turn, game.turn + 1);
assert.equal(done.choamMentatPending, false);
assert.deepEqual(player(done, f.target).forces, player(game, f.target).forces);
assert.deepEqual(done.homeworlds?.custody, game.homeworlds?.custody);
assert.deepEqual(stock(done), stock(game));
} else consumeCancellationSuffix(f, game);
});
}
+280
View File
@@ -0,0 +1,280 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { splitLocation } from '../game/board';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { semutaHomeworldChoamMovementResources as resources,
semutaHomeworldChoamMovementStock as stock,
withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { createSemutaHomeworldChoamStormFixture, disposeSemutaHomeworldChoamStorm,
finishSemutaHomeworldChoamStorm, type SemutaHomeworldChoamStormFixture } from './fixture-semuta-homeworld-choam-storm';
/** Sectors that the fixture's actual Weather dial really traverses this turn. */
function stormCrossedSectors(fixture: SemutaHomeworldChoamStormFixture): number[] {
return Array.from({ length: fixture.weatherAmount },
(_, step) => (fixture.beforeWeather.storm + step) % 18 + 1);
}
function assertConservedCounters(game: Game, original: Game) {
for (const seat of game.players) {
const before = player(original, seat.id);
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0),
before.reserves + before.tanks + Object.values(before.forces).reduce((sum, amount) => sum + amount, 0));
assert.ok(Object.values(seat.forces).every(amount => amount > 0));
if (seat.elites && before.elites) {
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0),
before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((sum, amount) => sum + amount, 0));
for (const [key, amount] of Object.entries(seat.elites.forces))
assert.ok(amount >= 0 && amount <= (seat.forces[key] ?? 0));
}
}
assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody);
}
for (const advanced of [false, true]) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Jubba`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} protects the genuinely shipped original counter and resumes one Weather storm`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const inventory = stock(f.game), paid = resources(f.game);
assert.equal(f.afterShipment.turn, 1);
assert.equal(f.afterShipment.phase, 5);
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2);
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
assert.equal(player(f.afterShipment, f.owner).forces[f.origin], 1);
assert.equal(player(f.game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
assert.equal(f.beforeWeather.turn, 2);
assert.equal(f.beforeWeather.phase, 0);
assert.equal(f.beforeWeather.storm, 1);
assert.notEqual(f.weatherOwner, f.owner);
assert.equal(player(f.beforeWeather, f.weatherOwner).hand.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(f.afterWeather, f.weatherOwner).hand.some(card => card.id === f.weatherCard), false);
assert.equal(f.afterWeather.stormPending, f.weatherAmount);
assert.equal(f.game.stormMovementSource?.kind, 'weather');
assert.equal(f.game.stormMovementSource?.distance, f.weatherAmount);
assert.equal(f.game.discard.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(f.game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true,
'The ordinary Weather receipt was passed natively, not claimed');
assert.equal(f.game.decision?.kind, 'choamStorm');
if (f.game.decision?.kind !== 'choamStorm') throw Error('Missing original Storm protection decision');
assert.equal(f.game.decision.player, f.owner);
assert.throws(() => applyAction(f.game, f.weatherOwner, f.action),
'Another original seat cannot protect itself using CHOAM’s printed card');
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, territory: 'arrakeen' }),
'The original foreign Atreides territory is not a CHOAM protection target');
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, target: f.weatherOwner }),
'The real CHOAM decision cannot redirect protection to a foreign faction');
assert.deepEqual(stock(f.game), stock(f.setup));
assertConservedCounters(f.game, f.setup);
const declared = applyAction(f.game, f.owner, f.action);
assert.equal(declared.response?.kind, 'choamWorthless');
assert.equal(declared.pendingTreacheryDiscard, null, 'Jubba cannot be disposed before original Karama passes');
assert.equal(player(declared, f.owner).hand.some(card => card.id === f.card), true);
const offered = disposeSemutaHomeworldChoamStorm(f);
assert.equal(offered.pendingTreacheryDiscard?.continuation.kind, 'choamJubbaDiscard');
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(offered.storm, f.game.storm);
assert.equal(offered.stormResolution?.distance, f.weatherAmount);
assert.deepEqual(offered.stormResolution?.choamProtected ?? [], []);
assert.deepEqual(resources(offered), paid);
assert.deepEqual(stock(offered), inventory);
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
const event = offered.pendingTreacheryDiscard!.batch.event;
assert.throws(() => applyAction(offered, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(offered, f.owner, f.action));
assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` }));
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.stormResolution, null);
assert.equal(resumed.storm, 7);
assert.equal(resumed.decision?.kind, 'choamMarket');
assert.equal(resumed.turn, 2);
assert.equal(player(resumed, f.owner).forces[f.origin], 1);
assert.equal(player(resumed, f.owner).tanks, 0);
assert.deepEqual(resources(resumed).players, paid.players,
'Protection moves no counter, pays no new cost and does not repeat the shipment');
const protectedSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(resumed.spice, protectedSpice,
'The original storm destroys spice only in the sectors it crosses');
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(resumed.discard.filter(card => card.id === f.weatherCard).length, 1);
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.deepEqual(stock(resumed), inventory);
assertConservedCounters(resumed, f.setup);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaPass', event }));
assert.throws(() => applyAction(resumed, f.owner, f.action));
assert.throws(() => applyAction(resumed, f.holder, f.action));
const completed = finishSemutaHomeworldChoamStorm(resumed);
assert.equal(completed.turn, 2);
assert.equal(completed.phase, 1);
assert.equal(completed.storm, 7);
assert.equal(player(completed, f.owner).forces[f.origin], 1);
assert.equal(player(completed, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(completed, f.owner).spice, player(f.game, f.owner).spice);
assert.deepEqual(stock(completed), inventory);
assertConservedCounters(completed, f.setup);
});
void test(`${label}: full-hand singleton exchange keeps entitlement private and rejects replayed receipt controls`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const offered = disposeSemutaHomeworldChoamStorm(f);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const neutral = disposeSemutaHomeworldChoamStorm(f, absent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
const event = offered.pendingTreacheryDiscard!.batch.event;
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, seat.id, { type: 'semutaCommit', event }));
}
assert.throws(() => applyAction(offered, 'outside-seat', { type: 'semutaPass', event }));
const passed = applyAction(offered, f.owner, { type: 'semutaPass', event });
assert.throws(() => applyAction(passed, f.owner, { type: 'semutaPass', event }));
assert.equal(passed.storm, 1);
assert.equal(player(passed, f.owner).forces[f.origin], 1);
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.deepEqual(stock(claimed), stock(f.game));
assertConservedCounters(claimed, f.setup);
assert.equal(player(finishSemutaHomeworldChoamStorm(claimed), f.owner).forces[f.origin], 1);
});
void test(`${label}: all four minimal bot profiles legally claim or pass and finish the original Weather storm`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const offered = disposeSemutaHomeworldChoamStorm(f);
for (const difficulty of DIFFICULTIES) {
let game: Game = structuredClone(offered);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action, `${difficulty} has an entitled receipt action`);
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
game = applyAction(game, f.holder, action);
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!;
game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.storm, 7);
assert.equal(player(game, f.owner).forces[f.origin], 1);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
assert.deepEqual(resources(game).players, resources(f.game).players);
const botSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(game.spice, botSpice);
assert.deepEqual(stock(game), stock(f.game));
assertConservedCounters(game, f.setup);
const completed = finishSemutaHomeworldChoamStorm(game);
assert.equal(completed.phase, 1);
assert.equal(player(completed, f.owner).forces[f.origin], 1);
}
});
void test(`${label}: native decline leaves Jubba undisposed and the single unprotected original counter dies once`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.equal(declined.storm, 7);
assert.equal(declined.stormResolution, null);
assert.equal(declined.decision?.kind, 'choamMarket');
assert.equal(player(declined, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(declined, f.owner).tanks, 1);
assert.equal(player(declined, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(declined, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(declined.discard.filter(card => card.id === f.card).length, 0);
assert.equal(player(declined, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
assert.equal(player(declined, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.deepEqual(stock(declined), stock(f.game));
assertConservedCounters(declined, f.setup);
assert.throws(() => applyAction(declined, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(declined, f.owner, f.action));
assert.equal(finishSemutaHomeworldChoamStorm(declined).phase, 1);
});
for (const claim of [false, true]) void test(`${label}: real Karama cancellation Semuta ${claim ? 'claim' : 'pass'} decides the physical Karama while Jubba stays and the counter dies unprotected`, () => {
const f = createSemutaHomeworldChoamStormFixture(advanced);
const inventory = stock(f.game), paid = resources(f.game);
const karama = player(f.game, f.canceller).hand.find(card => card.name === 'Karama');
assert.ok(karama);
const declared = applyAction(f.game, f.owner, f.action);
assert.equal(declared.response?.kind, 'choamWorthless');
const cancelled = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
assert.equal(cancelled.response, null);
assert.equal(cancelled.pendingTreacheryDiscard?.continuation.kind, 'karamaChoamWorthlessDiscard');
assert.equal(cancelled.decision, null);
assert.equal(cancelled.storm, f.game.storm);
assert.equal(player(cancelled, f.owner).forces[f.origin], 1);
assert.equal(player(cancelled, f.owner).tanks, 0);
assert.equal(player(cancelled, f.owner).hand.filter(card => card.id === f.card).length, 1,
'A cancelled declaration never disposes the retained Jubba');
assert.equal(viewGame(cancelled, f.holder).semutaReaction?.canCommit, true);
const event = cancelled.pendingTreacheryDiscard!.batch.event;
for (const seat of cancelled.players.filter(seat => seat.id !== f.holder))
assert.throws(() => applyAction(cancelled, seat.id, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(cancelled, f.canceller, f.action),
'Jubba cannot be declared again during the Karama receipt');
assert.throws(() => applyAction(cancelled, f.canceller, { type: 'decision', decline: true }));
assert.equal(cancelled.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const resumed = reactSemutaHomeworldChoam(structuredClone(cancelled), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.decision?.kind, 'choamStorm', 'The safe original protection decision is reoffered');
assert.equal(player(resumed, f.owner).forces[f.origin], 1);
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, 0);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === karama.id).length, claim ? 0 : 1);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.deepEqual(stock(resumed), inventory);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
const resolved = applyAction(resumed, f.owner, { type: 'decision', decline: true });
assert.equal(resolved.storm, 7);
assert.equal(resolved.stormResolution, null);
assert.equal(resolved.decision?.kind, 'choamMarket');
assert.equal(player(resolved, f.owner).forces[f.origin] ?? 0, 0, 'Only the threatened original counter is lost');
assert.equal(player(resolved, f.owner).tanks, 1);
assert.equal(player(resolved, f.owner).reserves, player(f.game, f.owner).reserves);
assert.equal(player(resolved, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.card).length, 1);
assert.equal(player(resolved, f.owner).hand.filter(card => card.id === f.retainedKulon).length, 1);
assert.equal(player(resolved, f.holder).hand.filter(card => card.id === karama.id).length, claim ? 1 : 0);
assert.deepEqual(resources(resolved).players, paid.players.map(seat => seat.id === f.owner
? { ...seat, forces: {}, tanks: 1 }
: seat), 'Only the genuinely threatened original counter becomes a Tank');
const crossedSpice = Object.fromEntries(Object.entries(f.game.spice)
.filter(([key]) => !stormCrossedSectors(f).includes(splitLocation(key).sector)));
assert.deepEqual(resolved.spice, crossedSpice);
assert.deepEqual(stock(resolved), inventory);
assertConservedCounters(resolved, f.setup);
assert.throws(() => applyAction(resolved, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(resolved, f.canceller, { type: 'card', mode: 'cancel', card: karama.id }));
const completed = finishSemutaHomeworldChoamStorm(resolved);
assert.equal(completed.phase, 1);
assert.equal(completed.turn, 2);
assert.equal(player(completed, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(completed, f.owner).tanks, 1);
assert.deepEqual(stock(completed), inventory);
assertConservedCounters(completed, f.setup);
});
}